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}` 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::{ExternalPluginDef, ManifestEntryRule};
21use serde_json::Value;
22
23use super::PathRule;
24use super::config_parser::normalize_config_path;
25
26/// A kind of `manifestEntries` diagnostic, kebab-serialized for agents that
27/// branch on it. Centralizes the vocabulary shared by the production warn path
28/// (`evaluate_manifest_entries`) and the agent-facing check path
29/// (`check_manifest_entries` / `fallow plugin-check`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum WarningKind {
32    /// The `manifests` glob matched zero files.
33    ManifestsMatchedNone,
34    /// The `when` gate excluded every matched manifest.
35    WhenExcludedAll,
36    /// A referenced field path resolved in none of the gated manifests (typo).
37    FieldPathUnresolved,
38    /// The rule's `entries` list is empty; it seeds nothing.
39    EntriesEmpty,
40    /// One or more matched manifests could not be read or parsed.
41    ManifestParseFailed,
42    /// An entry resolved outside the project root and was skipped.
43    EntryOutsideRoot,
44    /// A rule seeded entries but none of the seeded paths exist on disk.
45    /// Check-only (production seeds the pattern regardless of existence).
46    SeededPathsMissing,
47}
48
49impl WarningKind {
50    /// The kebab-case token agents branch on.
51    #[must_use]
52    pub fn as_kebab(self) -> &'static str {
53        match self {
54            Self::ManifestsMatchedNone => "manifests-matched-none",
55            Self::WhenExcludedAll => "when-excluded-all",
56            Self::FieldPathUnresolved => "field-path-unresolved",
57            Self::EntriesEmpty => "entries-empty",
58            Self::ManifestParseFailed => "manifest-parse-failed",
59            Self::EntryOutsideRoot => "entry-outside-root",
60            Self::SeededPathsMissing => "seeded-paths-missing",
61        }
62    }
63}
64
65/// A single `manifestEntries` diagnostic with typed payload slots (agents read
66/// the slot their `kind` implies rather than parsing prose).
67#[derive(Debug, Clone)]
68pub struct CheckWarning {
69    pub kind: WarningKind,
70    /// The offending `manifests` glob (for `manifests-matched-none`).
71    pub glob: Option<String>,
72    /// The offending dotted field path (for `field-path-unresolved`).
73    pub field_path: Option<String>,
74    /// The manifest a per-manifest warning relates to (root-relative).
75    pub manifest: Option<String>,
76    /// The offending resolved entry (for `entry-outside-root`).
77    pub entry: Option<String>,
78}
79
80impl CheckWarning {
81    /// A warning carrying only the offending `manifests` glob.
82    fn glob(kind: WarningKind, glob: &str) -> Self {
83        Self {
84            kind,
85            glob: Some(glob.to_string()),
86            field_path: None,
87            manifest: None,
88            entry: None,
89        }
90    }
91
92    /// A warning carrying only the offending dotted field path.
93    fn field(kind: WarningKind, field_path: String) -> Self {
94        Self {
95            kind,
96            glob: None,
97            field_path: Some(field_path),
98            manifest: None,
99            entry: None,
100        }
101    }
102
103    /// A warning carrying only the offending manifest (root-relative).
104    fn manifest(kind: WarningKind, manifest: String) -> Self {
105        Self {
106            kind,
107            glob: None,
108            field_path: None,
109            manifest: Some(manifest),
110            entry: None,
111        }
112    }
113}
114
115/// What one matched-and-parsed manifest yielded under a rule.
116#[derive(Debug, Clone)]
117pub struct ManifestResult {
118    /// Root-relative manifest path.
119    pub path: String,
120    /// Whether the rule-level `when` gate passed for this manifest.
121    pub when_passed: bool,
122    /// Root-relative entry globs seeded from this manifest (empty unless
123    /// `when_passed`). Each still encodes its own extension (e.g. `{ts,tsx}`).
124    pub seeded: Vec<String>,
125}
126
127/// The result of evaluating one `manifestEntries` rule: the shared source of
128/// truth for BOTH production seeding and the agent-facing check output, so the
129/// two can never drift.
130#[derive(Debug, Clone)]
131pub struct RuleReport {
132    /// The rule's `manifests` glob.
133    pub manifests: String,
134    /// Root-relative paths of the manifests the glob matched (sorted, stable).
135    pub manifests_matched: Vec<String>,
136    /// Per-matched-manifest results (sorted by path).
137    pub matched: Vec<ManifestResult>,
138    /// Diagnostics for this rule, sorted by `(kind, manifest, entry, field_path)`
139    /// so the JSON is byte-identical across machines and CI runs.
140    pub warnings: Vec<CheckWarning>,
141}
142
143/// Evaluate every `manifestEntries` rule on an active external plugin, returning
144/// the root-relative entry patterns to seed. Delegates to the shared
145/// `build_rule_report` so the seeded set and the `fallow plugin-check` report
146/// are computed by identical logic, then re-emits each report warning as a
147/// `tracing::warn!` (the loud stderr behavior is preserved).
148///
149/// Manifest files are config files, not source files, so they are not in the
150/// source-discovery set; this does a bounded `.gitignore`-respecting walk (like
151/// plugin detection's file-existence fallback) to find them. Manifests under
152/// gitignored / `node_modules` directories are intentionally invisible.
153#[must_use]
154pub fn evaluate_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<PathRule> {
155    let mut out = Vec::new();
156    for rule in &ext.manifest_entries {
157        let report = build_rule_report(rule, root);
158        for manifest in &report.matched {
159            for seed in &manifest.seeded {
160                out.push(PathRule::new(seed.clone()));
161            }
162        }
163        emit_report_warnings(&ext.name, &report);
164    }
165    out
166}
167
168/// Evaluate every `manifestEntries` rule and return the STRUCTURED report per
169/// rule, without seeding or warning. This is the read-only dry-run the
170/// `fallow plugin-check` command surfaces to agents.
171#[must_use]
172pub fn check_manifest_entries(ext: &ExternalPluginDef, root: &Path) -> Vec<RuleReport> {
173    ext.manifest_entries
174        .iter()
175        .map(|rule| build_rule_report(rule, root))
176        .collect()
177}
178
179/// The shared core: walk manifests, gate on `when`, seed entries, and collect
180/// diagnostics into a [`RuleReport`]. Deterministically ordered.
181fn build_rule_report(rule: &ManifestEntryRule, root: &Path) -> RuleReport {
182    let mut report = RuleReport {
183        manifests: rule.manifests.clone(),
184        manifests_matched: Vec::new(),
185        matched: Vec::new(),
186        warnings: Vec::new(),
187    };
188
189    if rule.entries.is_empty() {
190        report.warnings.push(CheckWarning::glob(
191            WarningKind::EntriesEmpty,
192            &rule.manifests,
193        ));
194        return report;
195    }
196
197    let Ok(glob) = globset::Glob::new(&rule.manifests) else {
198        // Glob validity is enforced at config load; a compile failure here is
199        // defensive and, like a non-matching glob, seeds nothing.
200        report.warnings.push(CheckWarning::glob(
201            WarningKind::ManifestsMatchedNone,
202            &rule.manifests,
203        ));
204        return report;
205    };
206    let matcher = glob.compile_matcher();
207
208    let referenced = referenced_field_paths(rule);
209    let mut resolved: BTreeMap<&str, bool> =
210        referenced.iter().map(|p| (p.as_str(), false)).collect();
211    let mut passed = 0usize;
212    let mut parsed = 0usize;
213
214    for file in discover_manifest_paths(root, &matcher) {
215        let rel_manifest = root_relative_forward_slash(&file, root)
216            .unwrap_or_else(|| file.to_string_lossy().replace('\\', "/"));
217        report.manifests_matched.push(rel_manifest.clone());
218
219        let manifest: Value = match std::fs::read_to_string(&file)
220            .ok()
221            .and_then(|source| fallow_config::jsonc::parse_to_value(&source).ok())
222        {
223            Some(value) => value,
224            None => {
225                // Per-file diagnostic (with the offending manifest) so an agent
226                // does not have to set-difference manifests_matched vs matched.
227                report.warnings.push(CheckWarning::manifest(
228                    WarningKind::ManifestParseFailed,
229                    rel_manifest,
230                ));
231                continue;
232            }
233        };
234        parsed += 1;
235
236        let when_passed = when_matches(&manifest, &rule.when);
237        let mut seeded = Vec::new();
238        if when_passed {
239            passed += 1;
240            for path in &referenced {
241                if dotted_lookup(&manifest, path).is_some()
242                    && let Some(flag) = resolved.get_mut(path.as_str())
243                {
244                    *flag = true;
245                }
246            }
247            let (entries, mut entry_warnings) = seed_rule_entries(rule, &manifest, &file, root);
248            seeded = entries;
249            report.warnings.append(&mut entry_warnings);
250        }
251        report.matched.push(ManifestResult {
252            path: rel_manifest,
253            when_passed,
254            seeded,
255        });
256    }
257
258    report.warnings.extend(rule_level_warnings(
259        &rule.manifests,
260        report.manifests_matched.len(),
261        parsed,
262        passed,
263        &resolved,
264    ));
265
266    // manifests_matched inherits discover_manifest_paths' sorted order; matched
267    // and warnings are sorted here so the JSON is byte-identical across runs and
268    // filesystems (warnings tie-break on manifest then entry, since a rule can
269    // emit multiple parse-failed / entry-outside-root warnings).
270    report.matched.sort_by(|a, b| a.path.cmp(&b.path));
271    report.warnings.sort_by(|a, b| {
272        a.kind
273            .as_kebab()
274            .cmp(b.kind.as_kebab())
275            .then_with(|| a.manifest.cmp(&b.manifest))
276            .then_with(|| a.entry.cmp(&b.entry))
277            .then_with(|| a.field_path.cmp(&b.field_path))
278    });
279    report
280}
281
282/// Assemble the RULE-LEVEL diagnostics (matched-none / when-excluded-all /
283/// field-path-unresolved) from the walk tallies. Per-manifest diagnostics
284/// (parse-failed, entry-outside-root) are pushed during the walk. `parsed` is
285/// the count of manifests that read + parsed; `passed` cleared the `when` gate.
286fn rule_level_warnings(
287    manifests: &str,
288    matched: usize,
289    parsed: usize,
290    passed: usize,
291    resolved: &BTreeMap<&str, bool>,
292) -> Vec<CheckWarning> {
293    let mut out = Vec::new();
294    if matched == 0 {
295        out.push(CheckWarning::glob(
296            WarningKind::ManifestsMatchedNone,
297            manifests,
298        ));
299        return out;
300    }
301    // Only claim the `when` gate excluded everything when there WERE parseable
302    // manifests for it to gate; if all failed to parse, the per-file
303    // parse-failed warnings already explain the zero seed.
304    if parsed > 0 && passed == 0 {
305        out.push(CheckWarning::glob(WarningKind::WhenExcludedAll, manifests));
306        return out;
307    }
308    if passed == 0 {
309        return out;
310    }
311    for (path, was_resolved) in resolved {
312        if !was_resolved {
313            out.push(CheckWarning::field(
314                WarningKind::FieldPathUnresolved,
315                (*path).to_string(),
316            ));
317        }
318    }
319    out
320}
321
322/// Seed one manifest's entries: returns the root-relative entry globs plus any
323/// `entry-outside-root` diagnostics.
324fn seed_rule_entries(
325    rule: &ManifestEntryRule,
326    manifest: &Value,
327    manifest_path: &Path,
328    root: &Path,
329) -> (Vec<String>, Vec<CheckWarning>) {
330    let rel_manifest = root_relative_forward_slash(manifest_path, root);
331    let mut seeded = Vec::new();
332    let mut warnings = Vec::new();
333    for seed in &rule.entries {
334        if !when_matches(manifest, &seed.when) {
335            continue;
336        }
337        for concrete in expand_interpolations(&seed.path, manifest) {
338            match normalize_config_path(&concrete, manifest_path, root) {
339                Some(rel) => seeded.push(rel),
340                None => warnings.push(CheckWarning {
341                    kind: WarningKind::EntryOutsideRoot,
342                    glob: None,
343                    field_path: None,
344                    manifest: rel_manifest.clone(),
345                    entry: Some(concrete),
346                }),
347            }
348        }
349    }
350    (seeded, warnings)
351}
352
353/// Re-emit a rule report's warnings as `tracing::warn!` on the production path.
354fn emit_report_warnings(plugin_name: &str, report: &RuleReport) {
355    for warning in &report.warnings {
356        match warning.kind {
357            WarningKind::EntriesEmpty => tracing::warn!(
358                "Plugin '{plugin_name}': manifestEntries rule for '{}' has an empty 'entries' \
359                 list; it seeds nothing.",
360                report.manifests
361            ),
362            WarningKind::ManifestsMatchedNone => tracing::warn!(
363                "Plugin '{plugin_name}': manifestEntries 'manifests' glob '{}' matched no files. \
364                 Check the glob and whether the manifests live under an ignored directory.",
365                report.manifests
366            ),
367            WarningKind::ManifestParseFailed => tracing::warn!(
368                "Plugin '{plugin_name}': manifestEntries skipped manifest '{}' (glob '{}') because \
369                 it could not be read or parsed.",
370                warning.manifest.as_deref().unwrap_or(""),
371                report.manifests
372            ),
373            WarningKind::WhenExcludedAll => tracing::warn!(
374                "Plugin '{plugin_name}': manifestEntries 'when' gate excluded all matched \
375                 manifest(s) for glob '{}'. No entries were seeded.",
376                report.manifests
377            ),
378            WarningKind::FieldPathUnresolved => tracing::warn!(
379                "Plugin '{plugin_name}': manifestEntries field path '{}' resolved in none of the \
380                 gated manifest(s). Likely a typo in a 'when' key or a ${{...}} interpolation.",
381                warning.field_path.as_deref().unwrap_or("")
382            ),
383            WarningKind::EntryOutsideRoot => tracing::warn!(
384                "Plugin '{plugin_name}': manifestEntries entry '{}' (from manifest '{}') resolved \
385                 outside the project root and was skipped.",
386                warning.entry.as_deref().unwrap_or(""),
387                warning.manifest.as_deref().unwrap_or("")
388            ),
389            // Check-only; never produced by build_rule_report.
390            WarningKind::SeededPathsMissing => {}
391        }
392    }
393}
394
395/// Collect every field path a rule references (rule-level `when` keys, per-seed
396/// `when` keys, and `${...}` interpolations in seed paths) for typo diagnostics.
397fn referenced_field_paths(rule: &ManifestEntryRule) -> Vec<String> {
398    let mut paths: Vec<String> = rule.when.keys().cloned().collect();
399    for seed in &rule.entries {
400        paths.extend(seed.when.keys().cloned());
401        paths.extend(interpolation_field_paths(&seed.path));
402    }
403    paths.sort();
404    paths.dedup();
405    paths
406}
407
408/// Extract the dotted field paths named by `${...}` interpolations in a path.
409fn interpolation_field_paths(path: &str) -> Vec<String> {
410    let mut out = Vec::new();
411    let mut rest = path;
412    while let Some(start) = rest.find("${") {
413        let after = &rest[start + 2..];
414        if let Some(end) = after.find('}') {
415            out.push(after[..end].to_string());
416            rest = &after[end + 1..];
417        } else {
418            break;
419        }
420    }
421    out
422}
423
424/// Expand `${dotted.field}` interpolations in a path against a manifest, fanning
425/// out over string / array field values. Returns an empty vec when any
426/// interpolation resolves to nothing (a missing field seeds nothing).
427fn expand_interpolations(path: &str, manifest: &Value) -> Vec<String> {
428    let Some(start) = path.find("${") else {
429        return vec![path.to_string()];
430    };
431    let prefix = &path[..start];
432    let after = &path[start + 2..];
433    let Some(end) = after.find('}') else {
434        // Unterminated interpolation: not a valid template, seed nothing.
435        return Vec::new();
436    };
437    let field = &after[..end];
438    let suffix = &after[end + 1..];
439
440    // Recurse on the SUFFIX only (strictly shorter, so termination is
441    // guaranteed) and cartesian-combine with this field's values. A substituted
442    // value is treated as a literal segment, never re-scanned for `${...}`, so a
443    // manifest whose field value contains `${...}` cannot cause runaway recursion.
444    let mut out = Vec::new();
445    let tails = expand_interpolations(suffix, manifest);
446    for value in field_segment_values(manifest, field) {
447        for tail in &tails {
448            out.push(format!("{prefix}{value}{tail}"));
449        }
450    }
451    out
452}
453
454/// The path-segment string values a dotted field yields: a string or number
455/// yields one; an array yields one per scalar element; anything else yields none.
456fn field_segment_values(manifest: &Value, field: &str) -> Vec<String> {
457    match dotted_lookup(manifest, field) {
458        Some(Value::String(s)) if !s.is_empty() => vec![s.clone()],
459        Some(Value::Number(n)) => vec![n.to_string()],
460        Some(Value::Array(items)) => items.iter().filter_map(scalar_segment).collect(),
461        _ => Vec::new(),
462    }
463}
464
465fn scalar_segment(value: &Value) -> Option<String> {
466    match value {
467        Value::String(s) if !s.is_empty() => Some(s.clone()),
468        Value::Number(n) => Some(n.to_string()),
469        _ => None,
470    }
471}
472
473/// Whether every `(dotted-path, expected)` pair in `when` matches the manifest
474/// by strict equality. An empty map always matches.
475fn when_matches(manifest: &Value, when: &BTreeMap<String, Value>) -> bool {
476    when.iter()
477        .all(|(path, expected)| dotted_lookup(manifest, path) == Some(expected))
478}
479
480/// Look up a dotted field path (`plugin.browser`) in a JSON value.
481fn dotted_lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
482    let mut current = value;
483    for segment in path.split('.') {
484        current = current.get(segment)?;
485    }
486    Some(current)
487}
488
489/// Walk `root` (respecting `.gitignore`, skipping `node_modules`) and return the
490/// absolute paths of files whose root-relative path matches `matcher`. Bounded
491/// to the manifest glob; runs only when an active plugin declares manifestEntries.
492fn discover_manifest_paths(root: &Path, matcher: &globset::GlobMatcher) -> Vec<PathBuf> {
493    let mut out = Vec::new();
494    let walker = ignore::WalkBuilder::new(root)
495        .hidden(false)
496        .git_ignore(true)
497        .git_global(true)
498        .git_exclude(true)
499        .filter_entry(|entry| entry.file_name() != "node_modules")
500        .build();
501    for entry in walker.flatten() {
502        // Skip directories; match everything else (regular files and any
503        // symlinked manifest) against the glob, reads fail gracefully.
504        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
505            continue;
506        }
507        let path = entry.path();
508        if let Some(rel) = root_relative_forward_slash(path, root)
509            && matcher.is_match(Path::new(&rel))
510        {
511            out.push(path.to_path_buf());
512        }
513    }
514    // `ignore::WalkBuilder` yields raw filesystem order; sort so seeding and the
515    // check report (manifests_matched, per-manifest warnings) are deterministic
516    // across machines and CI runners.
517    out.sort();
518    out
519}
520
521/// Root-relative forward-slash string for a discovered (absolute) path, or
522/// `None` if it is not under `root`.
523fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
524    let rel = file.strip_prefix(root).ok()?;
525    Some(rel.to_string_lossy().replace('\\', "/"))
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use fallow_config::{EntryPointRole, ManifestFormat, ManifestSeedRule};
532
533    fn json(text: &str) -> Value {
534        serde_json::from_str(text).unwrap()
535    }
536
537    fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
538        ManifestSeedRule {
539            path: path.to_string(),
540            when: when
541                .iter()
542                .map(|(k, v)| ((*k).to_string(), v.clone()))
543                .collect(),
544        }
545    }
546
547    #[test]
548    fn dotted_lookup_traverses_nested_fields() {
549        let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
550        assert_eq!(
551            dotted_lookup(&m, "plugin.browser"),
552            Some(&Value::Bool(true))
553        );
554        assert_eq!(
555            dotted_lookup(&m, "plugin.id"),
556            Some(&Value::String("actions".into()))
557        );
558        assert_eq!(dotted_lookup(&m, "plugin.missing"), None);
559        assert_eq!(dotted_lookup(&m, "absent.field"), None);
560    }
561
562    #[test]
563    fn when_matches_is_strict_equality_and_presence_is_not_matched() {
564        let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
565        let mut when = BTreeMap::new();
566        when.insert("type".to_string(), Value::String("plugin".into()));
567        assert!(when_matches(&m, &when));
568
569        // browser is present but false: matching against `true` must FAIL
570        // (strict equality, no presence overload).
571        let mut when_browser = BTreeMap::new();
572        when_browser.insert("plugin.browser".to_string(), Value::Bool(true));
573        assert!(!when_matches(&m, &when_browser));
574
575        // empty when always matches
576        assert!(when_matches(&m, &BTreeMap::new()));
577    }
578
579    #[test]
580    fn expand_interpolations_string_array_and_missing() {
581        let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
582        // string field -> one entry
583        assert_eq!(
584            expand_interpolations("${plugin.id}/index.ts", &m),
585            vec!["actions/index.ts"]
586        );
587        // array field -> one entry per element
588        assert_eq!(
589            expand_interpolations("${plugin.extraPublicDirs}/index.{ts,tsx}", &m),
590            vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
591        );
592        // missing field -> nothing seeded
593        assert!(expand_interpolations("${plugin.absent}/index.ts", &m).is_empty());
594        // no interpolation -> passthrough
595        assert_eq!(
596            expand_interpolations("public/index.{ts,tsx}", &m),
597            vec!["public/index.{ts,tsx}"]
598        );
599    }
600
601    #[test]
602    fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
603        let dir = tempfile::tempdir().unwrap();
604        let root = dir.path();
605        let manifest_dir = root.join("x-pack/plugins/actions");
606        std::fs::create_dir_all(&manifest_dir).unwrap();
607        let manifest_path = manifest_dir.join("kibana.jsonc");
608        std::fs::write(
609            &manifest_path,
610            r#"{
611                // a real Kibana-shaped manifest
612                "type": "plugin",
613                "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
614            }"#,
615        )
616        .unwrap();
617
618        let ext = ExternalPluginDef {
619            schema: None,
620            name: "kibana".to_string(),
621            detection: None,
622            enablers: vec![],
623            entry_points: vec![],
624            entry_point_role: EntryPointRole::Runtime,
625            manifest_entries: vec![ManifestEntryRule {
626                manifests: "**/kibana.jsonc".to_string(),
627                format: ManifestFormat::Jsonc,
628                when: BTreeMap::from([("type".to_string(), Value::String("plugin".into()))]),
629                entries: vec![
630                    seed(
631                        "public/index.{ts,tsx}",
632                        &[("plugin.browser", Value::Bool(true))],
633                    ),
634                    seed(
635                        "server/index.{ts,tsx}",
636                        &[("plugin.server", Value::Bool(true))],
637                    ),
638                    seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
639                ],
640            }],
641            config_patterns: vec![],
642            always_used: vec![],
643            tooling_dependencies: vec![],
644            used_exports: vec![],
645            used_class_members: vec![],
646        };
647
648        let rules = evaluate_manifest_entries(&ext, root);
649        let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
650
651        // browser:true seeds public; server:false does NOT seed server; extraPublicDirs fans out.
652        assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
653        assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
654        assert!(
655            !paths.iter().any(|p| p.contains("server/index")),
656            "server:false must not seed the server entry, got {paths:?}"
657        );
658    }
659
660    fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
661        ExternalPluginDef {
662            schema: None,
663            name: "kibana".to_string(),
664            detection: None,
665            enablers: vec![],
666            entry_points: vec![],
667            entry_point_role: EntryPointRole::Runtime,
668            manifest_entries: rules,
669            config_patterns: vec![],
670            always_used: vec![],
671            tooling_dependencies: vec![],
672            used_exports: vec![],
673            used_class_members: vec![],
674        }
675    }
676
677    fn rule(
678        manifests: &str,
679        when: &[(&str, Value)],
680        entries: Vec<ManifestSeedRule>,
681    ) -> ManifestEntryRule {
682        ManifestEntryRule {
683            manifests: manifests.to_string(),
684            format: ManifestFormat::Jsonc,
685            when: when
686                .iter()
687                .map(|(k, v)| ((*k).to_string(), v.clone()))
688                .collect(),
689            entries,
690        }
691    }
692
693    fn write_manifest(root: &Path, rel: &str, body: &str) {
694        let p = root.join(rel);
695        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
696        std::fs::write(p, body).unwrap();
697    }
698
699    fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
700        reports
701            .iter()
702            .flat_map(|r| r.warnings.iter().map(|w| w.kind))
703            .collect()
704    }
705
706    #[test]
707    fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
708        let dir = tempfile::tempdir().unwrap();
709        let root = dir.path();
710        write_manifest(
711            root,
712            "plugins/alpha/kibana.jsonc",
713            r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
714        );
715        write_manifest(
716            root,
717            "plugins/beta/kibana.jsonc",
718            r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
719        );
720        let ext = plugin_with(vec![rule(
721            "**/kibana.jsonc",
722            &[("type", Value::String("plugin".into()))],
723            vec![
724                seed(
725                    "public/index.{ts,tsx}",
726                    &[("plugin.browser", Value::Bool(true))],
727                ),
728                seed(
729                    "server/index.{ts,tsx}",
730                    &[("plugin.server", Value::Bool(true))],
731                ),
732            ],
733        )]);
734
735        let reports = check_manifest_entries(&ext, root);
736        assert_eq!(reports.len(), 1);
737        let report = &reports[0];
738        assert!(
739            report.warnings.is_empty(),
740            "clean plugin, got {:?}",
741            report.warnings
742        );
743        // manifests_matched is sorted (agents diff across runs).
744        assert_eq!(
745            report.manifests_matched,
746            vec![
747                "plugins/alpha/kibana.jsonc".to_string(),
748                "plugins/beta/kibana.jsonc".to_string()
749            ]
750        );
751
752        let beta = report
753            .matched
754            .iter()
755            .find(|m| m.path == "plugins/beta/kibana.jsonc")
756            .expect("beta matched");
757        assert!(beta.when_passed);
758        assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
759        assert!(
760            !beta.seeded.iter().any(|s| s.contains("server/index")),
761            "beta server:false must not seed the server entry, got {:?}",
762            beta.seeded
763        );
764    }
765
766    #[test]
767    fn check_warns_manifests_matched_none() {
768        let dir = tempfile::tempdir().unwrap();
769        let ext = plugin_with(vec![rule(
770            "**/nonexistent.jsonc",
771            &[],
772            vec![seed("public/index.ts", &[])],
773        )]);
774        let reports = check_manifest_entries(&ext, dir.path());
775        assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
776        assert_eq!(
777            reports[0].warnings[0].glob.as_deref(),
778            Some("**/nonexistent.jsonc")
779        );
780    }
781
782    #[test]
783    fn check_warns_field_path_unresolved_on_typo() {
784        let dir = tempfile::tempdir().unwrap();
785        let root = dir.path();
786        write_manifest(
787            root,
788            "plugins/alpha/kibana.jsonc",
789            r#"{"type":"plugin","plugin":{"browser":true}}"#,
790        );
791        let ext = plugin_with(vec![rule(
792            "**/kibana.jsonc",
793            &[("type", Value::String("plugin".into()))],
794            // typo: plugin.extarPublicDirs does not exist
795            vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
796        )]);
797        let reports = check_manifest_entries(&ext, root);
798        let warn = reports[0]
799            .warnings
800            .iter()
801            .find(|w| w.kind == WarningKind::FieldPathUnresolved)
802            .expect("field-path-unresolved warning");
803        assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
804    }
805
806    #[test]
807    fn check_warns_when_excluded_all() {
808        let dir = tempfile::tempdir().unwrap();
809        let root = dir.path();
810        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
811        let ext = plugin_with(vec![rule(
812            "**/kibana.jsonc",
813            &[("type", Value::String("plugin".into()))],
814            vec![seed("public/index.ts", &[])],
815        )]);
816        let reports = check_manifest_entries(&ext, root);
817        assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
818    }
819
820    #[test]
821    fn check_warns_manifest_parse_failed_per_file() {
822        let dir = tempfile::tempdir().unwrap();
823        let root = dir.path();
824        write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
825        write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
826        let ext = plugin_with(vec![rule(
827            "**/kibana.jsonc",
828            &[("type", Value::String("plugin".into()))],
829            vec![seed("public/index.ts", &[])],
830        )]);
831        let reports = check_manifest_entries(&ext, root);
832        let warn = reports[0]
833            .warnings
834            .iter()
835            .find(|w| w.kind == WarningKind::ManifestParseFailed)
836            .expect("manifest-parse-failed warning");
837        // carries the offending file, not just the glob (agents read the slot).
838        assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
839    }
840
841    #[test]
842    fn check_output_is_deterministic_across_walk_order() {
843        let dir = tempfile::tempdir().unwrap();
844        let root = dir.path();
845        // Names chosen so raw readdir order is unlikely to be sorted.
846        for name in ["mmm", "aaa", "zzz", "ccc"] {
847            write_manifest(
848                root,
849                &format!("plugins/{name}/kibana.jsonc"),
850                r#"{"type":"plugin"}"#,
851            );
852        }
853        let ext = plugin_with(vec![rule(
854            "**/kibana.jsonc",
855            &[("type", Value::String("plugin".into()))],
856            // escapes root -> one entry-outside-root warning per manifest.
857            vec![seed("../../../../escape/index.ts", &[])],
858        )]);
859        let reports = check_manifest_entries(&ext, root);
860        let r = &reports[0];
861        // manifests_matched and the per-file warnings are sorted, not walk order.
862        let mut sorted = r.manifests_matched.clone();
863        sorted.sort();
864        assert_eq!(
865            r.manifests_matched, sorted,
866            "manifests_matched must be sorted"
867        );
868        let warn_manifests: Vec<&str> = r
869            .warnings
870            .iter()
871            .filter_map(|w| w.manifest.as_deref())
872            .collect();
873        let mut sorted_w = warn_manifests.clone();
874        sorted_w.sort_unstable();
875        assert_eq!(
876            warn_manifests, sorted_w,
877            "entry-outside-root warnings must be sorted"
878        );
879    }
880
881    #[test]
882    fn check_warns_entry_outside_root() {
883        let dir = tempfile::tempdir().unwrap();
884        let root = dir.path();
885        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
886        let ext = plugin_with(vec![rule(
887            "**/kibana.jsonc",
888            &[("type", Value::String("plugin".into()))],
889            // escapes above root from plugins/alpha
890            vec![seed("../../../../escape/index.ts", &[])],
891        )]);
892        let reports = check_manifest_entries(&ext, root);
893        let warn = reports[0]
894            .warnings
895            .iter()
896            .find(|w| w.kind == WarningKind::EntryOutsideRoot)
897            .expect("entry-outside-root warning");
898        assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
899        assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
900    }
901}