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 canonical_root = root.canonicalize().ok();
495    let walker = ignore::WalkBuilder::new(root)
496        .hidden(false)
497        .git_ignore(true)
498        .git_global(true)
499        .git_exclude(true)
500        .filter_entry(|entry| entry.file_name() != "node_modules")
501        .build();
502    for entry in walker.flatten() {
503        let Some(file_type) = entry.file_type() else {
504            continue;
505        };
506        if file_type.is_dir() {
507            continue;
508        }
509        let path = entry.path();
510        if file_type.is_symlink()
511            && !is_contained_regular_file_symlink(path, canonical_root.as_deref())
512        {
513            tracing::debug!(
514                path = %path.display(),
515                "skipping manifest symlink with a broken, non-file, or outside-root target"
516            );
517            continue;
518        }
519        if let Some(rel) = root_relative_forward_slash(path, root)
520            && matcher.is_match(Path::new(&rel))
521        {
522            out.push(path.to_path_buf());
523        }
524    }
525    // `ignore::WalkBuilder` yields raw filesystem order; sort so seeding and the
526    // check report (manifests_matched, per-manifest warnings) are deterministic
527    // across machines and CI runners.
528    out.sort();
529    out
530}
531
532fn is_contained_regular_file_symlink(path: &Path, canonical_root: Option<&Path>) -> bool {
533    let Some(root) = canonical_root else {
534        return false;
535    };
536    let Ok(target) = path.canonicalize() else {
537        return false;
538    };
539    target.starts_with(root) && target.metadata().is_ok_and(|metadata| metadata.is_file())
540}
541
542/// Root-relative forward-slash string for a discovered (absolute) path, or
543/// `None` if it is not under `root`.
544fn root_relative_forward_slash(file: &Path, root: &Path) -> Option<String> {
545    let rel = file.strip_prefix(root).ok()?;
546    Some(rel.to_string_lossy().replace('\\', "/"))
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use fallow_config::{EntryPointRole, ManifestFormat, ManifestSeedRule};
553
554    fn json(text: &str) -> Value {
555        serde_json::from_str(text).unwrap()
556    }
557
558    fn seed(path: &str, when: &[(&str, Value)]) -> ManifestSeedRule {
559        ManifestSeedRule {
560            path: path.to_string(),
561            when: when
562                .iter()
563                .map(|(k, v)| ((*k).to_string(), v.clone()))
564                .collect(),
565        }
566    }
567
568    #[test]
569    fn dotted_lookup_traverses_nested_fields() {
570        let m = json(r#"{"plugin": {"browser": true, "id": "actions"}}"#);
571        assert_eq!(
572            dotted_lookup(&m, "plugin.browser"),
573            Some(&Value::Bool(true))
574        );
575        assert_eq!(
576            dotted_lookup(&m, "plugin.id"),
577            Some(&Value::String("actions".into()))
578        );
579        assert_eq!(dotted_lookup(&m, "plugin.missing"), None);
580        assert_eq!(dotted_lookup(&m, "absent.field"), None);
581    }
582
583    #[test]
584    fn when_matches_is_strict_equality_and_presence_is_not_matched() {
585        let m = json(r#"{"type": "plugin", "plugin": {"browser": false}}"#);
586        let mut when = BTreeMap::new();
587        when.insert("type".to_string(), Value::String("plugin".into()));
588        assert!(when_matches(&m, &when));
589
590        // browser is present but false: matching against `true` must FAIL
591        // (strict equality, no presence overload).
592        let mut when_browser = BTreeMap::new();
593        when_browser.insert("plugin.browser".to_string(), Value::Bool(true));
594        assert!(!when_matches(&m, &when_browser));
595
596        // empty when always matches
597        assert!(when_matches(&m, &BTreeMap::new()));
598    }
599
600    #[test]
601    fn expand_interpolations_string_array_and_missing() {
602        let m = json(r#"{"plugin": {"extraPublicDirs": ["common", "types"], "id": "actions"}}"#);
603        // string field -> one entry
604        assert_eq!(
605            expand_interpolations("${plugin.id}/index.ts", &m),
606            vec!["actions/index.ts"]
607        );
608        // array field -> one entry per element
609        assert_eq!(
610            expand_interpolations("${plugin.extraPublicDirs}/index.{ts,tsx}", &m),
611            vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
612        );
613        // missing field -> nothing seeded
614        assert!(expand_interpolations("${plugin.absent}/index.ts", &m).is_empty());
615        // no interpolation -> passthrough
616        assert_eq!(
617            expand_interpolations("public/index.{ts,tsx}", &m),
618            vec!["public/index.{ts,tsx}"]
619        );
620    }
621
622    #[test]
623    fn evaluate_seeds_relative_to_manifest_dir_with_when_and_fanout() {
624        let dir = tempfile::tempdir().unwrap();
625        let root = dir.path();
626        let manifest_dir = root.join("x-pack/plugins/actions");
627        std::fs::create_dir_all(&manifest_dir).unwrap();
628        let manifest_path = manifest_dir.join("kibana.jsonc");
629        std::fs::write(
630            &manifest_path,
631            r#"{
632                // a real Kibana-shaped manifest
633                "type": "plugin",
634                "plugin": { "browser": true, "server": false, "extraPublicDirs": ["common"] },
635            }"#,
636        )
637        .unwrap();
638
639        let ext = ExternalPluginDef {
640            schema: None,
641            name: "kibana".to_string(),
642            detection: None,
643            enablers: vec![],
644            entry_points: vec![],
645            entry_point_role: EntryPointRole::Runtime,
646            manifest_entries: vec![ManifestEntryRule {
647                manifests: "**/kibana.jsonc".to_string(),
648                format: ManifestFormat::Jsonc,
649                when: BTreeMap::from([("type".to_string(), Value::String("plugin".into()))]),
650                entries: vec![
651                    seed(
652                        "public/index.{ts,tsx}",
653                        &[("plugin.browser", Value::Bool(true))],
654                    ),
655                    seed(
656                        "server/index.{ts,tsx}",
657                        &[("plugin.server", Value::Bool(true))],
658                    ),
659                    seed("${plugin.extraPublicDirs}/index.{ts,tsx}", &[]),
660                ],
661            }],
662            config_patterns: vec![],
663            always_used: vec![],
664            tooling_dependencies: vec![],
665            used_exports: vec![],
666            used_class_members: vec![],
667        };
668
669        let rules = evaluate_manifest_entries(&ext, root);
670        let paths: Vec<&str> = rules.iter().map(|r| r.pattern.as_str()).collect();
671
672        // browser:true seeds public; server:false does NOT seed server; extraPublicDirs fans out.
673        assert!(paths.contains(&"x-pack/plugins/actions/public/index.{ts,tsx}"));
674        assert!(paths.contains(&"x-pack/plugins/actions/common/index.{ts,tsx}"));
675        assert!(
676            !paths.iter().any(|p| p.contains("server/index")),
677            "server:false must not seed the server entry, got {paths:?}"
678        );
679    }
680
681    fn plugin_with(rules: Vec<ManifestEntryRule>) -> ExternalPluginDef {
682        ExternalPluginDef {
683            schema: None,
684            name: "kibana".to_string(),
685            detection: None,
686            enablers: vec![],
687            entry_points: vec![],
688            entry_point_role: EntryPointRole::Runtime,
689            manifest_entries: rules,
690            config_patterns: vec![],
691            always_used: vec![],
692            tooling_dependencies: vec![],
693            used_exports: vec![],
694            used_class_members: vec![],
695        }
696    }
697
698    fn rule(
699        manifests: &str,
700        when: &[(&str, Value)],
701        entries: Vec<ManifestSeedRule>,
702    ) -> ManifestEntryRule {
703        ManifestEntryRule {
704            manifests: manifests.to_string(),
705            format: ManifestFormat::Jsonc,
706            when: when
707                .iter()
708                .map(|(k, v)| ((*k).to_string(), v.clone()))
709                .collect(),
710            entries,
711        }
712    }
713
714    fn write_manifest(root: &Path, rel: &str, body: &str) {
715        let p = root.join(rel);
716        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
717        std::fs::write(p, body).unwrap();
718    }
719
720    #[cfg(unix)]
721    fn symlink_file(target: &Path, link: &Path) {
722        std::os::unix::fs::symlink(target, link).expect("create file symlink");
723    }
724
725    #[cfg(windows)]
726    fn symlink_file(target: &Path, link: &Path) {
727        std::os::windows::fs::symlink_file(target, link).expect("create file symlink");
728    }
729
730    #[test]
731    fn manifest_symlinks_must_target_regular_files_inside_root() {
732        let dir = tempfile::tempdir().expect("create project");
733        let outside = tempfile::tempdir().expect("create outside dir");
734        let root = dir.path();
735        let targets = root.join("targets");
736        let plugins = root.join("plugins");
737        std::fs::create_dir_all(&targets).unwrap();
738        std::fs::create_dir_all(&plugins).unwrap();
739        std::fs::write(targets.join("inside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
740        std::fs::write(outside.path().join("outside.jsonc"), r#"{"type":"plugin"}"#).unwrap();
741
742        symlink_file(
743            &targets.join("inside.jsonc"),
744            &plugins.join("inside-kibana.jsonc"),
745        );
746        symlink_file(
747            &outside.path().join("outside.jsonc"),
748            &plugins.join("outside-kibana.jsonc"),
749        );
750        symlink_file(
751            &targets.join("missing.jsonc"),
752            &plugins.join("broken-kibana.jsonc"),
753        );
754
755        let matcher = globset::Glob::new("**/*-kibana.jsonc")
756            .unwrap()
757            .compile_matcher();
758        let paths = discover_manifest_paths(root, &matcher);
759        let relative: Vec<String> = paths
760            .iter()
761            .filter_map(|path| root_relative_forward_slash(path, root))
762            .collect();
763
764        assert_eq!(relative, vec!["plugins/inside-kibana.jsonc"]);
765    }
766
767    fn kinds(reports: &[RuleReport]) -> Vec<WarningKind> {
768        reports
769            .iter()
770            .flat_map(|r| r.warnings.iter().map(|w| w.kind))
771            .collect()
772    }
773
774    #[test]
775    fn check_reports_matched_manifests_when_gate_and_seeded_entries() {
776        let dir = tempfile::tempdir().unwrap();
777        let root = dir.path();
778        write_manifest(
779            root,
780            "plugins/alpha/kibana.jsonc",
781            r#"{"type":"plugin","plugin":{"browser":true,"server":true}}"#,
782        );
783        write_manifest(
784            root,
785            "plugins/beta/kibana.jsonc",
786            r#"{"type":"plugin","plugin":{"browser":true,"server":false}}"#,
787        );
788        let ext = plugin_with(vec![rule(
789            "**/kibana.jsonc",
790            &[("type", Value::String("plugin".into()))],
791            vec![
792                seed(
793                    "public/index.{ts,tsx}",
794                    &[("plugin.browser", Value::Bool(true))],
795                ),
796                seed(
797                    "server/index.{ts,tsx}",
798                    &[("plugin.server", Value::Bool(true))],
799                ),
800            ],
801        )]);
802
803        let reports = check_manifest_entries(&ext, root);
804        assert_eq!(reports.len(), 1);
805        let report = &reports[0];
806        assert!(
807            report.warnings.is_empty(),
808            "clean plugin, got {:?}",
809            report.warnings
810        );
811        // manifests_matched is sorted (agents diff across runs).
812        assert_eq!(
813            report.manifests_matched,
814            vec![
815                "plugins/alpha/kibana.jsonc".to_string(),
816                "plugins/beta/kibana.jsonc".to_string()
817            ]
818        );
819
820        let beta = report
821            .matched
822            .iter()
823            .find(|m| m.path == "plugins/beta/kibana.jsonc")
824            .expect("beta matched");
825        assert!(beta.when_passed);
826        assert!(beta.seeded.iter().any(|s| s.contains("beta/public/index")));
827        assert!(
828            !beta.seeded.iter().any(|s| s.contains("server/index")),
829            "beta server:false must not seed the server entry, got {:?}",
830            beta.seeded
831        );
832    }
833
834    #[test]
835    fn check_warns_manifests_matched_none() {
836        let dir = tempfile::tempdir().unwrap();
837        let ext = plugin_with(vec![rule(
838            "**/nonexistent.jsonc",
839            &[],
840            vec![seed("public/index.ts", &[])],
841        )]);
842        let reports = check_manifest_entries(&ext, dir.path());
843        assert!(kinds(&reports).contains(&WarningKind::ManifestsMatchedNone));
844        assert_eq!(
845            reports[0].warnings[0].glob.as_deref(),
846            Some("**/nonexistent.jsonc")
847        );
848    }
849
850    #[test]
851    fn check_warns_field_path_unresolved_on_typo() {
852        let dir = tempfile::tempdir().unwrap();
853        let root = dir.path();
854        write_manifest(
855            root,
856            "plugins/alpha/kibana.jsonc",
857            r#"{"type":"plugin","plugin":{"browser":true}}"#,
858        );
859        let ext = plugin_with(vec![rule(
860            "**/kibana.jsonc",
861            &[("type", Value::String("plugin".into()))],
862            // typo: plugin.extarPublicDirs does not exist
863            vec![seed("${plugin.extarPublicDirs}/index.ts", &[])],
864        )]);
865        let reports = check_manifest_entries(&ext, root);
866        let warn = reports[0]
867            .warnings
868            .iter()
869            .find(|w| w.kind == WarningKind::FieldPathUnresolved)
870            .expect("field-path-unresolved warning");
871        assert_eq!(warn.field_path.as_deref(), Some("plugin.extarPublicDirs"));
872    }
873
874    #[test]
875    fn check_warns_when_excluded_all() {
876        let dir = tempfile::tempdir().unwrap();
877        let root = dir.path();
878        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"package"}"#);
879        let ext = plugin_with(vec![rule(
880            "**/kibana.jsonc",
881            &[("type", Value::String("plugin".into()))],
882            vec![seed("public/index.ts", &[])],
883        )]);
884        let reports = check_manifest_entries(&ext, root);
885        assert!(kinds(&reports).contains(&WarningKind::WhenExcludedAll));
886    }
887
888    #[test]
889    fn check_warns_manifest_parse_failed_per_file() {
890        let dir = tempfile::tempdir().unwrap();
891        let root = dir.path();
892        write_manifest(root, "plugins/good/kibana.jsonc", r#"{"type":"plugin"}"#);
893        write_manifest(root, "plugins/bad/kibana.jsonc", "{ this is not valid json");
894        let ext = plugin_with(vec![rule(
895            "**/kibana.jsonc",
896            &[("type", Value::String("plugin".into()))],
897            vec![seed("public/index.ts", &[])],
898        )]);
899        let reports = check_manifest_entries(&ext, root);
900        let warn = reports[0]
901            .warnings
902            .iter()
903            .find(|w| w.kind == WarningKind::ManifestParseFailed)
904            .expect("manifest-parse-failed warning");
905        // carries the offending file, not just the glob (agents read the slot).
906        assert_eq!(warn.manifest.as_deref(), Some("plugins/bad/kibana.jsonc"));
907    }
908
909    #[test]
910    fn check_output_is_deterministic_across_walk_order() {
911        let dir = tempfile::tempdir().unwrap();
912        let root = dir.path();
913        // Names chosen so raw readdir order is unlikely to be sorted.
914        for name in ["mmm", "aaa", "zzz", "ccc"] {
915            write_manifest(
916                root,
917                &format!("plugins/{name}/kibana.jsonc"),
918                r#"{"type":"plugin"}"#,
919            );
920        }
921        let ext = plugin_with(vec![rule(
922            "**/kibana.jsonc",
923            &[("type", Value::String("plugin".into()))],
924            // escapes root -> one entry-outside-root warning per manifest.
925            vec![seed("../../../../escape/index.ts", &[])],
926        )]);
927        let reports = check_manifest_entries(&ext, root);
928        let r = &reports[0];
929        // manifests_matched and the per-file warnings are sorted, not walk order.
930        let mut sorted = r.manifests_matched.clone();
931        sorted.sort();
932        assert_eq!(
933            r.manifests_matched, sorted,
934            "manifests_matched must be sorted"
935        );
936        let warn_manifests: Vec<&str> = r
937            .warnings
938            .iter()
939            .filter_map(|w| w.manifest.as_deref())
940            .collect();
941        let mut sorted_w = warn_manifests.clone();
942        sorted_w.sort_unstable();
943        assert_eq!(
944            warn_manifests, sorted_w,
945            "entry-outside-root warnings must be sorted"
946        );
947    }
948
949    #[test]
950    fn check_warns_entry_outside_root() {
951        let dir = tempfile::tempdir().unwrap();
952        let root = dir.path();
953        write_manifest(root, "plugins/alpha/kibana.jsonc", r#"{"type":"plugin"}"#);
954        let ext = plugin_with(vec![rule(
955            "**/kibana.jsonc",
956            &[("type", Value::String("plugin".into()))],
957            // escapes above root from plugins/alpha
958            vec![seed("../../../../escape/index.ts", &[])],
959        )]);
960        let reports = check_manifest_entries(&ext, root);
961        let warn = reports[0]
962            .warnings
963            .iter()
964            .find(|w| w.kind == WarningKind::EntryOutsideRoot)
965            .expect("entry-outside-root warning");
966        assert!(warn.entry.as_deref().is_some_and(|e| e.contains("escape")));
967        assert_eq!(warn.manifest.as_deref(), Some("plugins/alpha/kibana.jsonc"));
968    }
969}