1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use harn_lexer::{Lexer, Span};
14use harn_parser::{peel_attributes, Attribute, AttributeArg, Node, Parser};
15use sha2::{Digest, Sha256};
16
17use super::executor::{PredicateKind, SemanticFallbackPolicy};
18use crate::flow::slice::PredicateHash;
19
20pub const INVARIANTS_FILE: &str = "invariants.harn";
22
23#[derive(Clone, Debug)]
26pub struct DiscoveredInvariantFile {
27 pub path: PathBuf,
29 pub relative_dir: String,
31 pub source: String,
33 pub predicates: Vec<DiscoveredPredicate>,
35 pub diagnostics: Vec<DiscoveryDiagnostic>,
37}
38
39#[derive(Clone, Debug)]
41pub struct DiscoveredPredicate {
42 pub name: String,
45 pub kind: PredicateKind,
47 pub fallback: Option<String>,
50 pub fallback_policy: SemanticFallbackPolicy,
53 pub archivist: Option<ArchivistMetadata>,
55 pub retroactive: bool,
58 pub source_hash: PredicateHash,
62 pub span: Span,
64}
65
66#[derive(Clone, Debug, Default, PartialEq)]
68pub struct ArchivistMetadata {
69 pub evidence: Vec<String>,
70 pub confidence: Option<f64>,
71 pub source_date: Option<String>,
72 pub coverage_examples: Vec<String>,
73}
74
75#[derive(Clone, Debug)]
79pub struct DiscoveryDiagnostic {
80 pub severity: DiagnosticSeverity,
81 pub message: String,
82 pub span: Option<Span>,
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum DiagnosticSeverity {
87 Warning,
88 Error,
89}
90
91pub fn discover_invariants(root: &Path, target_dir: &Path) -> Vec<DiscoveredInvariantFile> {
101 let mut files = Vec::new();
102 let candidates = candidate_directories(root, target_dir);
103
104 for dir in candidates {
105 let path = dir.join(INVARIANTS_FILE);
106 if !path.is_file() {
107 continue;
108 }
109 let source = match std::fs::read_to_string(&path) {
110 Ok(s) => s,
111 Err(_) => continue,
112 };
113 let relative_dir = relative_dir_label(root, &dir);
114 let parsed = parse_invariants_source(&source);
115 files.push(DiscoveredInvariantFile {
116 path,
117 relative_dir,
118 source,
119 predicates: parsed.predicates,
120 diagnostics: parsed.diagnostics,
121 });
122 }
123
124 validate_semantic_fallbacks(&mut files);
125 files
126}
127
128pub fn parse_invariants_source(source: &str) -> ParsedInvariantFile {
131 let mut diagnostics = Vec::new();
132 let tokens = match Lexer::new(source).tokenize() {
133 Ok(t) => t,
134 Err(error) => {
135 diagnostics.push(DiscoveryDiagnostic {
136 severity: DiagnosticSeverity::Error,
137 message: format!("lex error: {error:?}"),
138 span: None,
139 });
140 return ParsedInvariantFile {
141 predicates: Vec::new(),
142 diagnostics,
143 };
144 }
145 };
146 let program = match Parser::new(tokens).parse() {
147 Ok(p) => p,
148 Err(error) => {
149 diagnostics.push(DiscoveryDiagnostic {
150 severity: DiagnosticSeverity::Error,
151 message: format!("parse error: {error:?}"),
152 span: None,
153 });
154 return ParsedInvariantFile {
155 predicates: Vec::new(),
156 diagnostics,
157 };
158 }
159 };
160
161 let mut predicates = Vec::new();
162 for node in &program {
163 let (attrs, inner) = peel_attributes(node);
164 if !harn_parser::is_flow_predicate_declaration(attrs, inner) {
165 continue;
166 }
167 let Node::FnDecl { name, .. } = &inner.node else {
168 continue;
169 };
170 let predicate =
171 predicate_from_attributes(source, name, attrs, inner.span, &mut diagnostics);
172 predicates.push(predicate);
173 }
174
175 ParsedInvariantFile {
176 predicates,
177 diagnostics,
178 }
179}
180
181#[derive(Clone, Debug, Default)]
183pub struct ParsedInvariantFile {
184 pub predicates: Vec<DiscoveredPredicate>,
185 pub diagnostics: Vec<DiscoveryDiagnostic>,
186}
187
188fn predicate_from_attributes(
189 source: &str,
190 name: &str,
191 attrs: &[Attribute],
192 span: Span,
193 diagnostics: &mut Vec<DiscoveryDiagnostic>,
194) -> DiscoveredPredicate {
195 let deterministic = attrs.iter().any(|a| a.name == "deterministic");
196 let semantic = attrs.iter().any(|a| a.name == "semantic");
197 let kind = match (deterministic, semantic) {
198 (true, true) => {
199 diagnostics.push(DiscoveryDiagnostic {
200 severity: DiagnosticSeverity::Error,
201 message: format!(
202 "predicate `{name}` declares both `@deterministic` and \
203 `@semantic`; pick exactly one"
204 ),
205 span: Some(span),
206 });
207 PredicateKind::Deterministic
208 }
209 (false, false) => {
210 PredicateKind::Deterministic
213 }
214 (true, false) => PredicateKind::Deterministic,
215 (false, true) => PredicateKind::Semantic,
216 };
217
218 let archivist = attrs
219 .iter()
220 .find(|a| a.name == "archivist")
221 .map(parse_archivist_attribute);
222 if archivist.is_none() {
223 diagnostics.push(DiscoveryDiagnostic {
224 severity: DiagnosticSeverity::Warning,
225 message: format!(
226 "predicate `{name}` is missing `@archivist(...)` provenance \
227 (evidence, confidence, source_date, coverage_examples)"
228 ),
229 span: Some(span),
230 });
231 }
232
233 let retroactive = attrs.iter().any(|a| a.name == "retroactive");
234 let fallback = attrs
235 .iter()
236 .find(|a| a.name == "semantic")
237 .and_then(parse_semantic_fallback);
238 let fallback_policy = attrs
239 .iter()
240 .find(|a| a.name == "semantic")
241 .map(|attr| parse_semantic_fallback_policy(attr, name, span, diagnostics))
242 .unwrap_or_default();
243 if kind == PredicateKind::Semantic && fallback.is_none() {
244 diagnostics.push(DiscoveryDiagnostic {
245 severity: DiagnosticSeverity::Error,
246 message: format!(
247 "semantic predicate `{name}` must declare a deterministic fallback with \
248 `@semantic(fallback: \"predicate_name\")`"
249 ),
250 span: Some(span),
251 });
252 }
253 let source_hash = predicate_source_hash(source, attrs, span);
254
255 DiscoveredPredicate {
256 name: name.to_string(),
257 kind,
258 fallback,
259 fallback_policy,
260 archivist,
261 retroactive,
262 source_hash,
263 span,
264 }
265}
266
267fn parse_semantic_fallback(attr: &Attribute) -> Option<String> {
268 attr.args
269 .iter()
270 .find(|arg| arg.name.as_deref() == Some("fallback"))
271 .or_else(|| attr.args.iter().find(|arg| arg.name.is_none()))
272 .and_then(identifier_or_string_arg)
273}
274
275fn parse_semantic_fallback_policy(
276 attr: &Attribute,
277 predicate_name: &str,
278 span: Span,
279 diagnostics: &mut Vec<DiscoveryDiagnostic>,
280) -> SemanticFallbackPolicy {
281 let Some(value) = attr
282 .args
283 .iter()
284 .find(|arg| arg.name.as_deref() == Some("policy"))
285 .and_then(identifier_or_string_arg)
286 else {
287 return SemanticFallbackPolicy::Enforce;
288 };
289 match value.as_str() {
290 "enforce" => SemanticFallbackPolicy::Enforce,
291 "advisory" => SemanticFallbackPolicy::Advisory,
292 _ => {
293 diagnostics.push(DiscoveryDiagnostic {
294 severity: DiagnosticSeverity::Error,
295 message: format!(
296 "semantic predicate `{predicate_name}` has unsupported fallback policy \
297 `{value}`; expected `enforce` or `advisory`"
298 ),
299 span: Some(span),
300 });
301 SemanticFallbackPolicy::Enforce
302 }
303 }
304}
305
306fn validate_semantic_fallbacks(files: &mut [DiscoveredInvariantFile]) {
307 let mut visible_deterministic = BTreeMap::<String, PredicateHash>::new();
308
309 for file in files {
310 for predicate in &file.predicates {
311 if predicate.kind == PredicateKind::Deterministic {
312 visible_deterministic.insert(predicate.name.clone(), predicate.source_hash.clone());
313 }
314 }
315
316 let diagnostics = file
317 .predicates
318 .iter()
319 .filter(|predicate| predicate.kind == PredicateKind::Semantic)
320 .filter_map(|predicate| {
321 let fallback = predicate.fallback.as_ref()?;
322 if visible_deterministic.contains_key(fallback) {
323 return None;
324 }
325 Some(DiscoveryDiagnostic {
326 severity: DiagnosticSeverity::Error,
327 message: format!(
328 "semantic predicate `{}` fallback `{fallback}` must name a \
329 deterministic predicate in the same invariants.harn file or an ancestor file",
330 predicate.name
331 ),
332 span: Some(predicate.span),
333 })
334 })
335 .collect::<Vec<_>>();
336 file.diagnostics.extend(diagnostics);
337 }
338}
339
340fn predicate_source_hash(source: &str, attrs: &[Attribute], span: Span) -> PredicateHash {
341 let start = attrs
342 .iter()
343 .map(|attr| attr.span.start)
344 .min()
345 .unwrap_or(span.start)
346 .min(source.len());
347 let end = span.end.min(source.len()).max(start);
348 let bytes = &source.as_bytes()[start..end];
349 PredicateHash::new(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
350}
351
352fn parse_archivist_attribute(attr: &Attribute) -> ArchivistMetadata {
353 let mut metadata = ArchivistMetadata::default();
354 for arg in &attr.args {
355 let Some(name) = arg.name.as_deref() else {
356 continue;
357 };
358 match name {
359 "evidence" => metadata.evidence = string_list_arg(arg),
360 "confidence" => metadata.confidence = number_arg(arg),
361 "source_date" => metadata.source_date = string_arg(arg),
362 "coverage_examples" => metadata.coverage_examples = string_list_arg(arg),
363 _ => {}
364 }
365 }
366 metadata
367}
368
369fn string_arg(arg: &AttributeArg) -> Option<String> {
370 match &arg.value.node {
371 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
372 _ => None,
373 }
374}
375
376fn identifier_or_string_arg(arg: &AttributeArg) -> Option<String> {
377 match &arg.value.node {
378 Node::Identifier(s) | Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
379 _ => None,
380 }
381}
382
383fn number_arg(arg: &AttributeArg) -> Option<f64> {
384 match &arg.value.node {
385 Node::FloatLiteral(f) => Some(*f),
386 Node::IntLiteral(i) => Some(*i as f64),
387 _ => None,
388 }
389}
390
391fn string_list_arg(arg: &AttributeArg) -> Vec<String> {
392 match &arg.value.node {
393 Node::ListLiteral(items) => items
394 .iter()
395 .filter_map(|item| match &item.node {
396 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
397 _ => None,
398 })
399 .collect(),
400 Node::StringLiteral(s) | Node::RawStringLiteral(s) => vec![s.clone()],
401 _ => Vec::new(),
402 }
403}
404
405fn candidate_directories(root: &Path, target_dir: &Path) -> Vec<PathBuf> {
411 let mut chain = vec![root.to_path_buf()];
412
413 let relative = target_dir.strip_prefix(root).unwrap_or_else(|_| {
416 if target_dir.is_absolute() {
417 Path::new("")
418 } else {
419 target_dir
420 }
421 });
422
423 let mut current = root.to_path_buf();
424 for component in relative.components() {
425 use std::path::Component;
426 match component {
427 Component::Normal(name) => {
428 current.push(name);
429 chain.push(current.clone());
430 }
431 Component::CurDir => {}
432 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
434 continue;
435 }
436 }
437 }
438
439 chain
440}
441
442fn relative_dir_label(root: &Path, dir: &Path) -> String {
443 let rel = dir.strip_prefix(root).unwrap_or(dir);
444 let mut parts: Vec<String> = Vec::new();
445 for component in rel.components() {
446 if let std::path::Component::Normal(name) = component {
447 parts.push(name.to_string_lossy().into_owned());
448 }
449 }
450 if parts.is_empty() {
451 ".".to_string()
452 } else {
453 parts.join("/")
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::flow::resolve_predicates;
461 use std::fs;
462 use tempfile::TempDir;
463
464 fn write(dir: &Path, name: &str, contents: &str) {
465 fs::create_dir_all(dir).unwrap();
466 fs::write(dir.join(name), contents).unwrap();
467 }
468
469 fn sample_predicate(name: &str) -> String {
470 format!(
471 r#"
472@invariant
473@deterministic
474@archivist(evidence: ["https://example.com/spec"], confidence: 0.95, source_date: "2026-04-01")
475fn {name}(slice) -> bool {{
476 return true
477}}
478"#
479 )
480 }
481
482 #[test]
483 fn discover_walks_from_root_to_leaf() {
484 let tmp = TempDir::new().unwrap();
485 let root = tmp.path();
486 write(root, INVARIANTS_FILE, &sample_predicate("root_check"));
487 let nested = root.join("crates").join("foo");
488 write(&nested, INVARIANTS_FILE, &sample_predicate("inner_check"));
489
490 let files = discover_invariants(root, &nested);
491 let labels: Vec<_> = files.iter().map(|f| f.relative_dir.clone()).collect();
492 assert_eq!(labels, vec![".".to_string(), "crates/foo".to_string()]);
493 assert_eq!(files[0].predicates[0].name, "root_check");
494 assert_eq!(files[0].predicates[0].kind, PredicateKind::Deterministic);
495 assert_eq!(files[1].predicates[0].name, "inner_check");
496 }
497
498 #[test]
499 fn discover_clamps_parent_dir_traversal() {
500 let tmp = TempDir::new().unwrap();
501 let root = tmp.path().join("repo");
502 fs::create_dir_all(&root).unwrap();
503 write(&root, INVARIANTS_FILE, &sample_predicate("root_check"));
504
505 let files = discover_invariants(&root, Path::new("../../escape"));
506 assert_eq!(files.len(), 1);
507 assert_eq!(files[0].relative_dir, ".");
508 }
509
510 #[test]
511 fn parse_picks_up_archivist_metadata() {
512 let source = sample_predicate("foo");
513 let parsed = parse_invariants_source(&source);
514 assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
515 let pred = &parsed.predicates[0];
516 let arch = pred.archivist.as_ref().expect("archivist present");
517 assert_eq!(arch.evidence, vec!["https://example.com/spec".to_string()]);
518 assert_eq!(arch.confidence, Some(0.95));
519 assert_eq!(arch.source_date.as_deref(), Some("2026-04-01"));
520 }
521
522 #[test]
523 fn parse_pins_predicate_source_hash() {
524 let source = sample_predicate("foo");
525 let parsed = parse_invariants_source(&source);
526 let original = parsed.predicates[0].source_hash.clone();
527
528 let changed = sample_predicate("foo").replace("return true", "return false");
529 let reparsed = parse_invariants_source(&changed);
530 assert_ne!(reparsed.predicates[0].source_hash, original);
531 assert!(original.as_str().starts_with("sha256:"));
532 }
533
534 #[test]
535 fn parse_warns_when_archivist_missing() {
536 let source = r"
537@invariant
538@deterministic
539fn missing_arch(slice) -> bool { return true }
540";
541 let parsed = parse_invariants_source(source);
542 assert_eq!(parsed.predicates.len(), 1);
543 assert!(parsed
544 .diagnostics
545 .iter()
546 .any(|d| d.message.contains("missing `@archivist(...)`")));
547 }
548
549 #[test]
550 fn parse_errors_when_kinds_collide() {
551 let source = r#"
552@invariant
553@deterministic
554@semantic
555@archivist(evidence: ["x"])
556fn both_modes(slice) -> bool { return true }
557"#;
558 let parsed = parse_invariants_source(source);
559 assert!(parsed
560 .diagnostics
561 .iter()
562 .any(|d| d.severity == DiagnosticSeverity::Error
563 && d.message.contains("pick exactly one")));
564 }
565
566 #[test]
567 fn parse_recognises_semantic_mode_and_retroactive() {
568 let source = r#"
569@invariant
570@semantic(fallback: "fallback_check")
571@retroactive
572@archivist(evidence: ["https://x"], confidence: 0.5)
573fn check(slice) -> bool { return true }
574
575@invariant
576@deterministic
577@archivist(evidence: ["https://x"])
578fn fallback_check(slice) -> bool { return true }
579"#;
580 let parsed = parse_invariants_source(source);
581 assert_eq!(parsed.predicates.len(), 2);
582 let pred = &parsed.predicates[0];
583 assert_eq!(pred.kind, PredicateKind::Semantic);
584 assert_eq!(pred.fallback.as_deref(), Some("fallback_check"));
585 assert!(pred.retroactive);
586 }
587
588 #[test]
589 fn parse_recognises_advisory_fallback_policy() {
590 let source = r#"
591@invariant
592@semantic(fallback: "fallback_check", policy: "advisory")
593@archivist(evidence: ["https://x"], confidence: 0.5)
594fn check(slice) -> bool { return true }
595
596@invariant
597@deterministic
598@archivist(evidence: ["https://x"])
599fn fallback_check(slice) -> bool { return true }
600"#;
601 let parsed = parse_invariants_source(source);
602 assert!(parsed
603 .diagnostics
604 .iter()
605 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error));
606 assert_eq!(
607 parsed.predicates[0].fallback_policy,
608 SemanticFallbackPolicy::Advisory
609 );
610 }
611
612 #[test]
613 fn parse_errors_when_semantic_fallback_missing() {
614 let source = r#"
615@invariant
616@semantic
617@archivist(evidence: ["https://x"], confidence: 0.5)
618fn check(slice) -> bool { return true }
619"#;
620 let parsed = parse_invariants_source(source);
621 assert!(parsed.diagnostics.iter().any(|d| {
622 d.severity == DiagnosticSeverity::Error
623 && d.message.contains("must declare a deterministic fallback")
624 }));
625 }
626
627 #[test]
628 fn discover_accepts_semantic_fallback_from_ancestor() {
629 let tmp = TempDir::new().unwrap();
630 let root = tmp.path();
631 write(root, INVARIANTS_FILE, &sample_predicate("root_fallback"));
632 let nested = root.join("crates");
633 write(
634 &nested,
635 INVARIANTS_FILE,
636 r#"
637@invariant
638@semantic(fallback: root_fallback)
639@archivist(evidence: ["https://x"], confidence: 0.5)
640fn semantic_check(slice) -> bool { return true }
641"#,
642 );
643
644 let files = discover_invariants(root, &nested);
645
646 assert!(files
647 .iter()
648 .flat_map(|file| file.diagnostics.iter())
649 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error));
650 let resolved = resolve_predicates(&files);
651 let semantic = resolved
652 .iter()
653 .find(|predicate| predicate.logical_name == "semantic_check")
654 .unwrap();
655 assert_eq!(
656 semantic.fallback_hash,
657 Some(files[0].predicates[0].source_hash.clone())
658 );
659 }
660
661 #[test]
662 fn discover_rejects_semantic_fallback_from_descendant_only() {
663 let tmp = TempDir::new().unwrap();
664 let root = tmp.path();
665 write(
666 root,
667 INVARIANTS_FILE,
668 r#"
669@invariant
670@semantic(fallback: child_fallback)
671@archivist(evidence: ["https://x"], confidence: 0.5)
672fn semantic_check(slice) -> bool { return true }
673"#,
674 );
675 let nested = root.join("crates");
676 write(
677 &nested,
678 INVARIANTS_FILE,
679 &sample_predicate("child_fallback"),
680 );
681
682 let files = discover_invariants(root, &nested);
683
684 assert!(files[0].diagnostics.iter().any(|diagnostic| {
685 diagnostic.severity == DiagnosticSeverity::Error
686 && diagnostic
687 .message
688 .contains("same invariants.harn file or an ancestor file")
689 }));
690 }
691
692 #[test]
693 fn parse_skips_handler_ir_invariants() {
694 let source = r#"
697@invariant("fs.writes", "src/**")
698fn handler_check(slice) -> bool { return true }
699"#;
700 let parsed = parse_invariants_source(source);
701 assert!(parsed.predicates.is_empty(), "{:?}", parsed.predicates);
702 }
703
704 #[test]
705 fn parse_discovers_bare_invariants_on_functions_only() {
706 let source = r"
707@invariant
708tool tool_check(slice) -> bool { return true }
709
710@invariant
711pipeline pipeline_check(slice) {}
712
713@invariant
714fn function_check(slice) -> bool { return true }
715";
716 let parsed = parse_invariants_source(source);
717 assert_eq!(
718 parsed
719 .predicates
720 .iter()
721 .map(|predicate| predicate.name.as_str())
722 .collect::<Vec<_>>(),
723 ["function_check"]
724 );
725 }
726
727 #[test]
728 fn resolve_predicates_keeps_ancestors_for_composition() {
729 let tmp = TempDir::new().unwrap();
730 let root = tmp.path();
731 write(root, INVARIANTS_FILE, &sample_predicate("shared"));
732 let nested = root.join("crates");
733 write(
735 &nested,
736 INVARIANTS_FILE,
737 &format!(
738 "{}{}",
739 sample_predicate("shared"),
740 sample_predicate("extra")
741 ),
742 );
743
744 let files = discover_invariants(root, &nested);
745 let resolved = resolve_predicates(&files);
746 let qualified: Vec<_> = resolved.iter().map(|p| p.qualified_name.clone()).collect();
747 assert!(qualified.contains(&"shared".to_string()));
750 assert!(qualified.contains(&"crates::shared".to_string()));
751 assert!(qualified.contains(&"crates::extra".to_string()));
753 }
754}