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        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/// 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) -> Option<DiscoveredPredicate> {
195    // The Flow predicate marker is a *bare* `@invariant`. Anything with
196    // arguments is the handler-IR form and is not part of Flow discovery.
197    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            // Default per design: predicates without an explicit mode are
218            // deterministic.
219            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
412/// Build the root → target chain of directories to inspect, in order.
413///
414/// Mirrors `MetadataState::resolve`: starts at `root`, then descends one
415/// component at a time. Empty / `.` / `..` components are stripped so a
416/// caller can't escape the root.
417fn candidate_directories(root: &Path, target_dir: &Path) -> Vec<PathBuf> {
418    let mut chain = vec![root.to_path_buf()];
419
420    // Make `target_dir` relative to `root` if it is absolute, otherwise
421    // treat it as already-relative.
422    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            // Refuse to escape `root`.
440            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        // `@invariant("name", "glob")` is the harn-ir handler form; it
702        // should never be treated as a Flow predicate.
703        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        // Override `shared` and add `extra`.
718        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        // Composition needs both versions so child results can tighten but
732        // cannot relax ancestor verdicts.
733        assert!(qualified.contains(&"shared".to_string()));
734        assert!(qualified.contains(&"crates::shared".to_string()));
735        // `extra` only exists in the deeper file.
736        assert!(qualified.contains(&"crates::extra".to_string()));
737    }
738}