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
352/// Seed the entries of one manifest that a built-in plugin already found and
353/// parsed. The rule's `manifests` glob is not used: the caller owns discovery.
354/// The gates, the `${...}` interpolation and the root containment are the same
355/// as for an external plugin rule. A gate or template that exceeds an
356/// evaluator limit seeds nothing, as on the external path.
357#[must_use]
358pub(crate) fn seed_parsed_manifest(
359    rule: &ManifestEntryRule,
360    manifest: &Value,
361    manifest_path: &Path,
362    root: &Path,
363) -> Vec<String> {
364    if !matches!(when_matches(manifest, &rule.when), Ok(true)) {
365        return Vec::new();
366    }
367    seed_rule_entries(rule, manifest, manifest_path, root).0
368}
369
370/// Parse manifest source text in the rule's declared format.
371#[must_use]
372pub(crate) fn parse_manifest(source: &str, format: ManifestFormat) -> Option<Value> {
373    match format {
374        ManifestFormat::Jsonc => fallow_config::jsonc::parse_to_value(source).ok(),
375        ManifestFormat::Json => serde_json::from_str(source).ok(),
376    }
377}
378
379/// Assemble the RULE-LEVEL diagnostics (matched-none / when-excluded-all /
380/// field-path-unresolved) from the walk tallies. Per-manifest diagnostics
381/// (parse-failed, entry-outside-root) are pushed during the walk. `parsed` is
382/// the count of manifests that read + parsed; `passed` cleared the `when` gate;
383/// `gate_errors` could not be evaluated because their traversal exceeded a
384/// declared bound.
385fn rule_level_warnings(
386    manifests: &str,
387    matched: usize,
388    parsed: usize,
389    passed: usize,
390    gate_errors: usize,
391    resolved: &BTreeMap<&str, bool>,
392) -> Vec<CheckWarning> {
393    let mut out = Vec::new();
394    if matched == 0 {
395        out.push(CheckWarning::glob(
396            WarningKind::ManifestsMatchedNone,
397            manifests,
398        ));
399        return out;
400    }
401    // Only claim the `when` gate excluded everything when there WERE parseable
402    // manifests for it to gate; if all failed to parse, the per-file
403    // parse-failed warnings already explain the zero seed.
404    if parsed > 0 && passed == 0 && gate_errors == 0 {
405        out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
406        return out;
407    }
408    if passed == 0 {
409        return out;
410    }
411    for (path, was_resolved) in resolved {
412        if !was_resolved {
413            out.push(CheckWarning::field(
414                WarningKind::FieldPathUnresolved,
415                (*path).to_string(),
416            ));
417        }
418    }
419    out
420}
421
422/// Seed one manifest's entries: returns the root-relative entry globs plus any
423/// `entry-outside-root` diagnostics.
424fn seed_rule_entries(
425    rule: &ManifestEntryRule,
426    manifest: &Value,
427    manifest_path: &Path,
428    root: &Path,
429) -> (Vec<String>, Vec<CheckWarning>) {
430    let rel_manifest = root_relative_forward_slash(manifest_path, root);
431    let mut seeded = Vec::new();
432    let mut warnings = Vec::new();
433    for seed in &rule.entries {
434        match when_matches(manifest, &seed.when) {
435            Ok(true) => {}
436            Ok(false) => continue,
437            Err(error) => {
438                warnings.push(error.into_warning(rel_manifest.clone()));
439                continue;
440            }
441        }
442        let concretes = match expand_interpolations(&seed.path, manifest) {
443            Ok(concretes) => concretes,
444            Err(error) => {
445                warnings.push(error.into_warning(rel_manifest.clone()));
446                continue;
447            }
448        };
449        for concrete in concretes {
450            match normalize_config_path(&concrete, manifest_path, root) {
451                Some(rel) => seeded.push(rel),
452                None => warnings.push(CheckWarning {
453                    kind: WarningKind::EntryOutsideRoot,
454                    glob: None,
455                    field_path: None,
456                    manifest: rel_manifest.clone(),
457                    entry: Some(concrete),
458                }),
459            }
460        }
461    }
462    (seeded, warnings)
463}
464
465/// Re-emit a rule report's warnings as `tracing::warn!` on the production path.
466fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
467    for warning in &report.warnings {
468        match warning.kind {
469            WarningKind::EntriesEmpty => tracing::warn!(
470                "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
471                 list; it seeds nothing.",
472                report.manifests
473            ),
474            WarningKind::ManifestsMatchedNone => tracing::warn!(
475                "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
476                 Check the glob and whether the manifests live under an ignored directory.",
477                report.manifests
478            ),
479            WarningKind::ManifestParseFailed => tracing::warn!(
480                "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
481                 it could not be read or parsed using the rule's declared format.",
482                warning.manifest.as_deref().unwrap_or(""),
483                report.manifests
484            ),
485            WarningKind::FieldValuesLimitExceeded => tracing::warn!(
486                "Plugin '{plugin_name}': manifestEntries field path '{}' in manifest '{}' exceeded \
487                 the traversal value limit of {}. The affected gate or template was skipped without \
488                 partial seeding.",
489                warning.field_path.as_deref().unwrap_or(""),
490                warning.manifest.as_deref().unwrap_or(""),
491                warning
492                    .kind
493                    .expansion_limit()
494                    .unwrap_or(MAX_MANIFEST_FIELD_VALUES)
495            ),
496            WarningKind::EntryExpansionLimitExceeded => tracing::warn!(
497                "Plugin '{plugin_name}': manifestEntries template '{}' in manifest '{}' exceeded \
498                 the concrete entry limit of {}. No entries were seeded from that template.",
499                warning.entry.as_deref().unwrap_or(""),
500                warning.manifest.as_deref().unwrap_or(""),
501                warning
502                    .kind
503                    .expansion_limit()
504                    .unwrap_or(MAX_MANIFEST_ENTRY_EXPANSIONS)
505            ),
506            WarningKind::WhenExcludedAll => tracing::warn!(
507                "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
508                 manifest(s) for glob '{}'. No entries were seeded.",
509                report.manifests
510            ),
511            WarningKind::FieldPathUnresolved => tracing::warn!(
512                "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
513                 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
514                warning.field_path.as_deref().unwrap_or("")
515            ),
516            WarningKind::EntryOutsideRoot => tracing::warn!(
517                "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
518                 outside the project root and was skipped.",
519                warning.entry.as_deref().unwrap_or(""),
520                warning.manifest.as_deref().unwrap_or("")
521            ),
522            // Check-only; never produced by build_rule_report.
523            WarningKind::SeededPathsMissing => {}
524        }
525    }
526}
527
528/// Collect every field path a rule references (rule-level `when` keys, per-seed
529/// `when` keys, and `${...}` interpolations in seed paths) for typo diagnostics.
530fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<ManifestFieldPath> {
531    let mut paths: Vec<ManifestFieldPath> = rule
532        .when
533        .iter()
534        .filter(|(_, condition)| condition_requires_present_value(condition))
535        .map(|(path, _)| path.clone())
536        .collect();
537    for seed in &rule.entries {
538        paths.extend(
539            seed.when
540                .iter()
541                .filter(|(_, condition)| condition_requires_present_value(condition))
542                .map(|(path, _)| path.clone()),
543        );
544        paths.extend(seed.path.parts().iter().filter_map(|part| match part {
545            ManifestPathPart::Field(path) => Some(path.clone()),
546            ManifestPathPart::Literal(_) => None,
547        }));
548    }
549    paths.sort();
550    paths.dedup();
551    paths
552}
553
554/// Expand `${dotted.field}` interpolations in a path against a manifest, fanning
555/// out over string / array field values. Returns an empty vec when any
556/// interpolation resolves to nothing (a missing field seeds nothing).
557fn expand_interpolations(
558    path: &ManifestPathTemplate,
559    manifest: &Value,
560) -> Result<Vec<String>, ExpansionError> {
561    let mut expanded = vec![String::new()];
562    for part in path.parts() {
563        match part {
564            ManifestPathPart::Literal(literal) => {
565                for value in &mut expanded {
566                    value.push_str(literal);
567                }
568            }
569            ManifestPathPart::Field(field) => {
570                let values = field_segment_values(manifest, field)?;
571                if values.is_empty() {
572                    return Ok(Vec::new());
573                }
574
575                let Some(next_len) = expanded.len().checked_mul(values.len()) else {
576                    return Err(ExpansionError::EntryPaths {
577                        template: path.as_str().to_string(),
578                    });
579                };
580                if next_len > MAX_MANIFEST_ENTRY_EXPANSIONS {
581                    return Err(ExpansionError::EntryPaths {
582                        template: path.as_str().to_string(),
583                    });
584                }
585
586                let mut next = Vec::with_capacity(next_len);
587                for prefix in &expanded {
588                    for value in &values {
589                        let mut concrete = String::with_capacity(prefix.len() + value.len());
590                        concrete.push_str(prefix);
591                        concrete.push_str(value);
592                        next.push(concrete);
593                    }
594                }
595                expanded = next;
596            }
597        }
598    }
599    Ok(expanded)
600}
601
602/// The path-segment string values a field yields: a string or number yields
603/// one; a final array yields one per scalar element; anything else yields none.
604fn field_segment_values(
605    manifest: &Value,
606    field: &ManifestFieldPath,
607) -> Result<Vec<String>, ExpansionError> {
608    let mut values = Vec::new();
609    for value in field_values(manifest, field)? {
610        match value {
611            Value::Array(items) => {
612                for item in items.iter().filter_map(scalar_segment) {
613                    push_field_segment(&mut values, item, field)?;
614                }
615            }
616            value => {
617                if let Some(segment) = scalar_segment(value) {
618                    push_field_segment(&mut values, segment, field)?;
619                }
620            }
621        }
622    }
623    Ok(values)
624}
625
626fn push_field_segment(
627    values: &mut Vec<String>,
628    value: String,
629    field: &ManifestFieldPath,
630) -> Result<(), ExpansionError> {
631    if values.len() == MAX_MANIFEST_FIELD_VALUES {
632        return Err(ExpansionError::FieldValues {
633            field_path: field.as_str().to_string(),
634        });
635    }
636    values.push(value);
637    Ok(())
638}
639
640fn scalar_segment(value: &Value) -> Option<String> {
641    match value {
642        Value::String(s) if !s.is_empty() => Some(s.clone()),
643        Value::Number(n) => Some(n.to_string()),
644        _ => None,
645    }
646}
647
648/// Whether every `(dotted-path, expected)` pair in `when` matches the manifest
649/// by strict equality. An empty map always matches.
650fn when_matches(
651    manifest: &Value,
652    when: &BTreeMap<ManifestFieldPath, ManifestCondition>,
653) -> Result<bool, ExpansionError> {
654    for (path, condition) in when {
655        let values = field_values(manifest, path)?;
656        let matches = match condition {
657            ManifestCondition::Equals(expected) => values.contains(&expected),
658            ManifestCondition::Exists(predicate) => values.is_empty() != predicate.exists,
659        };
660        if !matches {
661            return Ok(false);
662        }
663    }
664    Ok(true)
665}
666
667fn condition_requires_present_value(condition: &ManifestCondition) -> bool {
668    match condition {
669        ManifestCondition::Equals(_) => true,
670        ManifestCondition::Exists(predicate) => predicate.exists,
671    }
672}
673
674/// Evaluate a typed manifest field path, including explicit `[*]` traversal.
675fn field_values<'a>(
676    value: &'a Value,
677    path: &ManifestFieldPath,
678) -> Result<Vec<&'a Value>, ExpansionError> {
679    let mut current = vec![value];
680    for segment in path.segments() {
681        let mut next = Vec::new();
682        for value in current {
683            match segment {
684                ManifestFieldSegment::Key(key) => {
685                    if let Some(child) = value.get(key) {
686                        push_field_value(&mut next, child, path)?;
687                    }
688                }
689                ManifestFieldSegment::Each => {
690                    if let Value::Array(items) = value {
691                        for item in items {
692                            push_field_value(&mut next, item, path)?;
693                        }
694                    }
695                }
696            }
697        }
698        current = next;
699    }
700    Ok(current)
701}
702
703fn push_field_value<'a>(
704    values: &mut Vec<&'a Value>,
705    value: &'a Value,
706    path: &ManifestFieldPath,
707) -> Result<(), ExpansionError> {
708    if values.len() == MAX_MANIFEST_FIELD_VALUES {
709        return Err(ExpansionError::FieldValues {
710            field_path: path.as_str().to_string(),
711        });
712    }
713    values.push(value);
714    Ok(())
715}
716
717/// Walk `root` (respecting `.gitignore`, skipping `node_modules`) and return the
718/// absolute paths of files whose root-relative path matches `matcher`. Bounded
719/// to the manifest glob; runs only when an active plugin declares manifestEntries.
720fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
721    let mut out = Vec::new();
722    let canonical_root = root.canonicalize().ok();
723    let walker = fallow_config::source_walk_builder(root)
724        .filter_entry(|entry| entry.file_name() != "node_modules")
725        .build();
726    for entry in walker.flatten() {
727        let Some(file_type) = entry.file_type() else {
728            continue;
729        };
730        if file_type.is_dir() {
731            continue;
732        }
733        let path = entry.path();
734        if file_type.is_symlink()
735            && !is_contained_regular_file_symlink(path, canonical_root.as_deref())
736        {
737            tracing::debug!(
738                path = %path.display(),
739                "skipping manifest symlink with a broken, non-file, or outside-root target"
740            );
741            continue;
742        }
743        if let Some(rel) = root_relative_forward_slash(path, root)
744            && matcher.is_match(Path::new(&rel))
745        {
746            out.push(path.to_path_buf());
747        }
748    }
749    // `ignore::WalkBuilder` yields raw filesystem order; sort so seeding and the
750    // check report (manifests_matched, per-manifest warnings) are deterministic
751    // across machines and CI runners.
752    out.sort();
753    out
754}
755
756fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
757    let Some(root) = canonical_root else {
758        return false;
759    };
760    let Ok(target) = path.canonicalize() else {
761        return false;
762    };
763    target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
764}
765
766/// Root-relative forward-slash string for a discovered (absolute) path, or
767/// `None` if it is not under `root`.
768fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
769    let rel = file.strip_prefix(root).ok()?;
770    Some(rel.to_string_lossy().replace('\\', "/"))
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776    use fallow_config::{
777        EntryPointRole, ManifestExistsPredicate, ManifestFormat, ManifestSeedRule,
778    };
779
780    fn json(text: &str) -> Value {
781        serde_json::from_str(text).unwrap()
782    }
783
784    fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
785        ManifestSeedRule {
786            path: path.parse().unwrap(),
787            when: conditions(when),
788        }
789    }
790
791    fn field(path: &str) -> ManifestFieldPath {
792        path.parse().unwrap()
793    }
794
795    fn template(path: &str) -> ManifestPathTemplate {
796        path.parse().unwrap()
797    }
798
799    fn conditions(when: &[(&str, Value)]) -> BTreeMap<ManifestFieldPath, ManifestCondition> {
800        when.iter()
801            .map(|(path, expected)| (field(path), ManifestCondition::Equals(expected.clone())))
802            .collect()
803    }
804
805    fn exists(path: &str, expected: bool) -> (ManifestFieldPath, ManifestCondition) {
806        (
807            field(path),
808            ManifestCondition::Exists(ManifestExistsPredicate { exists: expected }),
809        )
810    }
811
812    #[test]
813    fn field_values_traverse_nested_fields_and_object_arrays() {
814        let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
815        assert_eq!(
816            field_values(&m, &field("plugin.browser")).unwrap(),
817            vec![&Value::Bool(true)]
818        );
819        assert_eq!(
820            field_values(&m, &field("plugin.id")).unwrap(),
821            vec![&Value::String("actions".into())]
822        );
823        assert!(
824            field_values(&m, &field("plugin.missing"))
825                .unwrap()
826                .is_empty()
827        );
828        assert!(field_values(&m, &field("absent.field")).unwrap().is_empty());
829
830        let m = json(
831            r#"{"content_scripts":[{"js":["a.js","b.js"]},null,{"js":["c.js"]},"invalid",{"css":[]}]}"#,
832        );
833        assert_eq!(
834            field_segment_values(&m, &field("content_scripts[*].js")).unwrap(),
835            vec!["a.js", "b.js", "c.js"]
836        );
837
838        let nested = json(
839            r#"{"groups":[{"entries":[{"path":"a.js"},{"path":"b.js"}]},{"entries":[{"path":"c.js"}]}]}"#,
840        );
841        assert_eq!(
842            field_segment_values(&nested, &field("groups[*].entries[*].path")).unwrap(),
843            vec!["a.js", "b.js", "c.js"]
844        );
845    }
846
847    #[test]
848    fn when_matches_is_strict_equality_and_presence_is_not_matched() {
849        let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
850        let mut when = BTreeMap::new();
851        when.insert(
852            field("type"),
853            ManifestCondition::Equals(Value::String("plugin".into())),
854        );
855        assert!(when_matches(&m, &when).unwrap());
856
857        // browser is present but false: matching against `true` must FAIL
858        // (strict equality, no presence overload).
859        let mut when_browser = BTreeMap::new();
860        when_browser.insert(
861            field("plugin.browser"),
862            ManifestCondition::Equals(Value::Bool(true)),
863        );
864        assert!(!when_matches(&m, &when_browser).unwrap());
865
866        // empty when always matches
867        assert!(when_matches(&m, &BTreeMap::new()).unwrap());
868
869        let manifest = json(r#"{"plugins":[{"kind":"worker"},{"kind":"browser"}]}"#);
870        let wildcard = conditions(&[("plugins[*].kind", Value::String("browser".into()))]);
871        assert!(when_matches(&manifest, &wildcard).unwrap());
872
873        let structured = json(r#"{"array":["worker"],"object":{"kind":"browser"}}"#);
874        let structured_when = BTreeMap::from([
875            (
876                field("array"),
877                ManifestCondition::Equals(json(r#"["worker"]"#)),
878            ),
879            (
880                field("object"),
881                ManifestCondition::Equals(json(r#"{"kind":"browser"}"#)),
882            ),
883        ]);
884        assert!(when_matches(&structured, &structured_when).unwrap());
885    }
886
887    #[test]
888    fn exists_conditions_test_presence_without_truthiness() {
889        let manifest = json(
890            r#"{"presentFalse":false,"presentNull":null,"presentEmpty":"","presentObject":{},"items":[]}"#,
891        );
892        for path in [
893            "presentFalse",
894            "presentNull",
895            "presentEmpty",
896            "presentObject",
897            "items",
898        ] {
899            assert!(
900                when_matches(&manifest, &BTreeMap::from([exists(path, true)])).unwrap(),
901                "{path} is present regardless of its value"
902            );
903        }
904        assert!(when_matches(&manifest, &BTreeMap::from([exists("missing", false)])).unwrap());
905        assert!(!when_matches(&manifest, &BTreeMap::from([exists("missing", true)])).unwrap());
906        assert!(
907            when_matches(
908                &manifest,
909                &BTreeMap::from([exists("items[*].value", false)])
910            )
911            .unwrap()
912        );
913    }
914
915    #[test]
916    fn exists_false_can_gate_a_rule_without_an_unresolved_path_warning() {
917        let dir = tempfile::tempdir().unwrap();
918        let root = dir.path();
919        write_manifest(root, "plugins/alpha/manifest.json", r#"{"name":"alpha"}"#);
920        let mut manifest_rule = rule("**/manifest.json", &[], vec![seed("index.ts", &[])]);
921        manifest_rule.when = BTreeMap::from([exists("main", false)]);
922
923        let reports = check_manifest_entries(&plugin_with(vec![manifest_rule]), root);
924        assert!(reports[0].warnings.is_empty());
925        assert!(reports[0].matched[0].when_passed);
926        assert_eq!(reports[0].matched[0].seeded, vec!["plugins/alpha/index.ts"]);
927    }
928
929    #[test]
930    fn parse_manifest_honors_the_declared_format() {
931        let jsonc_only = r#"{
932            // JSONC comment
933            "type": "plugin",
934        }"#;
935
936        assert!(parse_manifest(jsonc_only, ManifestFormat::Jsonc).is_some());
937        assert!(parse_manifest(jsonc_only, ManifestFormat::Json).is_none());
938        assert!(parse_manifest(r#"{"type":"plugin"}"#, ManifestFormat::Json).is_some());
939    }
940
941    #[test]
942    fn expand_interpolations_string_array_and_missing() {
943        let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
944        // string field -> one entry
945        assert_eq!(
946            expand_interpolations(&template("${plugin.id}/index.ts"), &m).unwrap(),
947            vec!["actions/index.ts"]
948        );
949        // array field -> one entry per element
950        assert_eq!(
951            expand_interpolations(&template("${plugin.extraPublicDirs}/index.{ts,tsx}"), &m)
952                .unwrap(),
953            vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
954        );
955        // missing field -> nothing seeded
956        assert!(
957            expand_interpolations(&template("${plugin.absent}/index.ts"), &m)
958                .unwrap()
959                .is_empty()
960        );
961        // no interpolation -> passthrough
962        assert_eq!(
963            expand_interpolations(&template("public/index.{ts,tsx}"), &m).unwrap(),
964            vec!["public/index.{ts,tsx}"]
965        );
966    }
967
968    #[test]
969    fn interpolation_limits_are_explicit_errors() {
970        let too_many: Vec<Value> = (0..=MAX_MANIFEST_FIELD_VALUES)
971            .map(|index| Value::String(index.to_string()))
972            .collect();
973        let manifest = serde_json::json!({ "values": too_many });
974        assert_eq!(
975            expand_interpolations(&template("${values}/index.ts"), &manifest),
976            Err(ExpansionError::FieldValues {
977                field_path: "values".to_string(),
978            })
979        );
980
981        let factors = (0..65)
982            .map(|index| Value::String(index.to_string()))
983            .collect::<Vec<_>>();
984        let manifest = serde_json::json!({ "left": factors, "right": factors });
985        assert_eq!(
986            expand_interpolations(&template("${left}/${right}/index.ts"), &manifest),
987            Err(ExpansionError::EntryPaths {
988                template: "${left}/${right}/index.ts".to_string(),
989            })
990        );
991    }
992
993    #[test]
994    fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
995        let dir = tempfile::tempdir().unwrap();
996        let root = dir.path();
997        let manifest_dir = root.join("x-pack/plugins/actions");
998        std::fs::create_dir_all(&manifest_dir).unwrap();
999        let manifest_path = manifest_dir.join("kibana.jsonc");
1000        std::fs::write(
1001            &manifest_path,
1002            r#"{
1003                // a real Kibana-shaped manifest
1004                "type": "plugin",
1005                "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
1006            }"#,
1007        )
1008        .unwrap();
1009
1010        let ext = ExternalPluginDef {
1011            schema: None,
1012            name: "kibana".to_string(),
1013            detection: None,
1014            enablers: vec![],
1015            entry_points: vec![],
1016            entry_point_role: EntryPointRole::Runtime,
1017            manifest_entries: vec![ManifestEntryRule {
1018                manifests: "**/kibana.jsonc".to_string(),
1019                format: ManifestFormat::Jsonc,
1020                when: conditions(&[("type", Value::String("plugin".into()))]),
1021                entries: vec![
1022                    seed(
1023                        "public/index.{ts,tsx}",
1024                        &[("plugin.browser", Value::Bool(true))],
1025                    ),
1026                    seed(
1027                        "server/index.{ts,tsx}",
1028                        &[("plugin.server", Value::Bool(true))],
1029                    ),
1030                    seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
1031                ],
1032            }],
1033            config_patterns: vec![],
1034            always_used: vec![],
1035            tooling_dependencies: vec![],
1036            used_exports: vec![],
1037            used_class_members: vec![],
1038        };
1039
1040        let rules = evaluate_manifest_entries(&ext, root);
1041        let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
1042
1043        // browser:true seeds public; server:false does NOT seed server; extraPublicDirs fans out.
1044        assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
1045        assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
1046        assert!(
1047            !paths.iter().any(|p| p.contains("server/index")),
1048            "server:false must not seed the server entry, got {paths:?}"
1049        );
1050    }
1051
1052    #[test]
1053    fn evaluate_fans_out_over_object_array_fields() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let root = dir.path();
1056        write_manifest(
1057            root,
1058            "extension/manifest.json",
1059            r#"{
1060                "manifest_version": 3,
1061                "content_scripts": [
1062                    { "matches": ["https://a.example/*"], "js": ["content/a.js", "content/b.js"] },
1063                    { "matches": ["https://b.example/*"], "js": ["content/c.js"] }
1064                ]
1065            }"#,
1066        );
1067        let ext = plugin_with(vec![rule(
1068            "**/manifest.json",
1069            &[("manifest_version", Value::Number(3.into()))],
1070            vec![seed("${content_scripts[*].js}", &[])],
1071        )]);
1072
1073        let reports = check_manifest_entries(&ext, root);
1074        assert!(reports[0].warnings.is_empty());
1075        assert_eq!(
1076            reports[0].matched[0].seeded,
1077            vec![
1078                "extension/content/a.js",
1079                "extension/content/b.js",
1080                "extension/content/c.js",
1081            ]
1082        );
1083    }
1084
1085    fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
1086        ExternalPluginDef {
1087            schema: None,
1088            name: "kibana".to_string(),
1089            detection: None,
1090            enablers: vec![],
1091            entry_points: vec![],
1092            entry_point_role: EntryPointRole::Runtime,
1093            manifest_entries: rules,
1094            config_patterns: vec![],
1095            always_used: vec![],
1096            tooling_dependencies: vec![],
1097            used_exports: vec![],
1098            used_class_members: vec![],
1099        }
1100    }
1101
1102    fn rule(
1103        manifests: &str,
1104        when: &[(&str, Value)],
1105        entries: Vec<ManifestSeedRule>,
1106    ) -> ManifestEntryRule {
1107        ManifestEntryRule {
1108            manifests: manifests.to_string(),
1109            format: ManifestFormat::Jsonc,
1110            when: conditions(when),
1111            entries,
1112        }
1113    }
1114
1115    fn write_manifest(root: &Path, rel: &str, body: &str) {
1116        let p = root.join(rel);
1117        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1118        std::fs::write(p, body).unwrap();
1119    }
1120
1121    #[cfg(unix)]
1122    fn symlink_file(target: &Path, link: &Path) {
1123        std::os::unix::fs::symlink(target, link).expect("create file symlink");
1124    }
1125
1126    #[cfg(windows)]
1127    fn symlink_file(target: &Path, link: &Path) {
1128        std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
1129    }
1130
1131    #[test]
1132    fn manifest_symlinks_must_target_regular_files_inside_root() {
1133        let dir = tempfile::tempdir().expect("create project");
1134        let outside = tempfile::tempdir().expect("create outside dir");
1135        let root = dir.path();
1136        let targets = root.join("targets");
1137        let plugins = root.join("plugins");
1138        std::fs::create_dir_all(&targets).unwrap();
1139        std::fs::create_dir_all(&plugins).unwrap();
1140        std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1141        std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
1142
1143        symlink_file(
1144            &targets.join("inside.jsonc"),
1145            &plugins.join("inside-kibana.jsonc"),
1146        );
1147        symlink_file(
1148            &outside.path().join("outside.jsonc"),
1149            &plugins.join("outside-kibana.jsonc"),
1150        );
1151        symlink_file(
1152            &targets.join("missing.jsonc"),
1153            &plugins.join("broken-kibana.jsonc"),
1154        );
1155
1156        let matcher = globset::Glob::new("**/*-kibana.jsonc")
1157            .unwrap()
1158            .compile_matcher();
1159        let paths = discover_manifest_paths(root, &matcher);
1160        let relative: Vec<String> = paths
1161            .iter()
1162            .filter_map(|path| root_relative_forward_slash(path, root))
1163            .collect();
1164
1165        assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
1166    }
1167
1168    fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
1169        reports
1170            .iter()
1171            .flat_map(|r| r.warnings.iter().map(|w| w.kind))
1172            .collect()
1173    }
1174
1175    #[test]
1176    fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
1177        let dir = tempfile::tempdir().unwrap();
1178        let root = dir.path();
1179        write_manifest(
1180            root,
1181            "plugins/alpha/kibana.jsonc",
1182            r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
1183        );
1184        write_manifest(
1185            root,
1186            "plugins/beta/kibana.jsonc",
1187            r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
1188        );
1189        let ext = plugin_with(vec![rule(
1190            "**/kibana.jsonc",
1191            &[("type", Value::String("plugin".into()))],
1192            vec![
1193                seed(
1194                    "public/index.{ts,tsx}",
1195                    &[("plugin.browser", Value::Bool(true))],
1196                ),
1197                seed(
1198                    "server/index.{ts,tsx}",
1199                    &[("plugin.server", Value::Bool(true))],
1200                ),
1201            ],
1202        )]);
1203
1204        let reports = check_manifest_entries(&ext, root);
1205        assert_eq!(reports.len(), 1);
1206        let report = &reports[0];
1207        assert!(
1208            report.warnings.is_empty(),
1209            "clean plugin, got {:?}",
1210            report.warnings
1211        );
1212        // manifests_matched is sorted (agents diff across runs).
1213        assert_eq!(
1214            report.manifests_matched,
1215            vec![
1216                "plugins/alpha/kibana.jsonc".to_string(),
1217                "plugins/beta/kibana.jsonc".to_string()
1218            ]
1219        );
1220
1221        let beta = report
1222            .matched
1223            .iter()
1224            .find(|m| m.path == "plugins/beta/kibana.jsonc")
1225            .expect("beta matched");
1226        assert!(beta.when_passed);
1227        assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
1228        assert!(
1229            !beta.seeded.iter().any(|s| s.contains("server/index")),
1230            "beta server:false must not seed the server entry, got {:?}",
1231            beta.seeded
1232        );
1233    }
1234
1235    #[test]
1236    fn check_warns_manifests_matched_none() {
1237        let dir = tempfile::tempdir().unwrap();
1238        let ext = plugin_with(vec![rule(
1239            "**/nonexistent.jsonc",
1240            &[],
1241            vec![seed("public/index.ts", &[])],
1242        )]);
1243        let reports = check_manifest_entries(&ext, dir.path());
1244        assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
1245        assert_eq!(
1246            reports[0].warnings[0].glob.as_deref(),
1247            Some("**/nonexistent.jsonc")
1248        );
1249    }
1250
1251    #[test]
1252    fn check_warns_field_path_unresolved_on_typo() {
1253        let dir = tempfile::tempdir().unwrap();
1254        let root = dir.path();
1255        write_manifest(
1256            root,
1257            "plugins/alpha/kibana.jsonc",
1258            r#"{"type":"plugin","plugin":{"browser":true}}"#,
1259        );
1260        let ext = plugin_with(vec![rule(
1261            "**/kibana.jsonc",
1262            &[("type", Value::String("plugin".into()))],
1263            // typo: plugin.extarPublicDirs does not exist
1264            vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
1265        )]);
1266        let reports = check_manifest_entries(&ext, root);
1267        let warn = reports[0]
1268            .warnings
1269            .iter()
1270            .find(|w| w.kind == WarningKind::FieldPathUnresolved)
1271            .expect("field-path-unresolved warning");
1272        assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
1273    }
1274
1275    #[test]
1276    fn check_reports_interpolation_limits_without_partial_seeding() {
1277        let dir = tempfile::tempdir().unwrap();
1278        let root = dir.path();
1279        let values = (0..=MAX_MANIFEST_FIELD_VALUES)
1280            .map(|index| index.to_string())
1281            .collect::<Vec<_>>();
1282        write_manifest(
1283            root,
1284            "plugins/alpha/manifest.json",
1285            &serde_json::json!({ "entries": values }).to_string(),
1286        );
1287        let ext = plugin_with(vec![rule(
1288            "**/manifest.json",
1289            &[],
1290            vec![seed("static.ts", &[]), seed("${entries}/index.ts", &[])],
1291        )]);
1292
1293        let reports = check_manifest_entries(&ext, root);
1294        let warning = reports[0]
1295            .warnings
1296            .iter()
1297            .find(|warning| warning.kind == WarningKind::FieldValuesLimitExceeded)
1298            .expect("field-values-limit-exceeded warning");
1299        assert_eq!(warning.field_path.as_deref(), Some("entries"));
1300        assert_eq!(
1301            warning.manifest.as_deref(),
1302            Some("plugins/alpha/manifest.json")
1303        );
1304        assert_eq!(
1305            warning.kind.expansion_limit(),
1306            Some(MAX_MANIFEST_FIELD_VALUES)
1307        );
1308        assert_eq!(
1309            reports[0].matched[0].seeded,
1310            vec!["plugins/alpha/static.ts"],
1311            "a limited template must not suppress valid sibling seeds"
1312        );
1313    }
1314
1315    #[test]
1316    fn check_reports_wildcard_gate_limits_without_a_false_exclusion_warning() {
1317        let dir = tempfile::tempdir().unwrap();
1318        let root = dir.path();
1319        let items = std::iter::repeat_with(|| serde_json::json!({ "enabled": true }))
1320            .take(MAX_MANIFEST_FIELD_VALUES + 1)
1321            .collect::<Vec<_>>();
1322        write_manifest(
1323            root,
1324            "plugins/alpha/manifest.json",
1325            &serde_json::json!({ "items": items }).to_string(),
1326        );
1327        let ext = plugin_with(vec![rule(
1328            "**/manifest.json",
1329            &[("items[*].enabled", Value::Bool(true))],
1330            vec![seed("index.ts", &[])],
1331        )]);
1332
1333        let reports = check_manifest_entries(&ext, root);
1334        assert!(
1335            kinds(&reports).contains(&WarningKind::FieldValuesLimitExceeded),
1336            "wildcard fan-out must report its explicit bound"
1337        );
1338        assert!(
1339            !kinds(&reports).contains(&WarningKind::WhenExcludedAll),
1340            "an evaluation limit is not a false 'when' result"
1341        );
1342        assert!(!reports[0].matched[0].when_passed);
1343    }
1344
1345    #[test]
1346    fn check_warns_when_excluded_all() {
1347        let dir = tempfile::tempdir().unwrap();
1348        let root = dir.path();
1349        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
1350        let ext = plugin_with(vec![rule(
1351            "**/kibana.jsonc",
1352            &[("type", Value::String("plugin".into()))],
1353            vec![seed("public/index.ts", &[])],
1354        )]);
1355        let reports = check_manifest_entries(&ext, root);
1356        assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
1357    }
1358
1359    #[test]
1360    fn check_warns_manifest_parse_failed_per_file() {
1361        let dir = tempfile::tempdir().unwrap();
1362        let root = dir.path();
1363        write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
1364        write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
1365        let ext = plugin_with(vec![rule(
1366            "**/kibana.jsonc",
1367            &[("type", Value::String("plugin".into()))],
1368            vec![seed("public/index.ts", &[])],
1369        )]);
1370        let reports = check_manifest_entries(&ext, root);
1371        let warn = reports[0]
1372            .warnings
1373            .iter()
1374            .find(|w| w.kind == WarningKind::ManifestParseFailed)
1375            .expect("manifest-parse-failed warning");
1376        // carries the offending file, not just the glob (agents read the slot).
1377        assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
1378    }
1379
1380    #[test]
1381    fn check_output_is_deterministic_across_walk_order() {
1382        let dir = tempfile::tempdir().unwrap();
1383        let root = dir.path();
1384        // Names chosen so raw readdir order is unlikely to be sorted.
1385        for name in ["mmm", "aaa", "zzz", "ccc"] {
1386            write_manifest(
1387                root,
1388                &format!("plugins/{name}/kibana.jsonc"),
1389                r#"{"type":"plugin"}"#,
1390            );
1391        }
1392        let ext = plugin_with(vec![rule(
1393            "**/kibana.jsonc",
1394            &[("type", Value::String("plugin".into()))],
1395            // escapes root -> one entry-outside-root warning per manifest.
1396            vec![seed("../../../../escape/index.ts", &[])],
1397        )]);
1398        let reports = check_manifest_entries(&ext, root);
1399        let r = &reports[0];
1400        // manifests_matched and the per-file warnings are sorted, not walk order.
1401        let mut sorted = r.manifests_matched.clone();
1402        sorted.sort();
1403        assert_eq!(
1404            r.manifests_matched, sorted,
1405            "manifests_matched must be sorted"
1406        );
1407        let warn_manifests: Vec<&str> = r
1408            .warnings
1409            .iter()
1410            .filter_map(|w| w.manifest.as_deref())
1411            .collect();
1412        let mut sorted_w = warn_manifests.clone();
1413        sorted_w.sort_unstable();
1414        assert_eq!(
1415            warn_manifests, sorted_w,
1416            "entry-outside-root warnings must be sorted"
1417        );
1418    }
1419
1420    #[test]
1421    fn check_warns_entry_outside_root() {
1422        let dir = tempfile::tempdir().unwrap();
1423        let root = dir.path();
1424        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
1425        let ext = plugin_with(vec![rule(
1426            "**/kibana.jsonc",
1427            &[("type", Value::String("plugin".into()))],
1428            // escapes above root from plugins/alpha
1429            vec![seed("../../../../escape/index.ts", &[])],
1430        )]);
1431        let reports = check_manifest_entries(&ext, root);
1432        let warn = reports[0]
1433            .warnings
1434            .iter()
1435            .find(|w| w.kind == WarningKind::EntryOutsideRoot)
1436            .expect("entry-outside-root warning");
1437        assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
1438        assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
1439    }
1440}