1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum WarningKind {
32 ManifestsMatchedNone,
34 WhenExcludedAll,
36 FieldPathUnresolved,
38 EntriesEmpty,
40 ManifestParseFailed,
42 EntryOutsideRoot,
44 SeededPathsMissing,
47}
48
49impl WarningKind {
50 #[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#[derive(Debug, Clone)]
68pub struct CheckWarning {
69 pub kind: WarningKind,
70 pub glob: Option<String>,
72 pub field_path: Option<String>,
74 pub manifest: Option<String>,
76 pub entry: Option<String>,
78}
79
80impl CheckWarning {
81 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 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 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#[derive(Debug, Clone)]
117pub struct ManifestResult {
118 pub path: String,
120 pub when_passed: bool,
122 pub seeded: Vec<String>,
125}
126
127#[derive(Debug, Clone)]
131pub struct RuleReport {
132 pub manifests: String,
134 pub manifests_matched: Vec<String>,
136 pub matched: Vec<ManifestResult>,
138 pub warnings: Vec<CheckWarning>,
141}
142
143#[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#[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
179fn 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 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 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 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
282fn 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 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
322fn 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
353fn 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 WarningKind::SeededPathsMissing => {}
391 }
392 }
393}
394
395fn 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
408fn 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
424fn 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 return Vec::new();
436 };
437 let field = &after[..end];
438 let suffix = &after[end + 1..];
439
440 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
454fn 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
473fn when_matches(manifest: &Value, when: &BTreeMap<String, Value>) -> bool {
476 when.iter()
477 .all(|(path, expected)| dotted_lookup(manifest, path) == Some(expected))
478}
479
480fn 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
489fn 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 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
542fn 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 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 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 assert_eq!(
605 expand_interpolations("${plugin.id}/index.ts", &m),
606 vec!["actions/index.ts"]
607 );
608 assert_eq!(
610 expand_interpolations("${plugin.extraPublicDirs}/index.{ts,tsx}", &m),
611 vec!["common/index.{ts,tsx}", "types/index.{ts,tsx}"]
612 );
613 assert!(expand_interpolations("${plugin.absent}/index.ts", &m).is_empty());
615 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 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 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 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 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 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 vec![seed("../../../../escape/index.ts", &[])],
926 )]);
927 let reports = check_manifest_entries(&ext, root);
928 let r = &reports[0];
929 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 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}