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 let Node::FnDecl { name, .. } = &inner.node else {
165 continue;
166 };
167 let Some(predicate) =
168 predicate_from_attributes(source, name, attrs, inner.span, &mut diagnostics)
169 else {
170 continue;
171 };
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) -> Option<DiscoveredPredicate> {
195 let invariant = attrs.iter().find(|a| a.name == "invariant")?;
198 if !invariant.args.is_empty() {
199 return None;
200 }
201
202 let deterministic = attrs.iter().any(|a| a.name == "deterministic");
203 let semantic = attrs.iter().any(|a| a.name == "semantic");
204 let kind = match (deterministic, semantic) {
205 (true, true) => {
206 diagnostics.push(DiscoveryDiagnostic {
207 severity: DiagnosticSeverity::Error,
208 message: format!(
209 "predicate `{name}` declares both `@deterministic` and \
210 `@semantic`; pick exactly one"
211 ),
212 span: Some(span),
213 });
214 PredicateKind::Deterministic
215 }
216 (false, false) => {
217 PredicateKind::Deterministic
220 }
221 (true, false) => PredicateKind::Deterministic,
222 (false, true) => PredicateKind::Semantic,
223 };
224
225 let archivist = attrs
226 .iter()
227 .find(|a| a.name == "archivist")
228 .map(parse_archivist_attribute);
229 if archivist.is_none() {
230 diagnostics.push(DiscoveryDiagnostic {
231 severity: DiagnosticSeverity::Warning,
232 message: format!(
233 "predicate `{name}` is missing `@archivist(...)` provenance \
234 (evidence, confidence, source_date, coverage_examples)"
235 ),
236 span: Some(span),
237 });
238 }
239
240 let retroactive = attrs.iter().any(|a| a.name == "retroactive");
241 let fallback = attrs
242 .iter()
243 .find(|a| a.name == "semantic")
244 .and_then(parse_semantic_fallback);
245 let fallback_policy = attrs
246 .iter()
247 .find(|a| a.name == "semantic")
248 .map(|attr| parse_semantic_fallback_policy(attr, name, span, diagnostics))
249 .unwrap_or_default();
250 if kind == PredicateKind::Semantic && fallback.is_none() {
251 diagnostics.push(DiscoveryDiagnostic {
252 severity: DiagnosticSeverity::Error,
253 message: format!(
254 "semantic predicate `{name}` must declare a deterministic fallback with \
255 `@semantic(fallback: \"predicate_name\")`"
256 ),
257 span: Some(span),
258 });
259 }
260 let source_hash = predicate_source_hash(source, attrs, span);
261
262 Some(DiscoveredPredicate {
263 name: name.to_string(),
264 kind,
265 fallback,
266 fallback_policy,
267 archivist,
268 retroactive,
269 source_hash,
270 span,
271 })
272}
273
274fn parse_semantic_fallback(attr: &Attribute) -> Option<String> {
275 attr.args
276 .iter()
277 .find(|arg| arg.name.as_deref() == Some("fallback"))
278 .or_else(|| attr.args.iter().find(|arg| arg.name.is_none()))
279 .and_then(identifier_or_string_arg)
280}
281
282fn parse_semantic_fallback_policy(
283 attr: &Attribute,
284 predicate_name: &str,
285 span: Span,
286 diagnostics: &mut Vec<DiscoveryDiagnostic>,
287) -> SemanticFallbackPolicy {
288 let Some(value) = attr
289 .args
290 .iter()
291 .find(|arg| arg.name.as_deref() == Some("policy"))
292 .and_then(identifier_or_string_arg)
293 else {
294 return SemanticFallbackPolicy::Enforce;
295 };
296 match value.as_str() {
297 "enforce" => SemanticFallbackPolicy::Enforce,
298 "advisory" => SemanticFallbackPolicy::Advisory,
299 _ => {
300 diagnostics.push(DiscoveryDiagnostic {
301 severity: DiagnosticSeverity::Error,
302 message: format!(
303 "semantic predicate `{predicate_name}` has unsupported fallback policy \
304 `{value}`; expected `enforce` or `advisory`"
305 ),
306 span: Some(span),
307 });
308 SemanticFallbackPolicy::Enforce
309 }
310 }
311}
312
313fn validate_semantic_fallbacks(files: &mut [DiscoveredInvariantFile]) {
314 let mut visible_deterministic = BTreeMap::<String, PredicateHash>::new();
315
316 for file in files {
317 for predicate in &file.predicates {
318 if predicate.kind == PredicateKind::Deterministic {
319 visible_deterministic.insert(predicate.name.clone(), predicate.source_hash.clone());
320 }
321 }
322
323 let diagnostics = file
324 .predicates
325 .iter()
326 .filter(|predicate| predicate.kind == PredicateKind::Semantic)
327 .filter_map(|predicate| {
328 let fallback = predicate.fallback.as_ref()?;
329 if visible_deterministic.contains_key(fallback) {
330 return None;
331 }
332 Some(DiscoveryDiagnostic {
333 severity: DiagnosticSeverity::Error,
334 message: format!(
335 "semantic predicate `{}` fallback `{fallback}` must name a \
336 deterministic predicate in the same invariants.harn file or an ancestor file",
337 predicate.name
338 ),
339 span: Some(predicate.span),
340 })
341 })
342 .collect::<Vec<_>>();
343 file.diagnostics.extend(diagnostics);
344 }
345}
346
347fn predicate_source_hash(source: &str, attrs: &[Attribute], span: Span) -> PredicateHash {
348 let start = attrs
349 .iter()
350 .map(|attr| attr.span.start)
351 .min()
352 .unwrap_or(span.start)
353 .min(source.len());
354 let end = span.end.min(source.len()).max(start);
355 let bytes = &source.as_bytes()[start..end];
356 PredicateHash::new(format!("sha256:{}", hex::encode(Sha256::digest(bytes))))
357}
358
359fn parse_archivist_attribute(attr: &Attribute) -> ArchivistMetadata {
360 let mut metadata = ArchivistMetadata::default();
361 for arg in &attr.args {
362 let Some(name) = arg.name.as_deref() else {
363 continue;
364 };
365 match name {
366 "evidence" => metadata.evidence = string_list_arg(arg),
367 "confidence" => metadata.confidence = number_arg(arg),
368 "source_date" => metadata.source_date = string_arg(arg),
369 "coverage_examples" => metadata.coverage_examples = string_list_arg(arg),
370 _ => {}
371 }
372 }
373 metadata
374}
375
376fn string_arg(arg: &AttributeArg) -> Option<String> {
377 match &arg.value.node {
378 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
379 _ => None,
380 }
381}
382
383fn identifier_or_string_arg(arg: &AttributeArg) -> Option<String> {
384 match &arg.value.node {
385 Node::Identifier(s) | Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
386 _ => None,
387 }
388}
389
390fn number_arg(arg: &AttributeArg) -> Option<f64> {
391 match &arg.value.node {
392 Node::FloatLiteral(f) => Some(*f),
393 Node::IntLiteral(i) => Some(*i as f64),
394 _ => None,
395 }
396}
397
398fn string_list_arg(arg: &AttributeArg) -> Vec<String> {
399 match &arg.value.node {
400 Node::ListLiteral(items) => items
401 .iter()
402 .filter_map(|item| match &item.node {
403 Node::StringLiteral(s) | Node::RawStringLiteral(s) => Some(s.clone()),
404 _ => None,
405 })
406 .collect(),
407 Node::StringLiteral(s) | Node::RawStringLiteral(s) => vec![s.clone()],
408 _ => Vec::new(),
409 }
410}
411
412fn candidate_directories(root: &Path, target_dir: &Path) -> Vec<PathBuf> {
418 let mut chain = vec![root.to_path_buf()];
419
420 let relative = target_dir.strip_prefix(root).unwrap_or_else(|_| {
423 if target_dir.is_absolute() {
424 Path::new("")
425 } else {
426 target_dir
427 }
428 });
429
430 let mut current = root.to_path_buf();
431 for component in relative.components() {
432 use std::path::Component;
433 match component {
434 Component::Normal(name) => {
435 current.push(name);
436 chain.push(current.clone());
437 }
438 Component::CurDir => {}
439 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
441 continue;
442 }
443 }
444 }
445
446 chain
447}
448
449fn relative_dir_label(root: &Path, dir: &Path) -> String {
450 let rel = dir.strip_prefix(root).unwrap_or(dir);
451 let mut parts: Vec<String> = Vec::new();
452 for component in rel.components() {
453 if let std::path::Component::Normal(name) = component {
454 parts.push(name.to_string_lossy().into_owned());
455 }
456 }
457 if parts.is_empty() {
458 ".".to_string()
459 } else {
460 parts.join("/")
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::flow::resolve_predicates;
468 use std::fs;
469 use tempfile::TempDir;
470
471 fn write(dir: &Path, name: &str, contents: &str) {
472 fs::create_dir_all(dir).unwrap();
473 fs::write(dir.join(name), contents).unwrap();
474 }
475
476 fn sample_predicate(name: &str) -> String {
477 format!(
478 r#"
479@invariant
480@deterministic
481@archivist(evidence: ["https://example.com/spec"], confidence: 0.95, source_date: "2026-04-01")
482fn {name}(slice) -> bool {{
483 return true
484}}
485"#
486 )
487 }
488
489 #[test]
490 fn discover_walks_from_root_to_leaf() {
491 let tmp = TempDir::new().unwrap();
492 let root = tmp.path();
493 write(root, INVARIANTS_FILE, &sample_predicate("root_check"));
494 let nested = root.join("crates").join("foo");
495 write(&nested, INVARIANTS_FILE, &sample_predicate("inner_check"));
496
497 let files = discover_invariants(root, &nested);
498 let labels: Vec<_> = files.iter().map(|f| f.relative_dir.clone()).collect();
499 assert_eq!(labels, vec![".".to_string(), "crates/foo".to_string()]);
500 assert_eq!(files[0].predicates[0].name, "root_check");
501 assert_eq!(files[0].predicates[0].kind, PredicateKind::Deterministic);
502 assert_eq!(files[1].predicates[0].name, "inner_check");
503 }
504
505 #[test]
506 fn discover_clamps_parent_dir_traversal() {
507 let tmp = TempDir::new().unwrap();
508 let root = tmp.path().join("repo");
509 fs::create_dir_all(&root).unwrap();
510 write(&root, INVARIANTS_FILE, &sample_predicate("root_check"));
511
512 let files = discover_invariants(&root, Path::new("../../escape"));
513 assert_eq!(files.len(), 1);
514 assert_eq!(files[0].relative_dir, ".");
515 }
516
517 #[test]
518 fn parse_picks_up_archivist_metadata() {
519 let source = sample_predicate("foo");
520 let parsed = parse_invariants_source(&source);
521 assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
522 let pred = &parsed.predicates[0];
523 let arch = pred.archivist.as_ref().expect("archivist present");
524 assert_eq!(arch.evidence, vec!["https://example.com/spec".to_string()]);
525 assert_eq!(arch.confidence, Some(0.95));
526 assert_eq!(arch.source_date.as_deref(), Some("2026-04-01"));
527 }
528
529 #[test]
530 fn parse_pins_predicate_source_hash() {
531 let source = sample_predicate("foo");
532 let parsed = parse_invariants_source(&source);
533 let original = parsed.predicates[0].source_hash.clone();
534
535 let changed = sample_predicate("foo").replace("return true", "return false");
536 let reparsed = parse_invariants_source(&changed);
537 assert_ne!(reparsed.predicates[0].source_hash, original);
538 assert!(original.as_str().starts_with("sha256:"));
539 }
540
541 #[test]
542 fn parse_warns_when_archivist_missing() {
543 let source = r"
544@invariant
545@deterministic
546fn missing_arch(slice) -> bool { return true }
547";
548 let parsed = parse_invariants_source(source);
549 assert_eq!(parsed.predicates.len(), 1);
550 assert!(parsed
551 .diagnostics
552 .iter()
553 .any(|d| d.message.contains("missing `@archivist(...)`")));
554 }
555
556 #[test]
557 fn parse_errors_when_kinds_collide() {
558 let source = r#"
559@invariant
560@deterministic
561@semantic
562@archivist(evidence: ["x"])
563fn both_modes(slice) -> bool { return true }
564"#;
565 let parsed = parse_invariants_source(source);
566 assert!(parsed
567 .diagnostics
568 .iter()
569 .any(|d| d.severity == DiagnosticSeverity::Error
570 && d.message.contains("pick exactly one")));
571 }
572
573 #[test]
574 fn parse_recognises_semantic_mode_and_retroactive() {
575 let source = r#"
576@invariant
577@semantic(fallback: "fallback_check")
578@retroactive
579@archivist(evidence: ["https://x"], confidence: 0.5)
580fn check(slice) -> bool { return true }
581
582@invariant
583@deterministic
584@archivist(evidence: ["https://x"])
585fn fallback_check(slice) -> bool { return true }
586"#;
587 let parsed = parse_invariants_source(source);
588 assert_eq!(parsed.predicates.len(), 2);
589 let pred = &parsed.predicates[0];
590 assert_eq!(pred.kind, PredicateKind::Semantic);
591 assert_eq!(pred.fallback.as_deref(), Some("fallback_check"));
592 assert!(pred.retroactive);
593 }
594
595 #[test]
596 fn parse_recognises_advisory_fallback_policy() {
597 let source = r#"
598@invariant
599@semantic(fallback: "fallback_check", policy: "advisory")
600@archivist(evidence: ["https://x"], confidence: 0.5)
601fn check(slice) -> bool { return true }
602
603@invariant
604@deterministic
605@archivist(evidence: ["https://x"])
606fn fallback_check(slice) -> bool { return true }
607"#;
608 let parsed = parse_invariants_source(source);
609 assert!(parsed
610 .diagnostics
611 .iter()
612 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error));
613 assert_eq!(
614 parsed.predicates[0].fallback_policy,
615 SemanticFallbackPolicy::Advisory
616 );
617 }
618
619 #[test]
620 fn parse_errors_when_semantic_fallback_missing() {
621 let source = r#"
622@invariant
623@semantic
624@archivist(evidence: ["https://x"], confidence: 0.5)
625fn check(slice) -> bool { return true }
626"#;
627 let parsed = parse_invariants_source(source);
628 assert!(parsed.diagnostics.iter().any(|d| {
629 d.severity == DiagnosticSeverity::Error
630 && d.message.contains("must declare a deterministic fallback")
631 }));
632 }
633
634 #[test]
635 fn discover_accepts_semantic_fallback_from_ancestor() {
636 let tmp = TempDir::new().unwrap();
637 let root = tmp.path();
638 write(root, INVARIANTS_FILE, &sample_predicate("root_fallback"));
639 let nested = root.join("crates");
640 write(
641 &nested,
642 INVARIANTS_FILE,
643 r#"
644@invariant
645@semantic(fallback: root_fallback)
646@archivist(evidence: ["https://x"], confidence: 0.5)
647fn semantic_check(slice) -> bool { return true }
648"#,
649 );
650
651 let files = discover_invariants(root, &nested);
652
653 assert!(files
654 .iter()
655 .flat_map(|file| file.diagnostics.iter())
656 .all(|diagnostic| diagnostic.severity != DiagnosticSeverity::Error));
657 let resolved = resolve_predicates(&files);
658 let semantic = resolved
659 .iter()
660 .find(|predicate| predicate.logical_name == "semantic_check")
661 .unwrap();
662 assert_eq!(
663 semantic.fallback_hash,
664 Some(files[0].predicates[0].source_hash.clone())
665 );
666 }
667
668 #[test]
669 fn discover_rejects_semantic_fallback_from_descendant_only() {
670 let tmp = TempDir::new().unwrap();
671 let root = tmp.path();
672 write(
673 root,
674 INVARIANTS_FILE,
675 r#"
676@invariant
677@semantic(fallback: child_fallback)
678@archivist(evidence: ["https://x"], confidence: 0.5)
679fn semantic_check(slice) -> bool { return true }
680"#,
681 );
682 let nested = root.join("crates");
683 write(
684 &nested,
685 INVARIANTS_FILE,
686 &sample_predicate("child_fallback"),
687 );
688
689 let files = discover_invariants(root, &nested);
690
691 assert!(files[0].diagnostics.iter().any(|diagnostic| {
692 diagnostic.severity == DiagnosticSeverity::Error
693 && diagnostic
694 .message
695 .contains("same invariants.harn file or an ancestor file")
696 }));
697 }
698
699 #[test]
700 fn parse_skips_handler_ir_invariants() {
701 let source = r#"
704@invariant("fs.writes", "src/**")
705fn handler_check(slice) -> bool { return true }
706"#;
707 let parsed = parse_invariants_source(source);
708 assert!(parsed.predicates.is_empty(), "{:?}", parsed.predicates);
709 }
710
711 #[test]
712 fn resolve_predicates_keeps_ancestors_for_composition() {
713 let tmp = TempDir::new().unwrap();
714 let root = tmp.path();
715 write(root, INVARIANTS_FILE, &sample_predicate("shared"));
716 let nested = root.join("crates");
717 write(
719 &nested,
720 INVARIANTS_FILE,
721 &format!(
722 "{}{}",
723 sample_predicate("shared"),
724 sample_predicate("extra")
725 ),
726 );
727
728 let files = discover_invariants(root, &nested);
729 let resolved = resolve_predicates(&files);
730 let qualified: Vec<_> = resolved.iter().map(|p| p.qualified_name.clone()).collect();
731 assert!(qualified.contains(&"shared".to_string()));
734 assert!(qualified.contains(&"crates::shared".to_string()));
735 assert!(qualified.contains(&"crates::extra".to_string()));
737 }
738}