Skip to main content

fallow_core/plugins/
manifest_entries.rs

1//! Evaluation of external-plugin `manifestEntries` rules.
2//!
3//! A [`ManifestEntryRule`] seeds entry points DERIVED from framework manifest
4//! files: it finds manifests by a recursive glob (a bounded, `.gitignore`-aware
5//! second walk, because manifests are config files and are NOT in the
6//! source-discovery set), parses each one, and for every manifest that passes
7//! the rule-level `when` gate resolves each `entries[].path` relative to that
8//! manifest's directory (with `${dotted.field}` and `[*]` interpolation) into a
9//! root-relative entry pattern.
10//!
11//! The dominant failure mode is silent-none across a large manifest set (a typo
12//! in a field path seeds nothing), so evaluation emits loud `tracing::warn!`
13//! diagnostics: a `manifests` glob that matches nothing, a `when` that excludes
14//! every matched manifest, a referenced field path that resolves in zero
15//! matched manifests, an empty `entries` list, and unparseable manifests.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use fallow_config::{
21    ExternalPluginDef, ManifestCondition, ManifestEntryRule, ManifestFieldPath,
22    ManifestFieldSegment, ManifestFormat, ManifestPathPart, ManifestPathTemplate,
23};
24use serde_json::Value;
25
26use super::PathRule;
27use super::config_parser::normalize_config_path;
28
29/// Maximum number of values one field traversal may retain at any step.
30/// This bounds large manifest arrays before scalar conversion or cartesian
31/// expansion begins.
32const MAX_MANIFEST_FIELD_VALUES: usize = 1_024;
33
34/// Maximum number of concrete paths one entry template may produce per manifest.
35const MAX_MANIFEST_ENTRY_EXPANSIONS: usize = 4_096;
36
37/// A kind of `manifestEntries` diagnostic, kebab-serialized for agents that
38/// branch on it. Centralizes the vocabulary shared by the production warn path
39/// (`evaluate_manifest_entries`) and the agent-facing check path
40/// (`check_manifest_entries` / `fallow plugin-check`).
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WarningKind {
43    /// The `manifests` glob matched zero files.
44    ManifestsMatchedNone,
45    /// The `when` gate excluded every matched manifest.
46    WhenExcludedAll,
47    /// A referenced field path resolved in none of the gated manifests (typo).
48    FieldPathUnresolved,
49    /// The rule's `entries` list is empty; it seeds nothing.
50    EntriesEmpty,
51    /// One or more matched manifests could not be read or parsed.
52    ManifestParseFailed,
53    /// A field path yielded more values than the evaluator permits.
54    FieldValuesLimitExceeded,
55    /// An entry template's interpolation product exceeded the evaluator limit.
56    EntryExpansionLimitExceeded,
57    /// An entry resolved outside the project root and was skipped.
58    EntryOutsideRoot,
59    /// A rule seeded entries but none of the seeded paths exist on disk.
60    /// Check-only (production seeds the pattern regardless of existence).
61    SeededPathsMissing,
62}
63
64impl WarningKind {
65    /// The kebab-case token agents branch on.
66    #[must_use]
67    pub fn as_kebab(self) -> &'static str {
68        match self {
69            Self::ManifestsMatchedNone => "manifests-matched-none",
70            Self::WhenExcludedAll => "when-excluded-all",
71            Self::FieldPathUnresolved => "field-path-unresolved",
72            Self::EntriesEmpty => "entries-empty",
73            Self::ManifestParseFailed => "manifest-parse-failed",
74            Self::FieldValuesLimitExceeded => "field-values-limit-exceeded",
75            Self::EntryExpansionLimitExceeded => "entry-expansion-limit-exceeded",
76            Self::EntryOutsideRoot => "entry-outside-root",
77            Self::SeededPathsMissing => "seeded-paths-missing",
78        }
79    }
80
81    /// The enforced ceiling for warnings caused by bounded expansion.
82    #[must_use]
83    pub fn expansion_limit(self) -> Option<usize> {
84        match self {
85            Self::FieldValuesLimitExceeded => Some(MAX_MANIFEST_FIELD_VALUES),
86            Self::EntryExpansionLimitExceeded => Some(MAX_MANIFEST_ENTRY_EXPANSIONS),
87            _ => None,
88        }
89    }
90}
91
92/// A single `manifestEntries` diagnostic with typed payload slots (agents read
93/// the slot their `kind` implies rather than parsing prose).
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct CheckWarning {
96    pub kind: WarningKind,
97    /// The offending `manifests` glob (for `manifests-matched-none`).
98    pub glob: Option<String>,
99    /// The offending field path (for unresolved or value-limit warnings).
100    pub field_path: Option<String>,
101    /// The manifest a per-manifest warning relates to (root-relative).
102    pub manifest: Option<String>,
103    /// The offending entry or template (for entry-path warnings).
104    pub entry: Option<String>,
105}
106
107impl CheckWarning {
108    /// A warning carrying only the offending `manifests` glob.
109    fn glob(kind: WarningKind, glob: &str) -> Self {
110        Self {
111            kind,
112            glob: Some(glob.to_string()),
113            field_path: None,
114            manifest: None,
115            entry: None,
116        }
117    }
118
119    /// A warning carrying only the offending dotted field path.
120    fn field(kind: WarningKind, field_path: String) -> Self {
121        Self {
122            kind,
123            glob: None,
124            field_path: Some(field_path),
125            manifest: None,
126            entry: None,
127        }
128    }
129
130    /// A warning carrying only the offending manifest (root-relative).
131    fn manifest(kind: WarningKind, manifest: String) -> Self {
132        Self {
133            kind,
134            glob: None,
135            field_path: None,
136            manifest: Some(manifest),
137            entry: None,
138        }
139    }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143enum ExpansionError {
144    FieldValues { field_path: String },
145    EntryPaths { template: String },
146}
147
148impl ExpansionError {
149    fn into_warning(self, manifest: Option<String>) -> CheckWarning {
150        match self {
151            Self::FieldValues { field_path } => CheckWarning {
152                kind: WarningKind::FieldValuesLimitExceeded,
153                glob: None,
154                field_path: Some(field_path),
155                manifest,
156                entry: None,
157            },
158            Self::EntryPaths { template } => CheckWarning {
159                kind: WarningKind::EntryExpansionLimitExceeded,
160                glob: None,
161                field_path: None,
162                manifest,
163                entry: Some(template),
164            },
165        }
166    }
167}
168
169/// What one matched-and-parsed manifest yielded under a rule.
170#[derive(Debug, Clone)]
171pub struct ManifestResult {
172    /// Root-relative manifest path.
173    pub path: String,
174    /// Whether the rule-level `when` gate passed for this manifest.
175    pub when_passed: bool,
176    /// Root-relative entry globs seeded from this manifest (empty unless
177    /// `when_passed`). Each still encodes its own extension (e.g. `{ts,tsx}`).
178    pub seeded: Vec<String>,
179}
180
181/// The result of evaluating one `manifestEntries` rule: the shared source of
182/// truth for BOTH production seeding and the agent-facing check output, so the
183/// two can never drift.
184#[derive(Debug, Clone)]
185pub struct RuleReport {
186    /// The rule's `manifests` glob.
187    pub manifests: String,
188    /// Root-relative paths of the manifests the glob matched (sorted, stable).
189    pub manifests_matched: Vec<String>,
190    /// Per-matched-manifest results (sorted by path).
191    pub matched: Vec<ManifestResult>,
192    /// Diagnostics for this rule, sorted by `(kind, manifest, entry, field_path)`
193    /// so the JSON is byte-identical across machines and CI runs.
194    pub warnings: Vec<CheckWarning>,
195}
196
197/// Evaluate every `manifestEntries` rule on an active external plugin, returning
198/// the root-relative entry patterns to seed. Delegates to the shared
199/// `build_rule_report` so the seeded set and the `fallow plugin-check` report
200/// are computed by identical logic, then re-emits each report warning as a
201/// `tracing::warn!` (the loud stderr behavior is preserved).
202///
203/// Manifest files are config files, not source files, so they are not in the
204/// source-discovery set; this does a bounded `.gitignore`-respecting walk (like
205/// plugin detection's file-existence fallback) to find them. Manifests under
206/// gitignored / `node_modules` directories are intentionally invisible.
207#[must_use]
208pub(crate) fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
209    let mut out = Vec::new();
210    for rule in &ext.manifest_entries {
211        let report = build_rule_report(rule, root);
212        for manifest in &report.matched {
213            for seed in &manifest.seeded {
214                out.push(PathRule::new(seed.clone()));
215            }
216        }
217        emit_report_warnings(&ext.name, &report);
218    }
219    out
220}
221
222/// Evaluate every `manifestEntries` rule and return the STRUCTURED report per
223/// rule, without seeding or warning. This is the read-only dry-run the
224/// `fallow plugin-check` command surfaces to agents.
225#[must_use]
226pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
227    ext.manifest_entries
228        .iter()
229        .map(|rule| build_rule_report(rule, root))
230        .collect()
231}
232
233/// The shared core: walk manifests, gate on `when`, seed entries, and collect
234/// diagnostics into a [`RuleReport`]. Deterministically ordered.
235fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
236    let mut report = RuleReport {
237        manifests: rule.manifests.clone(),
238        manifests_matched: Vec::new(),
239        matched: Vec::new(),
240        warnings: Vec::new(),
241    };
242
243    if rule.entries.is_empty() {
244        report.warnings.push(CheckWarning::glob(
245            WarningKind::EntriesEmpty,
246            &rule.manifests,
247        ));
248        return report;
249    }
250
251    let Ok(glob) = globset::Glob::new(&rule.manifests) else {
252        // Glob validity is enforced at config load; a compile failure here is
253        // defensive and, like a non-matching glob, seeds nothing.
254        report.warnings.push(CheckWarning::glob(
255            WarningKind::ManifestsMatchedNone,
256            &rule.manifests,
257        ));
258        return report;
259    };
260    let matcher = glob.compile_matcher();
261
262    let referenced = referenced_field_paths(rule);
263    let mut resolved: BTreeMap<&str, bool> =
264        referenced.iter().map(|p| (p.as_str(), false)).collect();
265    let mut passed = 0usize;
266    let mut parsed = 0usize;
267    let mut gate_errors = 0usize;
268
269    for file in discover_manifest_paths(root, &matcher) {
270        let rel_manifest = root_relative_forward_slash(&file, root)
271            .unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
272        report.manifests_matched.push(rel_manifest.clone());
273
274        let manifest: Value = match std::fs::read_to_string(&file)
275            .ok()
276            .and_then(|source| parse_manifest(&source, rule.format))
277        {
278            Some(value) => value,
279            None => {
280                // Per-file diagnostic (with the offending manifest) so an agent
281                // does not have to set-difference manifests_matched vs matched.
282                report.warnings.push(CheckWarning::manifest(
283                    WarningKind::ManifestParseFailed,
284                    rel_manifest,
285                ));
286                continue;
287            }
288        };
289        parsed += 1;
290
291        let when_passed = match when_matches(&manifest, &rule.when) {
292            Ok(passed) => passed,
293            Err(error) => {
294                gate_errors += 1;
295                report
296                    .warnings
297                    .push(error.into_warning(Some(rel_manifest.clone())));
298                false
299            }
300        };
301        let mut seeded = Vec::new();
302        if when_passed {
303            passed += 1;
304            for path in &referenced {
305                let path_resolved = match field_values(&manifest, path) {
306                    Ok(values) => !values.is_empty(),
307                    // The path resolved; its fan-out is the problem. Evaluation
308                    // emits the more precise limit warning below.
309                    Err(_) => true,
310                };
311                if path_resolved && let Some(flag) = resolved.get_mut(path.as_str()) {
312                    *flag = true;
313                }
314            }
315            let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
316            seeded = entries;
317            report.warnings.append(&mut entry_warnings);
318        }
319        report.matched.push(ManifestResult {
320            path: rel_manifest,
321            when_passed,
322            seeded,
323        });
324    }
325
326    report.warnings.extend(rule_level_warnings(
327        &rule.manifests,
328        report.manifests_matched.len(),
329        parsed,
330        passed,
331        gate_errors,
332        &resolved,
333    ));
334
335    // manifests_matched inherits discover_manifest_paths' sorted order; matched
336    // and warnings are sorted here so the JSON is byte-identical across runs and
337    // filesystems (warnings tie-break on manifest then entry, since a rule can
338    // emit multiple parse-failed / entry-outside-root warnings).
339    report.matched.sort_by(|a, b| a.path.cmp(&b.path));
340    report.warnings.sort_by(|a, b| {
341        a.kind
342            .as_kebab()
343            .cmp(b.kind.as_kebab())
344            .then_with(|| a.manifest.cmp(&b.manifest))
345            .then_with(|| a.entry.cmp(&b.entry))
346            .then_with(|| a.field_path.cmp(&b.field_path))
347    });
348    report.warnings.dedup();
349    report
350}
351
352fn parse_manifest(source: &str, format: ManifestFormat) -> Option<Value> {
353    match format {
354        ManifestFormat::Jsonc => fallow_config::jsonc::parse_to_value(source).ok(),
355        ManifestFormat::Json => serde_json::from_str(source).ok(),
356    }
357}
358
359/// Assemble the RULE-LEVEL diagnostics (matched-none / when-excluded-all /
360/// field-path-unresolved) from the walk tallies. Per-manifest diagnostics
361/// (parse-failed, entry-outside-root) are pushed during the walk. `parsed` is
362/// the count of manifests that read + parsed; `passed` cleared the `when` gate;
363/// `gate_errors` could not be evaluated because their traversal exceeded a
364/// declared bound.
365fn rule_level_warnings(
366    manifests: &str,
367    matched: usize,
368    parsed: usize,
369    passed: usize,
370    gate_errors: usize,
371    resolved: &BTreeMap<&str, bool>,
372) -> Vec<CheckWarning> {
373    let mut out = Vec::new();
374    if matched == 0 {
375        out.push(CheckWarning::glob(
376            WarningKind::ManifestsMatchedNone,
377            manifests,
378        ));
379        return out;
380    }
381    // Only claim the `when` gate excluded everything when there WERE parseable
382    // manifests for it to gate; if all failed to parse, the per-file
383    // parse-failed warnings already explain the zero seed.
384    if parsed > 0 && passed == 0 && gate_errors == 0 {
385        out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
386        return out;
387    }
388    if passed == 0 {
389        return out;
390    }
391    for (path, was_resolved) in resolved {
392        if !was_resolved {
393            out.push(CheckWarning::field(
394                WarningKind::FieldPathUnresolved,
395                (*path).to_string(),
396            ));
397        }
398    }
399    out
400}
401
402/// Seed one manifest's entries: returns the root-relative entry globs plus any
403/// `entry-outside-root` diagnostics.
404fn seed_rule_entries(
405    rule: &ManifestEntryRule,
406    manifest: &Value,
407    manifest_path: &Path,
408    root: &Path,
409) -> (Vec<String>, Vec<CheckWarning>) {
410    let rel_manifest = root_relative_forward_slash(manifest_path, root);
411    let mut seeded = Vec::new();
412    let mut warnings = Vec::new();
413    for seed in &rule.entries {
414        match when_matches(manifest, &seed.when) {
415            Ok(true) => {}
416            Ok(false) => continue,
417            Err(error) => {
418                warnings.push(error.into_warning(rel_manifest.clone()));
419                continue;
420            }
421        }
422        let concretes = match expand_interpolations(&seed.path, manifest) {
423            Ok(concretes) => concretes,
424            Err(error) => {
425                warnings.push(error.into_warning(rel_manifest.clone()));
426                continue;
427            }
428        };
429        for concrete in concretes {
430            match normalize_config_path(&concrete, manifest_path, root) {
431                Some(rel) => seeded.push(rel),
432                None => warnings.push(CheckWarning {
433                    kind: WarningKind::EntryOutsideRoot,
434                    glob: None,
435                    field_path: None,
436                    manifest: rel_manifest.clone(),
437                    entry: Some(concrete),
438                }),
439            }
440        }
441    }
442    (seeded, warnings)
443}
444
445/// Re-emit a rule report's warnings as `tracing::warn!` on the production path.
446fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
447    for warning in &report.warnings {
448        match warning.kind {
449            WarningKind::EntriesEmpty => tracing::warn!(
450                "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
451                 list; it seeds nothing.",
452                report.manifests
453            ),
454            WarningKind::ManifestsMatchedNone => tracing::warn!(
455                "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
456                 Check the glob and whether the manifests live under an ignored directory.",
457                report.manifests
458            ),
459            WarningKind::ManifestParseFailed => tracing::warn!(
460                "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
461                 it could not be read or parsed using the rule's declared format.",
462                warning.manifest.as_deref().unwrap_or(""),
463                report.manifests
464            ),
465            WarningKind::FieldValuesLimitExceeded => tracing::warn!(
466                "Plugin '{plugin_name}': manifestEntries field path '{}' in manifest '{}' exceeded \
467                 the traversal value limit of {}. The affected gate or template was skipped without \
468                 partial seeding.",
469                warning.field_path.as_deref().unwrap_or(""),
470                warning.manifest.as_deref().unwrap_or(""),
471                warning
472                    .kind
473                    .expansion_limit()
474                    .unwrap_or(MAX_MANIFEST_FIELD_VALUES)
475            ),
476            WarningKind::EntryExpansionLimitExceeded => tracing::warn!(
477                "Plugin '{plugin_name}': manifestEntries template '{}' in manifest '{}' exceeded \
478                 the concrete entry limit of {}. No entries were seeded from that template.",
479                warning.entry.as_deref().unwrap_or(""),
480                warning.manifest.as_deref().unwrap_or(""),
481                warning
482                    .kind
483                    .expansion_limit()
484                    .unwrap_or(MAX_MANIFEST_ENTRY_EXPANSIONS)
485            ),
486            WarningKind::WhenExcludedAll => tracing::warn!(
487                "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
488                 manifest(s) for glob '{}'. No entries were seeded.",
489                report.manifests
490            ),
491            WarningKind::FieldPathUnresolved => tracing::warn!(
492                "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
493                 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
494                warning.field_path.as_deref().unwrap_or("")
495            ),
496            WarningKind::EntryOutsideRoot => tracing::warn!(
497                "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
498                 outside the project root and was skipped.",
499                warning.entry.as_deref().unwrap_or(""),
500                warning.manifest.as_deref().unwrap_or("")
501            ),
502            // Check-only; never produced by build_rule_report.
503            WarningKind::SeededPathsMissing => {}
504        }
505    }
506}
507
508/// Collect every field path a rule references (rule-level `when` keys, per-seed
509/// `when` keys, and `${...}` interpolations in seed paths) for typo diagnostics.
510fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<ManifestFieldPath> {
511    let mut paths: Vec<ManifestFieldPath> = rule
512        .when
513        .iter()
514        .filter(|(_, condition)| condition_requires_present_value(condition))
515        .map(|(path, _)| path.clone())
516        .collect();
517    for seed in &rule.entries {
518        paths.extend(
519            seed.when
520                .iter()
521                .filter(|(_, condition)| condition_requires_present_value(condition))
522                .map(|(path, _)| path.clone()),
523        );
524        paths.extend(seed.path.parts().iter().filter_map(|part| match part {
525            ManifestPathPart::Field(path) => Some(path.clone()),
526            ManifestPathPart::Literal(_) => None,
527        }));
528    }
529    paths.sort();
530    paths.dedup();
531    paths
532}
533
534/// Expand `${dotted.field}` interpolations in a path against a manifest, fanning
535/// out over string / array field values. Returns an empty vec when any
536/// interpolation resolves to nothing (a missing field seeds nothing).
537fn expand_interpolations(
538    path: &ManifestPathTemplate,
539    manifest: &Value,
540) -> Result<Vec<String>, ExpansionError> {
541    let mut expanded = vec![String::new()];
542    for part in path.parts() {
543        match part {
544            ManifestPathPart::Literal(literal) => {
545                for value in &mut expanded {
546                    value.push_str(literal);
547                }
548            }
549            ManifestPathPart::Field(field) => {
550                let values = field_segment_values(manifest, field)?;
551                if values.is_empty() {
552                    return Ok(Vec::new());
553                }
554
555                let Some(next_len) = expanded.len().checked_mul(values.len()) else {
556                    return Err(ExpansionError::EntryPaths {
557                        template: path.as_str().to_string(),
558                    });
559                };
560                if next_len > MAX_MANIFEST_ENTRY_EXPANSIONS {
561                    return Err(ExpansionError::EntryPaths {
562                        template: path.as_str().to_string(),
563                    });
564                }
565
566                let mut next = Vec::with_capacity(next_len);
567                for prefix in &expanded {
568                    for value in &values {
569                        let mut concrete = String::with_capacity(prefix.len() + value.len());
570                        concrete.push_str(prefix);
571                        concrete.push_str(value);
572                        next.push(concrete);
573                    }
574                }
575                expanded = next;
576            }
577        }
578    }
579    Ok(expanded)
580}
581
582/// The path-segment string values a field yields: a string or number yields
583/// one; a final array yields one per scalar element; anything else yields none.
584fn field_segment_values(
585    manifest: &Value,
586    field: &ManifestFieldPath,
587) -> Result<Vec<String>, ExpansionError> {
588    let mut values = Vec::new();
589    for value in field_values(manifest, field)? {
590        match value {
591            Value::Array(items) => {
592                for item in items.iter().filter_map(scalar_segment) {
593                    push_field_segment(&mut values, item, field)?;
594                }
595            }
596            value => {
597                if let Some(segment) = scalar_segment(value) {
598                    push_field_segment(&mut values, segment, field)?;
599                }
600            }
601        }
602    }
603    Ok(values)
604}
605
606fn push_field_segment(
607    values: &mut Vec<String>,
608    value: String,
609    field: &ManifestFieldPath,
610) -> Result<(), ExpansionError> {
611    if values.len() == MAX_MANIFEST_FIELD_VALUES {
612        return Err(ExpansionError::FieldValues {
613            field_path: field.as_str().to_string(),
614        });
615    }
616    values.push(value);
617    Ok(())
618}
619
620fn scalar_segment(value: &Value) -> Option<String> {
621    match value {
622        Value::String(s) if !s.is_empty() => Some(s.clone()),
623        Value::Number(n) => Some(n.to_string()),
624        _ => None,
625    }
626}
627
628/// Whether every `(dotted-path, expected)` pair in `when` matches the manifest
629/// by strict equality. An empty map always matches.
630fn when_matches(
631    manifest: &Value,
632    when: &BTreeMap<ManifestFieldPath, ManifestCondition>,
633) -> Result<bool, ExpansionError> {
634    for (path, condition) in when {
635        let values = field_values(manifest, path)?;
636        let matches = match condition {
637            ManifestCondition::Equals(expected) => values.contains(&expected),
638            ManifestCondition::Exists(predicate) => values.is_empty() != predicate.exists,
639        };
640        if !matches {
641            return Ok(false);
642        }
643    }
644    Ok(true)
645}
646
647fn condition_requires_present_value(condition: &ManifestCondition) -> bool {
648    match condition {
649        ManifestCondition::Equals(_) => true,
650        ManifestCondition::Exists(predicate) => predicate.exists,
651    }
652}
653
654/// Evaluate a typed manifest field path, including explicit `[*]` traversal.
655fn field_values<'a>(
656    value: &'a Value,
657    path: &ManifestFieldPath,
658) -> Result<Vec<&'a Value>, ExpansionError> {
659    let mut current = vec![value];
660    for segment in path.segments() {
661        let mut next = Vec::new();
662        for value in current {
663            match segment {
664                ManifestFieldSegment::Key(key) => {
665                    if let Some(child) = value.get(key) {
666                        push_field_value(&mut next, child, path)?;
667                    }
668                }
669                ManifestFieldSegment::Each => {
670                    if let Value::Array(items) = value {
671                        for item in items {
672                            push_field_value(&mut next, item, path)?;
673                        }
674                    }
675                }
676            }
677        }
678        current = next;
679    }
680    Ok(current)
681}
682
683fn push_field_value<'a>(
684    values: &mut Vec<&'a Value>,
685    value: &'a Value,
686    path: &ManifestFieldPath,
687) -> Result<(), ExpansionError> {
688    if values.len() == MAX_MANIFEST_FIELD_VALUES {
689        return Err(ExpansionError::FieldValues {
690            field_path: path.as_str().to_string(),
691        });
692    }
693    values.push(value);
694    Ok(())
695}
696
697/// Walk `root` (respecting `.gitignore`, skipping `node_modules`) and return the
698/// absolute paths of files whose root-relative path matches `matcher`. Bounded
699/// to the manifest glob; runs only when an active plugin declares manifestEntries.
700fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
701    let mut out = Vec::new();
702    let canonical_root = root.canonicalize().ok();
703    let walker = ignore::WalkBuilder::new(root)
704        .hidden(false)
705        .git_ignore(true)
706        .git_global(true)
707        .git_exclude(true)
708        .filter_entry(|entry| entry.file_name() != "node_modules")
709        .build();
710    for entry in walker.flatten() {
711        let Some(file_type) = entry.file_type() else {
712            continue;
713        };
714        if file_type.is_dir() {
715            continue;
716        }
717        let path = entry.path();
718        if file_type.is_symlink()
719            && !is_contained_regular_file_symlink(path, canonical_root.as_deref())
720        {
721            tracing::debug!(
722                path = %path.display(),
723                "skipping manifest symlink with a broken, non-file, or outside-root target"
724            );
725            continue;
726        }
727        if let Some(rel) = root_relative_forward_slash(path, root)
728            && matcher.is_match(Path::new(&rel))
729        {
730            out.push(path.to_path_buf());
731        }
732    }
733    // `ignore::WalkBuilder` yields raw filesystem order; sort so seeding and the
734    // check report (manifests_matched, per-manifest warnings) are deterministic
735    // across machines and CI runners.
736    out.sort();
737    out
738}
739
740fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
741    let Some(root) = canonical_root else {
742        return false;
743    };
744    let Ok(target) = path.canonicalize() else {
745        return false;
746    };
747    target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
748}
749
750/// Root-relative forward-slash string for a discovered (absolute) path, or
751/// `None` if it is not under `root`.
752fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
753    let rel = file.strip_prefix(root).ok()?;
754    Some(rel.to_string_lossy().replace('\\', "/"))
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use fallow_config::{
761        EntryPointRole, ManifestExistsPredicate, ManifestFormat, ManifestSeedRule,
762    };
763
764    fn json(text: &str) -> Value {
765        serde_json::from_str(text).unwrap()
766    }
767
768    fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
769        ManifestSeedRule {
770            path: path.parse().unwrap(),
771            when: conditions(when),
772        }
773    }
774
775    fn field(path: &str) -> ManifestFieldPath {
776        path.parse().unwrap()
777    }
778
779    fn template(path: &str) -> ManifestPathTemplate {
780        path.parse().unwrap()
781    }
782
783    fn conditions(when: &[(&str, Value)]) -> BTreeMap<ManifestFieldPath, ManifestCondition> {
784        when.iter()
785            .map(|(path, expected)| (field(path), ManifestCondition::Equals(expected.clone())))
786            .collect()
787    }
788
789    fn exists(path: &str, expected: bool) -> (ManifestFieldPath, ManifestCondition) {
790        (
791            field(path),
792            ManifestCondition::Exists(ManifestExistsPredicate { exists: expected }),
793        )
794    }
795
796    #[test]
797    fn field_values_traverse_nested_fields_and_object_arrays() {
798        let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
799        assert_eq!(
800            field_values(&m, &field("plugin.browser")).unwrap(),
801            vec![&Value::Bool(true)]
802        );
803        assert_eq!(
804            field_values(&m, &field("plugin.id")).unwrap(),
805            vec![&Value::String("actions".into())]
806        );
807        assert!(
808            field_values(&m, &field("plugin.missing"))
809                .unwrap()
810                .is_empty()
811        );
812        assert!(field_values(&m, &field("absent.field")).unwrap().is_empty());
813
814        let m = json(
815            r#"{"content_scripts":[{"js":["a.js","b.js"]},null,{"js":["c.js"]},"invalid",{"css":[]}]}"#,
816        );
817        assert_eq!(
818            field_segment_values(&m, &field("content_scripts[*].js")).unwrap(),
819            vec!["a.js", "b.js", "c.js"]
820        );
821
822        let nested = json(
823            r#"{"groups":[{"entries":[{"path":"a.js"},{"path":"b.js"}]},{"entries":[{"path":"c.js"}]}]}"#,
824        );
825        assert_eq!(
826            field_segment_values(&nested, &field("groups[*].entries[*].path")).unwrap(),
827            vec!["a.js", "b.js", "c.js"]
828        );
829    }
830
831    #[test]
832    fn when_matches_is_strict_equality_and_presence_is_not_matched() {
833        let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
834        let mut when = BTreeMap::new();
835        when.insert(
836            field("type"),
837            ManifestCondition::Equals(Value::String("plugin".into())),
838        );
839        assert!(when_matches(&m, &when).unwrap());
840
841        // browser is present but false: matching against `true` must FAIL
842        // (strict equality, no presence overload).
843        let mut when_browser = BTreeMap::new();
844        when_browser.insert(
845            field("plugin.browser"),
846            ManifestCondition::Equals(Value::Bool(true)),
847        );
848        assert!(!when_matches(&m, &when_browser).unwrap());
849
850        // empty when always matches
851        assert!(when_matches(&m, &BTreeMap::new()).unwrap());
852
853        let manifest = json(r#"{"plugins":[{"kind":"worker"},{"kind":"browser"}]}"#);
854        let wildcard = conditions(&[("plugins[*].kind", Value::String("browser".into()))]);
855        assert!(when_matches(&manifest, &wildcard).unwrap());
856
857        let structured = json(r#"{"array":["worker"],"object":{"kind":"browser"}}"#);
858        let structured_when = BTreeMap::from([
859            (
860                field("array"),
861                ManifestCondition::Equals(json(r#"["worker"]"#)),
862            ),
863            (
864                field("object"),
865                ManifestCondition::Equals(json(r#"{"kind":"browser"}"#)),
866            ),
867        ]);
868        assert!(when_matches(&structured, &structured_when).unwrap());
869    }
870
871    #[test]
872    fn exists_conditions_test_presence_without_truthiness() {
873        let manifest = json(
874            r#"{"presentFalse":false,"presentNull":null,"presentEmpty":"","presentObject":{},"items":[]}"#,
875        );
876        for path in [
877            "presentFalse",
878            "presentNull",
879            "presentEmpty",
880            "presentObject",
881            "items",
882        ] {
883            assert!(
884                when_matches(&manifest, &BTreeMap::from([exists(path, true)])).unwrap(),
885                "{path} is present regardless of its value"
886            );
887        }
888        assert!(when_matches(&manifest, &BTreeMap::from([exists("missing", false)])).unwrap());
889        assert!(!when_matches(&manifest, &BTreeMap::from([exists("missing", true)])).unwrap());
890        assert!(
891            when_matches(
892                &manifest,
893                &BTreeMap::from([exists("items[*].value", false)])
894            )
895            .unwrap()
896        );
897    }
898
899    #[test]
900    fn exists_false_can_gate_a_rule_without_an_unresolved_path_warning() {
901        let dir = tempfile::tempdir().unwrap();
902        let root = dir.path();
903        write_manifest(root, "plugins/alpha/manifest.json", r#"{"name":"alpha"}"#);
904        let mut manifest_rule = rule("**/manifest.json", &[], vec![seed("index.ts", &[])]);
905        manifest_rule.when = BTreeMap::from([exists("main", false)]);
906
907        let reports = check_manifest_entries(&plugin_with(vec![manifest_rule]), root);
908        assert!(reports[0].warnings.is_empty());
909        assert!(reports[0].matched[0].when_passed);
910        assert_eq!(reports[0].matched[0].seeded, vec!["plugins/alpha/index.ts"]);
911    }
912
913    #[test]
914    fn parse_manifest_honors_the_declared_format() {
915        let jsonc_only = r#"{
916            // JSONC comment
917            "type": "plugin",
918        }"#;
919
920        assert!(parse_manifest(jsonc_only, ManifestFormat::Jsonc).is_some());
921        assert!(parse_manifest(jsonc_only, ManifestFormat::Json).is_none());
922        assert!(parse_manifest(r#"{"type":"plugin"}"#, ManifestFormat::Json).is_some());
923    }
924
925    #[test]
926    fn expand_interpolations_string_array_and_missing() {
927        let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
928        // string field -> one entry
929        assert_eq!(
930            expand_interpolations(&template("${plugin.id}/index.ts"), &m).unwrap(),
931            vec!["actions/index.ts"]
932        );
933        // array field -> one entry per element
934        assert_eq!(
935            expand_interpolations(&template("${plugin.extraPublicDirs}/index.{ts,tsx}"), &m)
936                .unwrap(),
937            vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
938        );
939        // missing field -> nothing seeded
940        assert!(
941            expand_interpolations(&template("${plugin.absent}/index.ts"), &m)
942                .unwrap()
943                .is_empty()
944        );
945        // no interpolation -> passthrough
946        assert_eq!(
947            expand_interpolations(&template("public/index.{ts,tsx}"), &m).unwrap(),
948            vec!["public/index.{ts,tsx}"]
949        );
950    }
951
952    #[test]
953    fn interpolation_limits_are_explicit_errors() {
954        let too_many: Vec<Value> = (0..=MAX_MANIFEST_FIELD_VALUES)
955            .map(|index| Value::String(index.to_string()))
956            .collect();
957        let manifest = serde_json::json!({ "values": too_many });
958        assert_eq!(
959            expand_interpolations(&template("${values}/index.ts"), &manifest),
960            Err(ExpansionError::FieldValues {
961                field_path: "values".to_string(),
962            })
963        );
964
965        let factors = (0..65)
966            .map(|index| Value::String(index.to_string()))
967            .collect::<Vec<_>>();
968        let manifest = serde_json::json!({ "left": factors, "right": factors });
969        assert_eq!(
970            expand_interpolations(&template("${left}/${right}/index.ts"), &manifest),
971            Err(ExpansionError::EntryPaths {
972                template: "${left}/${right}/index.ts".to_string(),
973            })
974        );
975    }
976
977    #[test]
978    fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
979        let dir = tempfile::tempdir().unwrap();
980        let root = dir.path();
981        let manifest_dir = root.join("x-pack/plugins/actions");
982        std::fs::create_dir_all(&manifest_dir).unwrap();
983        let manifest_path = manifest_dir.join("kibana.jsonc");
984        std::fs::write(
985            &manifest_path,
986            r#"{
987                // a real Kibana-shaped manifest
988                "type": "plugin",
989                "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
990            }"#,
991        )
992        .unwrap();
993
994        let ext = ExternalPluginDef {
995            schema: None,
996            name: "kibana".to_string(),
997            detection: None,
998            enablers: vec![],
999            entry_points: vec![],
1000            entry_point_role: EntryPointRole::Runtime,
1001            manifest_entries: vec![ManifestEntryRule {
1002                manifests: "**/kibana.jsonc".to_string(),
1003                format: ManifestFormat::Jsonc,
1004                when: conditions(&[("type", Value::String("plugin".into()))]),
1005                entries: vec![
1006                    seed(
1007                        "public/index.{ts,tsx}",
1008                        &[("plugin.browser", Value::Bool(true))],
1009                    ),
1010                    seed(
1011                        "server/index.{ts,tsx}",
1012                        &[("plugin.server", Value::Bool(true))],
1013                    ),
1014                    seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
1015                ],
1016            }],
1017            config_patterns: vec![],
1018            always_used: vec![],
1019            tooling_dependencies: vec![],
1020            used_exports: vec![],
1021            used_class_members: vec![],
1022        };
1023
1024        let rules = evaluate_manifest_entries(&ext, root);
1025        let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
1026
1027        // browser:true seeds public; server:false does NOT seed server; extraPublicDirs fans out.
1028        assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
1029        assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
1030        assert!(
1031            !paths.iter().any(|p| p.contains("server/index")),
1032            "server:false must not seed the server entry, got {paths:?}"
1033        );
1034    }
1035
1036    #[test]
1037    fn evaluate_fans_out_over_object_array_fields() {
1038        let dir = tempfile::tempdir().unwrap();
1039        let root = dir.path();
1040        write_manifest(
1041            root,
1042            "extension/manifest.json",
1043            r#"{
1044                "manifest_version": 3,
1045                "content_scripts": [
1046                    { "matches": ["https://a.example/*"], "js": ["content/a.js", "content/b.js"] },
1047                    { "matches": ["https://b.example/*"], "js": ["content/c.js"] }
1048                ]
1049            }"#,
1050        );
1051        let ext = plugin_with(vec![rule(
1052            "**/manifest.json",
1053            &[("manifest_version", Value::Number(3.into()))],
1054            vec![seed("${content_scripts[*].js}", &[])],
1055        )]);
1056
1057        let reports = check_manifest_entries(&ext, root);
1058        assert!(reports[0].warnings.is_empty());
1059        assert_eq!(
1060            reports[0].matched[0].seeded,
1061            vec![
1062                "extension/content/a.js",
1063                "extension/content/b.js",
1064                "extension/content/c.js",
1065            ]
1066        );
1067    }
1068
1069    fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
1070        ExternalPluginDef {
1071            schema: None,
1072            name: "kibana".to_string(),
1073            detection: None,
1074            enablers: vec![],
1075            entry_points: vec![],
1076            entry_point_role: EntryPointRole::Runtime,
1077            manifest_entries: rules,
1078            config_patterns: vec![],
1079            always_used: vec![],
1080            tooling_dependencies: vec![],
1081            used_exports: vec![],
1082            used_class_members: vec![],
1083        }
1084    }
1085
1086    fn rule(
1087        manifests: &str,
1088        when: &[(&str, Value)],
1089        entries: Vec<ManifestSeedRule>,
1090    ) -> ManifestEntryRule {
1091        ManifestEntryRule {
1092            manifests: manifests.to_string(),
1093            format: ManifestFormat::Jsonc,
1094            when: conditions(when),
1095            entries,
1096        }
1097    }
1098
1099    fn write_manifest(root: &Path, rel: &str, body: &str) {
1100        let p = root.join(rel);
1101        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1102        std::fs::write(p, body).unwrap();
1103    }
1104
1105    #[cfg(unix)]
1106    fn symlink_file(target: &Path, link: &Path) {
1107        std::os::unix::fs::symlink(target, link).expect("create file symlink");
1108    }
1109
1110    #[cfg(windows)]
1111    fn symlink_file(target: &Path, link: &Path) {
1112        std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1113    }
1114
1115    #[test]
1116    fn manifest_symlinks_must_target_regular_files_inside_root() {
1117        let dir = tempfile::tempdir().expect("create project");
1118        let outside = tempfile::tempdir().expect("create outside dir");
1119        let root = dir.path();
1120        let targets = root.join("targets");
1121        let plugins = root.join("plugins");
1122        std::fs::create_dir_all(&targets).unwrap();
1123        std::fs::create_dir_all(&plugins).unwrap();
1124        std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1125        std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1126
1127        symlink_file(
1128            &targets.join("inside.jsonc"),
1129            &plugins.join("inside-kibana.jsonc"),
1130        );
1131        symlink_file(
1132            &outside.path().join("outside.jsonc"),
1133            &plugins.join("outside-kibana.jsonc"),
1134        );
1135        symlink_file(
1136            &targets.join("missing.jsonc"),
1137            &plugins.join("broken-kibana.jsonc"),
1138        );
1139
1140        let matcher = globset::Glob::new("**/*-kibana.jsonc")
1141            .unwrap()
1142            .compile_matcher();
1143        let paths = discover_manifest_paths(root, &matcher);
1144        let relative: Vec<String> = paths
1145            .iter()
1146            .filter_map(|path| root_relative_forward_slash(path, root))
1147            .collect();
1148
1149        assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
1150    }
1151
1152    fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
1153        reports
1154            .iter()
1155            .flat_map(|r| r.warnings.iter().map(|w| w.kind))
1156            .collect()
1157    }
1158
1159    #[test]
1160    fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
1161        let dir = tempfile::tempdir().unwrap();
1162        let root = dir.path();
1163        write_manifest(
1164            root,
1165            "plugins/alpha/kibana.jsonc",
1166            r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
1167        );
1168        write_manifest(
1169            root,
1170            "plugins/beta/kibana.jsonc",
1171            r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
1172        );
1173        let ext = plugin_with(vec![rule(
1174            "**/kibana.jsonc",
1175            &[("type", Value::String("plugin".into()))],
1176            vec![
1177                seed(
1178                    "public/index.{ts,tsx}",
1179                    &[("plugin.browser", Value::Bool(true))],
1180                ),
1181                seed(
1182                    "server/index.{ts,tsx}",
1183                    &[("plugin.server", Value::Bool(true))],
1184                ),
1185            ],
1186        )]);
1187
1188        let reports = check_manifest_entries(&ext, root);
1189        assert_eq!(reports.len(), 1);
1190        let report = &reports[0];
1191        assert!(
1192            report.warnings.is_empty(),
1193            "clean plugin, got {:?}",
1194            report.warnings
1195        );
1196        // manifests_matched is sorted (agents diff across runs).
1197        assert_eq!(
1198            report.manifests_matched,
1199            vec![
1200                "plugins/alpha/kibana.jsonc".to_string(),
1201                "plugins/beta/kibana.jsonc".to_string()
1202            ]
1203        );
1204
1205        let beta = report
1206            .matched
1207            .iter()
1208            .find(|m| m.path == "plugins/beta/kibana.jsonc")
1209            .expect("beta matched");
1210        assert!(beta.when_passed);
1211        assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
1212        assert!(
1213            !beta.seeded.iter().any(|s| s.contains("server/index")),
1214            "beta server:false must not seed the server entry, got {:?}",
1215            beta.seeded
1216        );
1217    }
1218
1219    #[test]
1220    fn check_warns_manifests_matched_none() {
1221        let dir = tempfile::tempdir().unwrap();
1222        let ext = plugin_with(vec![rule(
1223            "**/nonexistent.jsonc",
1224            &[],
1225            vec![seed("public/index.ts", &[])],
1226        )]);
1227        let reports = check_manifest_entries(&ext, dir.path());
1228        assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
1229        assert_eq!(
1230            reports[0].warnings[0].glob.as_deref(),
1231            Some("**/nonexistent.jsonc")
1232        );
1233    }
1234
1235    #[test]
1236    fn check_warns_field_path_unresolved_on_typo() {
1237        let dir = tempfile::tempdir().unwrap();
1238        let root = dir.path();
1239        write_manifest(
1240            root,
1241            "plugins/alpha/kibana.jsonc",
1242            r#"{"type":"plugin","plugin":{"browser":true}}"#,
1243        );
1244        let ext = plugin_with(vec![rule(
1245            "**/kibana.jsonc",
1246            &[("type", Value::String("plugin".into()))],
1247            // typo: plugin.extarPublicDirs does not exist
1248            vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
1249        )]);
1250        let reports = check_manifest_entries(&ext, root);
1251        let warn = reports[0]
1252            .warnings
1253            .iter()
1254            .find(|w| w.kind == WarningKind::FieldPathUnresolved)
1255            .expect("field-path-unresolved warning");
1256        assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
1257    }
1258
1259    #[test]
1260    fn check_reports_interpolation_limits_without_partial_seeding() {
1261        let dir = tempfile::tempdir().unwrap();
1262        let root = dir.path();
1263        let values = (0..=MAX_MANIFEST_FIELD_VALUES)
1264            .map(|index| index.to_string())
1265            .collect::<Vec<_>>();
1266        write_manifest(
1267            root,
1268            "plugins/alpha/manifest.json",
1269            &serde_json::json!({ "entries": values }).to_string(),
1270        );
1271        let ext = plugin_with(vec![rule(
1272            "**/manifest.json",
1273            &[],
1274            vec![seed("static.ts", &[]), seed("${entries}/index.ts", &[])],
1275        )]);
1276
1277        let reports = check_manifest_entries(&ext, root);
1278        let warning = reports[0]
1279            .warnings
1280            .iter()
1281            .find(|warning| warning.kind == WarningKind::FieldValuesLimitExceeded)
1282            .expect("field-values-limit-exceeded warning");
1283        assert_eq!(warning.field_path.as_deref(), Some("entries"));
1284        assert_eq!(
1285            warning.manifest.as_deref(),
1286            Some("plugins/alpha/manifest.json")
1287        );
1288        assert_eq!(
1289            warning.kind.expansion_limit(),
1290            Some(MAX_MANIFEST_FIELD_VALUES)
1291        );
1292        assert_eq!(
1293            reports[0].matched[0].seeded,
1294            vec!["plugins/alpha/static.ts"],
1295            "a limited template must not suppress valid sibling seeds"
1296        );
1297    }
1298
1299    #[test]
1300    fn check_reports_wildcard_gate_limits_without_a_false_exclusion_warning() {
1301        let dir = tempfile::tempdir().unwrap();
1302        let root = dir.path();
1303        let items = std::iter::repeat_with(|| serde_json::json!({ "enabled": true }))
1304            .take(MAX_MANIFEST_FIELD_VALUES + 1)
1305            .collect::<Vec<_>>();
1306        write_manifest(
1307            root,
1308            "plugins/alpha/manifest.json",
1309            &serde_json::json!({ "items": items }).to_string(),
1310        );
1311        let ext = plugin_with(vec![rule(
1312            "**/manifest.json",
1313            &[("items[*].enabled", Value::Bool(true))],
1314            vec![seed("index.ts", &[])],
1315        )]);
1316
1317        let reports = check_manifest_entries(&ext, root);
1318        assert!(
1319            kinds(&reports).contains(&WarningKind::FieldValuesLimitExceeded),
1320            "wildcard fan-out must report its explicit bound"
1321        );
1322        assert!(
1323            !kinds(&reports).contains(&WarningKind::WhenExcludedAll),
1324            "an evaluation limit is not a false 'when' result"
1325        );
1326        assert!(!reports[0].matched[0].when_passed);
1327    }
1328
1329    #[test]
1330    fn check_warns_when_excluded_all() {
1331        let dir = tempfile::tempdir().unwrap();
1332        let root = dir.path();
1333        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
1334        let ext = plugin_with(vec![rule(
1335            "**/kibana.jsonc",
1336            &[("type", Value::String("plugin".into()))],
1337            vec![seed("public/index.ts", &[])],
1338        )]);
1339        let reports = check_manifest_entries(&ext, root);
1340        assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
1341    }
1342
1343    #[test]
1344    fn check_warns_manifest_parse_failed_per_file() {
1345        let dir = tempfile::tempdir().unwrap();
1346        let root = dir.path();
1347        write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
1348        write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
1349        let ext = plugin_with(vec![rule(
1350            "**/kibana.jsonc",
1351            &[("type", Value::String("plugin".into()))],
1352            vec![seed("public/index.ts", &[])],
1353        )]);
1354        let reports = check_manifest_entries(&ext, root);
1355        let warn = reports[0]
1356            .warnings
1357            .iter()
1358            .find(|w| w.kind == WarningKind::ManifestParseFailed)
1359            .expect("manifest-parse-failed warning");
1360        // carries the offending file, not just the glob (agents read the slot).
1361        assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
1362    }
1363
1364    #[test]
1365    fn check_output_is_deterministic_across_walk_order() {
1366        let dir = tempfile::tempdir().unwrap();
1367        let root = dir.path();
1368        // Names chosen so raw readdir order is unlikely to be sorted.
1369        for name in ["mmm", "aaa", "zzz", "ccc"] {
1370            write_manifest(
1371                root,
1372                &format!("plugins/{name}/kibana.jsonc"),
1373                r#"{"type":"plugin"}"#,
1374            );
1375        }
1376        let ext = plugin_with(vec![rule(
1377            "**/kibana.jsonc",
1378            &[("type", Value::String("plugin".into()))],
1379            // escapes root -> one entry-outside-root warning per manifest.
1380            vec![seed("../../../../escape/index.ts", &[])],
1381        )]);
1382        let reports = check_manifest_entries(&ext, root);
1383        let r = &reports[0];
1384        // manifests_matched and the per-file warnings are sorted, not walk order.
1385        let mut sorted = r.manifests_matched.clone();
1386        sorted.sort();
1387        assert_eq!(
1388            r.manifests_matched, sorted,
1389            "manifests_matched must be sorted"
1390        );
1391        let warn_manifests: Vec<&str> = r
1392            .warnings
1393            .iter()
1394            .filter_map(|w| w.manifest.as_deref())
1395            .collect();
1396        let mut sorted_w = warn_manifests.clone();
1397        sorted_w.sort_unstable();
1398        assert_eq!(
1399            warn_manifests, sorted_w,
1400            "entry-outside-root warnings must be sorted"
1401        );
1402    }
1403
1404    #[test]
1405    fn check_warns_entry_outside_root() {
1406        let dir = tempfile::tempdir().unwrap();
1407        let root = dir.path();
1408        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
1409        let ext = plugin_with(vec![rule(
1410            "**/kibana.jsonc",
1411            &[("type", Value::String("plugin".into()))],
1412            // escapes above root from plugins/alpha
1413            vec![seed("../../../../escape/index.ts", &[])],
1414        )]);
1415        let reports = check_manifest_entries(&ext, root);
1416        let warn = reports[0]
1417            .warnings
1418            .iter()
1419            .find(|w| w.kind == WarningKind::EntryOutsideRoot)
1420            .expect("entry-outside-root warning");
1421        assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
1422        assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
1423    }
1424}