Skip to main content

harn_vm/flow/predicates/
discovery.rs

1//! Discovery and parsing of `invariants.harn` Flow predicate files.
2//!
3//! Mirrors `metadata_resolve` semantics: predicates declared in higher
4//! directories apply to all descendants. This module owns the walk + parse;
5//! hierarchy merging lives in [`super::compose`], and evaluation lives in
6//! [`super::executor`].
7//!
8//! See parent epic #571 and ticket #579 for the design rationale.
9
10use 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
20/// Filename used for per-directory Flow invariant declarations.
21pub const INVARIANTS_FILE: &str = "invariants.harn";
22
23/// One `invariants.harn` file discovered on disk, with its predicates
24/// already parsed into typed metadata.
25#[derive(Clone, Debug)]
26pub struct DiscoveredInvariantFile {
27    /// Absolute path to the source file.
28    pub path: PathBuf,
29    /// Path relative to the discovery root, normalised with `/` separators.
30    pub relative_dir: String,
31    /// Raw source — kept around so callers can render diagnostics.
32    pub source: String,
33    /// Predicates declared at the top level, in source order.
34    pub predicates: Vec<DiscoveredPredicate>,
35    /// Parse / attribute errors encountered when reading this file.
36    pub diagnostics: Vec<DiscoveryDiagnostic>,
37}
38
39/// One Flow predicate declaration parsed out of an invariants file.
40#[derive(Clone, Debug)]
41pub struct DiscoveredPredicate {
42    /// Function name. Composition uses this name plus the source directory
43    /// ancestry to identify stricter-child override lineages.
44    pub name: String,
45    /// `Deterministic` (default) or `Semantic`.
46    pub kind: PredicateKind,
47    /// For `@semantic` predicates, the named deterministic predicate that
48    /// carries the replayable enforcement path.
49    pub fallback: Option<String>,
50    /// Whether the fallback enforces a deterministic lower bound or is
51    /// retained only as replay/audit evidence.
52    pub fallback_policy: SemanticFallbackPolicy,
53    /// Optional Archivist provenance block.
54    pub archivist: Option<ArchivistMetadata>,
55    /// Advisory historical flag — predicates that legalise existing state
56    /// rather than gate new atoms.
57    pub retroactive: bool,
58    /// Stable content hash of the predicate declaration, including Flow
59    /// attributes. Shipped slices pin this value so later predicate edits are
60    /// append-only audit drift instead of retroactive blockers.
61    pub source_hash: PredicateHash,
62    /// Span of the function declaration in the source file (1-based).
63    pub span: Span,
64}
65
66/// Provenance metadata pulled from `@archivist(...)`.
67#[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/// One diagnostic surfaced by discovery — covers both parse errors and
76/// the structural attribute checks that go beyond the typechecker
77/// (`@invariant` requires `@archivist`, etc.).
78#[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
91/// Walk from `root` down through every component of `target_dir`,
92/// collecting `invariants.harn` at each level.
93///
94/// Returns the files in root-to-leaf order so composition can stamp source
95/// depth and evaluate ancestor/child predicates together.
96///
97/// `target_dir` is interpreted relative to `root`. Absolute paths or
98/// paths that escape `root` are silently clamped — discovery never reads
99/// files outside `root`.
100pub 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
128/// Parse a single `invariants.harn` source string. Exposed publicly for
129/// tests, the LSP, and tooling that has the file contents in hand.
130pub 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/// Parsed-but-not-yet-located output of [`parse_invariants_source`].
182#[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            // Default per design: predicates without an explicit mode are
211            // deterministic.
212            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
405/// Build the root → target chain of directories to inspect, in order.
406///
407/// Mirrors `MetadataState::resolve`: starts at `root`, then descends one
408/// component at a time. Empty / `.` / `..` components are stripped so a
409/// caller can't escape the root.
410fn candidate_directories(root: &Path, target_dir: &Path) -> Vec<PathBuf> {
411    let mut chain = vec![root.to_path_buf()];
412
413    // Make `target_dir` relative to `root` if it is absolute, otherwise
414    // treat it as already-relative.
415    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            // Refuse to escape `root`.
433            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        // `@invariant("name", "glob")` is the harn-ir handler form; it
695        // should never be treated as a Flow predicate.
696        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        // Override `shared` and add `extra`.
734        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        // Composition needs both versions so child results can tighten but
748        // cannot relax ancestor verdicts.
749        assert!(qualified.contains(&"shared".to_string()));
750        assert!(qualified.contains(&"crates::shared".to_string()));
751        // `extra` only exists in the deeper file.
752        assert!(qualified.contains(&"crates::extra".to_string()));
753    }
754}