1use std::path::{Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7
8const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
12
13#[derive(Debug, Clone, Default)]
15pub struct CategorizedEntryPoints {
16 pub all: Vec<EntryPoint>,
17 pub runtime: Vec<EntryPoint>,
18 pub test: Vec<EntryPoint>,
19}
20
21impl CategorizedEntryPoints {
22 pub fn push_runtime(&mut self, entry: EntryPoint) {
23 self.runtime.push(entry.clone());
24 self.all.push(entry);
25 }
26
27 pub fn push_test(&mut self, entry: EntryPoint) {
28 self.test.push(entry.clone());
29 self.all.push(entry);
30 }
31
32 pub fn push_support(&mut self, entry: EntryPoint) {
33 self.all.push(entry);
34 }
35
36 pub fn extend_runtime<I>(&mut self, entries: I)
37 where
38 I: IntoIterator<Item = EntryPoint>,
39 {
40 for entry in entries {
41 self.push_runtime(entry);
42 }
43 }
44
45 pub fn extend_test<I>(&mut self, entries: I)
46 where
47 I: IntoIterator<Item = EntryPoint>,
48 {
49 for entry in entries {
50 self.push_test(entry);
51 }
52 }
53
54 pub fn extend_support<I>(&mut self, entries: I)
55 where
56 I: IntoIterator<Item = EntryPoint>,
57 {
58 for entry in entries {
59 self.push_support(entry);
60 }
61 }
62
63 pub fn extend(&mut self, other: Self) {
64 self.all.extend(other.all);
65 self.runtime.extend(other.runtime);
66 self.test.extend(other.test);
67 }
68
69 #[must_use]
70 pub fn dedup(mut self) -> Self {
71 dedup_entry_paths(&mut self.all);
72 dedup_entry_paths(&mut self.runtime);
73 dedup_entry_paths(&mut self.test);
74 self
75 }
76}
77
78fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
79 entries.sort_by(|a, b| a.path.cmp(&b.path));
80 entries.dedup_by(|a, b| a.path == b.path);
81}
82
83pub fn resolve_entry_path(
90 base: &Path,
91 entry: &str,
92 canonical_root: &Path,
93 source: EntryPointSource,
94) -> Option<EntryPoint> {
95 if entry.contains('*') {
98 return None;
99 }
100
101 let resolved = base.join(entry);
102 let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
104 if !canonical_resolved.starts_with(canonical_root) {
105 tracing::warn!(path = %entry, "Skipping entry point outside project root");
106 return None;
107 }
108
109 if let Some(source_path) = try_output_to_source_path(base, entry) {
114 if let Ok(canonical_source) = dunce::canonicalize(&source_path)
116 && canonical_source.starts_with(canonical_root)
117 {
118 return Some(EntryPoint {
119 path: source_path,
120 source,
121 });
122 }
123 }
124
125 if is_entry_in_output_dir(entry)
133 && let Some(source_path) = try_source_index_fallback(base)
134 && let Ok(canonical_source) = dunce::canonicalize(&source_path)
135 && canonical_source.starts_with(canonical_root)
136 {
137 tracing::info!(
138 entry = %entry,
139 fallback = %source_path.display(),
140 "package.json entry resolves to an ignored output directory; falling back to source index"
141 );
142 return Some(EntryPoint {
143 path: source_path,
144 source,
145 });
146 }
147
148 if resolved.exists() {
149 return Some(EntryPoint {
150 path: resolved,
151 source,
152 });
153 }
154 for ext in SOURCE_EXTENSIONS {
156 let with_ext = resolved.with_extension(ext);
157 if with_ext.exists() {
158 return Some(EntryPoint {
159 path: with_ext,
160 source,
161 });
162 }
163 }
164 None
165}
166
167fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
179 let entry_path = Path::new(entry);
180 let components: Vec<_> = entry_path.components().collect();
181
182 let output_pos = components.iter().rposition(|c| {
184 if let std::path::Component::Normal(s) = c
185 && let Some(name) = s.to_str()
186 {
187 return OUTPUT_DIRS.contains(&name);
188 }
189 false
190 })?;
191
192 let prefix: PathBuf = components[..output_pos]
194 .iter()
195 .filter(|c| !matches!(c, std::path::Component::CurDir))
196 .collect();
197
198 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
200
201 for ext in SOURCE_EXTENSIONS {
203 let source_candidate = base
204 .join(&prefix)
205 .join("src")
206 .join(suffix.with_extension(ext));
207 if source_candidate.exists() {
208 return Some(source_candidate);
209 }
210 }
211
212 None
213}
214
215const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
218
219fn is_entry_in_output_dir(entry: &str) -> bool {
224 Path::new(entry).components().any(|c| {
225 matches!(
226 c,
227 std::path::Component::Normal(s)
228 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
229 )
230 })
231}
232
233fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
240 for stem in SOURCE_INDEX_FALLBACK_STEMS {
241 for ext in SOURCE_EXTENSIONS {
242 let candidate = base.join(format!("{stem}.{ext}"));
243 if candidate.is_file() {
244 return Some(candidate);
245 }
246 }
247 }
248 None
249}
250
251const DEFAULT_INDEX_PATTERNS: &[&str] = &[
253 "src/index.{ts,tsx,js,jsx}",
254 "src/main.{ts,tsx,js,jsx}",
255 "index.{ts,tsx,js,jsx}",
256 "main.{ts,tsx,js,jsx}",
257];
258
259fn apply_default_fallback(
264 files: &[DiscoveredFile],
265 root: &Path,
266 ws_filter: Option<&Path>,
267) -> Vec<EntryPoint> {
268 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
269 .iter()
270 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
271 .collect();
272
273 let mut entries = Vec::new();
274 for file in files {
275 if let Some(ws_root) = ws_filter
277 && file.path.strip_prefix(ws_root).is_err()
278 {
279 continue;
280 }
281 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
282 let relative_str = relative.to_string_lossy();
283 if default_matchers
284 .iter()
285 .any(|m| m.is_match(relative_str.as_ref()))
286 {
287 entries.push(EntryPoint {
288 path: file.path.clone(),
289 source: EntryPointSource::DefaultIndex,
290 });
291 }
292 }
293 entries
294}
295
296pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
298 let _span = tracing::info_span!("discover_entry_points").entered();
299 let mut entries = Vec::new();
300
301 let relative_paths: Vec<String> = files
303 .iter()
304 .map(|f| {
305 f.path
306 .strip_prefix(&config.root)
307 .unwrap_or(&f.path)
308 .to_string_lossy()
309 .into_owned()
310 })
311 .collect();
312
313 {
316 let mut builder = globset::GlobSetBuilder::new();
317 for pattern in &config.entry_patterns {
318 if let Ok(glob) = globset::Glob::new(pattern) {
319 builder.add(glob);
320 }
321 }
322 if let Ok(glob_set) = builder.build()
323 && !glob_set.is_empty()
324 {
325 for (idx, rel) in relative_paths.iter().enumerate() {
326 if glob_set.is_match(rel) {
327 entries.push(EntryPoint {
328 path: files[idx].path.clone(),
329 source: EntryPointSource::ManualEntry,
330 });
331 }
332 }
333 }
334 }
335
336 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
339 let pkg_path = config.root.join("package.json");
340 let root_pkg = PackageJson::load(&pkg_path).ok();
341 if let Some(pkg) = &root_pkg {
342 for entry_path in pkg.entry_points() {
343 if let Some(ep) = resolve_entry_path(
344 &config.root,
345 &entry_path,
346 &canonical_root,
347 EntryPointSource::PackageJsonMain,
348 ) {
349 entries.push(ep);
350 }
351 }
352
353 if let Some(scripts) = &pkg.scripts {
355 for script_value in scripts.values() {
356 for file_ref in extract_script_file_refs(script_value) {
357 if let Some(ep) = resolve_entry_path(
358 &config.root,
359 &file_ref,
360 &canonical_root,
361 EntryPointSource::PackageJsonScript,
362 ) {
363 entries.push(ep);
364 }
365 }
366 }
367 }
368
369 }
371
372 let exports_dirs = root_pkg
376 .map(|pkg| pkg.exports_subdirectories())
377 .unwrap_or_default();
378 discover_nested_package_entries(
379 &config.root,
380 files,
381 &mut entries,
382 &canonical_root,
383 &exports_dirs,
384 );
385
386 if entries.is_empty() {
388 entries = apply_default_fallback(files, &config.root, None);
389 }
390
391 entries.sort_by(|a, b| a.path.cmp(&b.path));
393 entries.dedup_by(|a, b| a.path == b.path);
394
395 entries
396}
397
398fn discover_nested_package_entries(
408 root: &Path,
409 _files: &[DiscoveredFile],
410 entries: &mut Vec<EntryPoint>,
411 canonical_root: &Path,
412 exports_subdirectories: &[String],
413) {
414 let mut visited = rustc_hash::FxHashSet::default();
415
416 let search_dirs = [
418 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
419 ];
420 for dir_name in &search_dirs {
421 let search_dir = root.join(dir_name);
422 if !search_dir.is_dir() {
423 continue;
424 }
425 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
426 continue;
427 };
428 for entry in read_dir.flatten() {
429 let pkg_dir = entry.path();
430 if visited.insert(pkg_dir.clone()) {
431 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
432 }
433 }
434 }
435
436 for dir_name in exports_subdirectories {
438 let pkg_dir = root.join(dir_name);
439 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
440 collect_nested_package_entries(&pkg_dir, entries, canonical_root);
441 }
442 }
443}
444
445fn collect_nested_package_entries(
447 pkg_dir: &Path,
448 entries: &mut Vec<EntryPoint>,
449 canonical_root: &Path,
450) {
451 let pkg_path = pkg_dir.join("package.json");
452 if !pkg_path.exists() {
453 return;
454 }
455 let Ok(pkg) = PackageJson::load(&pkg_path) else {
456 return;
457 };
458 for entry_path in pkg.entry_points() {
459 if entry_path.contains('*') {
460 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
461 } else if let Some(ep) = resolve_entry_path(
462 pkg_dir,
463 &entry_path,
464 canonical_root,
465 EntryPointSource::PackageJsonExports,
466 ) {
467 entries.push(ep);
468 }
469 }
470 if let Some(scripts) = &pkg.scripts {
471 for script_value in scripts.values() {
472 for file_ref in extract_script_file_refs(script_value) {
473 if let Some(ep) = resolve_entry_path(
474 pkg_dir,
475 &file_ref,
476 canonical_root,
477 EntryPointSource::PackageJsonScript,
478 ) {
479 entries.push(ep);
480 }
481 }
482 }
483 }
484}
485
486fn expand_wildcard_entries(
492 base: &Path,
493 pattern: &str,
494 canonical_root: &Path,
495 entries: &mut Vec<EntryPoint>,
496) {
497 let full_pattern = base.join(pattern).to_string_lossy().to_string();
498 let Ok(matches) = glob::glob(&full_pattern) else {
499 return;
500 };
501 for path_result in matches {
502 let Ok(path) = path_result else {
503 continue;
504 };
505 if let Ok(canonical) = dunce::canonicalize(&path)
506 && canonical.starts_with(canonical_root)
507 {
508 entries.push(EntryPoint {
509 path,
510 source: EntryPointSource::PackageJsonExports,
511 });
512 }
513 }
514}
515
516#[must_use]
518pub fn discover_workspace_entry_points(
519 ws_root: &Path,
520 _config: &ResolvedConfig,
521 all_files: &[DiscoveredFile],
522) -> Vec<EntryPoint> {
523 let mut entries = Vec::new();
524
525 let pkg_path = ws_root.join("package.json");
526 if let Ok(pkg) = PackageJson::load(&pkg_path) {
527 let canonical_ws_root =
528 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
529 for entry_path in pkg.entry_points() {
530 if entry_path.contains('*') {
531 expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
532 } else if let Some(ep) = resolve_entry_path(
533 ws_root,
534 &entry_path,
535 &canonical_ws_root,
536 EntryPointSource::PackageJsonMain,
537 ) {
538 entries.push(ep);
539 }
540 }
541
542 if let Some(scripts) = &pkg.scripts {
544 for script_value in scripts.values() {
545 for file_ref in extract_script_file_refs(script_value) {
546 if let Some(ep) = resolve_entry_path(
547 ws_root,
548 &file_ref,
549 &canonical_ws_root,
550 EntryPointSource::PackageJsonScript,
551 ) {
552 entries.push(ep);
553 }
554 }
555 }
556 }
557
558 }
560
561 if entries.is_empty() {
563 entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
564 }
565
566 entries.sort_by(|a, b| a.path.cmp(&b.path));
567 entries.dedup_by(|a, b| a.path == b.path);
568 entries
569}
570
571#[must_use]
576pub fn discover_plugin_entry_points(
577 plugin_result: &crate::plugins::AggregatedPluginResult,
578 config: &ResolvedConfig,
579 files: &[DiscoveredFile],
580) -> Vec<EntryPoint> {
581 discover_plugin_entry_point_sets(plugin_result, config, files).all
582}
583
584#[must_use]
586pub fn discover_plugin_entry_point_sets(
587 plugin_result: &crate::plugins::AggregatedPluginResult,
588 config: &ResolvedConfig,
589 files: &[DiscoveredFile],
590) -> CategorizedEntryPoints {
591 let mut entries = CategorizedEntryPoints::default();
592
593 let relative_paths: Vec<String> = files
595 .iter()
596 .map(|f| {
597 f.path
598 .strip_prefix(&config.root)
599 .unwrap_or(&f.path)
600 .to_string_lossy()
601 .into_owned()
602 })
603 .collect();
604
605 let mut builder = globset::GlobSetBuilder::new();
608 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
609 for (rule, pname) in &plugin_result.entry_patterns {
610 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
611 builder.add(include);
612 glob_meta.push(compiled);
613 }
614 }
615 for (pattern, pname) in plugin_result
616 .discovered_always_used
617 .iter()
618 .chain(plugin_result.always_used.iter())
619 .chain(plugin_result.fixture_patterns.iter())
620 {
621 if let Ok(glob) = globset::GlobBuilder::new(pattern)
622 .literal_separator(true)
623 .build()
624 {
625 builder.add(glob);
626 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
627 &crate::plugins::PathRule::new(pattern.clone()),
628 "support entry pattern",
629 ) {
630 glob_meta.push(CompiledEntryRule {
631 path,
632 plugin_name: pname,
633 role: EntryPointRole::Support,
634 });
635 }
636 }
637 }
638 if let Ok(glob_set) = builder.build()
639 && !glob_set.is_empty()
640 {
641 for (idx, rel) in relative_paths.iter().enumerate() {
642 let matches: Vec<usize> = glob_set
643 .matches(rel)
644 .into_iter()
645 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
646 .collect();
647 if !matches.is_empty() {
648 let name = glob_meta[matches[0]].plugin_name;
649 let entry = EntryPoint {
650 path: files[idx].path.clone(),
651 source: EntryPointSource::Plugin {
652 name: name.to_string(),
653 },
654 };
655
656 let mut has_runtime = false;
657 let mut has_test = false;
658 let mut has_support = false;
659 for match_idx in matches {
660 match glob_meta[match_idx].role {
661 EntryPointRole::Runtime => has_runtime = true,
662 EntryPointRole::Test => has_test = true,
663 EntryPointRole::Support => has_support = true,
664 }
665 }
666
667 if has_runtime {
668 entries.push_runtime(entry.clone());
669 }
670 if has_test {
671 entries.push_test(entry.clone());
672 }
673 if has_support || (!has_runtime && !has_test) {
674 entries.push_support(entry);
675 }
676 }
677 }
678 }
679
680 for (setup_file, pname) in &plugin_result.setup_files {
682 let resolved = if setup_file.is_absolute() {
683 setup_file.clone()
684 } else {
685 config.root.join(setup_file)
686 };
687 if resolved.exists() {
688 entries.push_support(EntryPoint {
689 path: resolved,
690 source: EntryPointSource::Plugin {
691 name: pname.clone(),
692 },
693 });
694 } else {
695 for ext in SOURCE_EXTENSIONS {
697 let with_ext = resolved.with_extension(ext);
698 if with_ext.exists() {
699 entries.push_support(EntryPoint {
700 path: with_ext,
701 source: EntryPointSource::Plugin {
702 name: pname.clone(),
703 },
704 });
705 break;
706 }
707 }
708 }
709 }
710
711 entries.dedup()
712}
713
714#[must_use]
719pub fn discover_dynamically_loaded_entry_points(
720 config: &ResolvedConfig,
721 files: &[DiscoveredFile],
722) -> Vec<EntryPoint> {
723 if config.dynamically_loaded.is_empty() {
724 return Vec::new();
725 }
726
727 let mut builder = globset::GlobSetBuilder::new();
728 for pattern in &config.dynamically_loaded {
729 if let Ok(glob) = globset::Glob::new(pattern) {
730 builder.add(glob);
731 }
732 }
733 let Ok(glob_set) = builder.build() else {
734 return Vec::new();
735 };
736 if glob_set.is_empty() {
737 return Vec::new();
738 }
739
740 let mut entries = Vec::new();
741 for file in files {
742 let rel = file
743 .path
744 .strip_prefix(&config.root)
745 .unwrap_or(&file.path)
746 .to_string_lossy();
747 if glob_set.is_match(rel.as_ref()) {
748 entries.push(EntryPoint {
749 path: file.path.clone(),
750 source: EntryPointSource::DynamicallyLoaded,
751 });
752 }
753 }
754 entries
755}
756
757struct CompiledEntryRule<'a> {
758 path: crate::plugins::CompiledPathRule,
759 plugin_name: &'a str,
760 role: EntryPointRole,
761}
762
763impl CompiledEntryRule<'_> {
764 fn matches(&self, path: &str) -> bool {
765 self.path.matches(path)
766 }
767}
768
769fn compile_entry_rule<'a>(
770 rule: &'a crate::plugins::PathRule,
771 plugin_name: &'a str,
772 plugin_result: &'a crate::plugins::AggregatedPluginResult,
773) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
774 let include = match globset::GlobBuilder::new(&rule.pattern)
775 .literal_separator(true)
776 .build()
777 {
778 Ok(glob) => glob,
779 Err(err) => {
780 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
781 return None;
782 }
783 };
784 let role = plugin_result
785 .entry_point_roles
786 .get(plugin_name)
787 .copied()
788 .unwrap_or(EntryPointRole::Support);
789 Some((
790 include,
791 CompiledEntryRule {
792 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
793 plugin_name,
794 role,
795 },
796 ))
797}
798
799#[must_use]
801pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
802 if patterns.is_empty() {
803 return None;
804 }
805 let mut builder = globset::GlobSetBuilder::new();
806 for pattern in patterns {
807 if let Ok(glob) = globset::GlobBuilder::new(pattern)
808 .literal_separator(true)
809 .build()
810 {
811 builder.add(glob);
812 }
813 }
814 builder.build().ok()
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
821 use fallow_types::discover::FileId;
822 use proptest::prelude::*;
823
824 proptest! {
825 #[test]
827 fn glob_patterns_never_panic_on_compile(
828 prefix in "[a-zA-Z0-9_]{1,20}",
829 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
830 ) {
831 let pattern = format!("**/{prefix}*.{ext}");
832 let result = globset::Glob::new(&pattern);
834 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
835 }
836
837 #[test]
839 fn non_source_extensions_not_in_list(
840 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
841 ) {
842 prop_assert!(
843 !SOURCE_EXTENSIONS.contains(&ext),
844 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
845 );
846 }
847
848 #[test]
850 fn compile_glob_set_no_panic(
851 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
852 ) {
853 let _ = compile_glob_set(&patterns);
855 }
856 }
857
858 #[test]
860 fn compile_glob_set_empty_input() {
861 assert!(
862 compile_glob_set(&[]).is_none(),
863 "empty patterns should return None"
864 );
865 }
866
867 #[test]
868 fn compile_glob_set_valid_patterns() {
869 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
870 let set = compile_glob_set(&patterns);
871 assert!(set.is_some(), "valid patterns should compile");
872 let set = set.unwrap();
873 assert!(set.is_match("src/foo.ts"));
874 assert!(set.is_match("src/bar.js"));
875 assert!(!set.is_match("src/bar.py"));
876 }
877
878 #[test]
879 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
880 let patterns = vec!["composables/*.{ts,js}".to_string()];
881 let set = compile_glob_set(&patterns).expect("pattern should compile");
882
883 assert!(set.is_match("composables/useFoo.ts"));
884 assert!(!set.is_match("composables/nested/useFoo.ts"));
885 }
886
887 #[test]
888 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
889 let dir = tempfile::tempdir().expect("create temp dir");
890 let root = dir.path();
891 std::fs::create_dir_all(root.join("src")).unwrap();
892 std::fs::create_dir_all(root.join("tests")).unwrap();
893 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
894 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
895 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
896
897 let config = FallowConfig {
898 schema: None,
899 extends: vec![],
900 entry: vec![],
901 ignore_patterns: vec![],
902 framework: vec![],
903 workspaces: None,
904 ignore_dependencies: vec![],
905 ignore_exports: vec![],
906 used_class_members: vec![],
907 duplicates: fallow_config::DuplicatesConfig::default(),
908 health: fallow_config::HealthConfig::default(),
909 rules: RulesConfig::default(),
910 boundaries: fallow_config::BoundaryConfig::default(),
911 production: false,
912 plugins: vec![],
913 dynamically_loaded: vec![],
914 overrides: vec![],
915 regression: None,
916 codeowners: None,
917 public_packages: vec![],
918 flags: fallow_config::FlagsConfig::default(),
919 sealed: false,
920 }
921 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
922
923 let files = vec![
924 DiscoveredFile {
925 id: FileId(0),
926 path: root.join("src/runtime.ts"),
927 size_bytes: 1,
928 },
929 DiscoveredFile {
930 id: FileId(1),
931 path: root.join("src/setup.ts"),
932 size_bytes: 1,
933 },
934 DiscoveredFile {
935 id: FileId(2),
936 path: root.join("tests/app.test.ts"),
937 size_bytes: 1,
938 },
939 ];
940
941 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
942 plugin_result.entry_patterns.push((
943 crate::plugins::PathRule::new("src/runtime.ts"),
944 "runtime-plugin".to_string(),
945 ));
946 plugin_result.entry_patterns.push((
947 crate::plugins::PathRule::new("tests/app.test.ts"),
948 "test-plugin".to_string(),
949 ));
950 plugin_result
951 .always_used
952 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
953 plugin_result
954 .entry_point_roles
955 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
956 plugin_result
957 .entry_point_roles
958 .insert("test-plugin".to_string(), EntryPointRole::Test);
959 plugin_result
960 .entry_point_roles
961 .insert("support-plugin".to_string(), EntryPointRole::Support);
962
963 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
964
965 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
966 assert!(
967 entries.runtime[0].path.ends_with("src/runtime.ts"),
968 "runtime entry should stay runtime-only"
969 );
970 assert_eq!(entries.test.len(), 1, "expected one test entry");
971 assert!(
972 entries.test[0].path.ends_with("tests/app.test.ts"),
973 "test entry should stay test-only"
974 );
975 assert_eq!(
976 entries.all.len(),
977 3,
978 "support entries should stay in all entries"
979 );
980 assert!(
981 entries
982 .all
983 .iter()
984 .any(|entry| entry.path.ends_with("src/setup.ts")),
985 "support entries should remain in the overall entry-point set"
986 );
987 assert!(
988 !entries
989 .runtime
990 .iter()
991 .any(|entry| entry.path.ends_with("src/setup.ts")),
992 "support entries should not bleed into runtime reachability"
993 );
994 assert!(
995 !entries
996 .test
997 .iter()
998 .any(|entry| entry.path.ends_with("src/setup.ts")),
999 "support entries should not bleed into test reachability"
1000 );
1001 }
1002
1003 #[test]
1004 fn plugin_entry_point_rules_respect_exclusions() {
1005 let dir = tempfile::tempdir().expect("create temp dir");
1006 let root = dir.path();
1007 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1008 std::fs::write(
1009 root.join("app/pages/index.tsx"),
1010 "export default function Page() { return null; }",
1011 )
1012 .unwrap();
1013 std::fs::write(
1014 root.join("app/pages/-helper.ts"),
1015 "export const helper = 1;",
1016 )
1017 .unwrap();
1018
1019 let config = FallowConfig {
1020 schema: None,
1021 extends: vec![],
1022 entry: vec![],
1023 ignore_patterns: vec![],
1024 framework: vec![],
1025 workspaces: None,
1026 ignore_dependencies: vec![],
1027 ignore_exports: vec![],
1028 used_class_members: vec![],
1029 duplicates: fallow_config::DuplicatesConfig::default(),
1030 health: fallow_config::HealthConfig::default(),
1031 rules: RulesConfig::default(),
1032 boundaries: fallow_config::BoundaryConfig::default(),
1033 production: false,
1034 plugins: vec![],
1035 dynamically_loaded: vec![],
1036 overrides: vec![],
1037 regression: None,
1038 codeowners: None,
1039 public_packages: vec![],
1040 flags: fallow_config::FlagsConfig::default(),
1041 sealed: false,
1042 }
1043 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1044
1045 let files = vec![
1046 DiscoveredFile {
1047 id: FileId(0),
1048 path: root.join("app/pages/index.tsx"),
1049 size_bytes: 1,
1050 },
1051 DiscoveredFile {
1052 id: FileId(1),
1053 path: root.join("app/pages/-helper.ts"),
1054 size_bytes: 1,
1055 },
1056 ];
1057
1058 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1059 plugin_result.entry_patterns.push((
1060 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1061 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1062 "tanstack-router".to_string(),
1063 ));
1064 plugin_result
1065 .entry_point_roles
1066 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1067
1068 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1069 let entry_paths: Vec<_> = entries
1070 .all
1071 .iter()
1072 .map(|entry| {
1073 entry
1074 .path
1075 .strip_prefix(root)
1076 .unwrap()
1077 .to_string_lossy()
1078 .into_owned()
1079 })
1080 .collect();
1081
1082 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1083 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1084 }
1085
1086 mod resolve_entry_path_tests {
1088 use super::*;
1089
1090 #[test]
1091 fn resolves_existing_file() {
1092 let dir = tempfile::tempdir().expect("create temp dir");
1093 let src = dir.path().join("src");
1094 std::fs::create_dir_all(&src).unwrap();
1095 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1096
1097 let canonical = dunce::canonicalize(dir.path()).unwrap();
1098 let result = resolve_entry_path(
1099 dir.path(),
1100 "src/index.ts",
1101 &canonical,
1102 EntryPointSource::PackageJsonMain,
1103 );
1104 assert!(result.is_some(), "should resolve an existing file");
1105 assert!(result.unwrap().path.ends_with("src/index.ts"));
1106 }
1107
1108 #[test]
1109 fn resolves_with_extension_fallback() {
1110 let dir = tempfile::tempdir().expect("create temp dir");
1111 let canonical = dunce::canonicalize(dir.path()).unwrap();
1113 let src = canonical.join("src");
1114 std::fs::create_dir_all(&src).unwrap();
1115 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1116
1117 let result = resolve_entry_path(
1119 &canonical,
1120 "src/index",
1121 &canonical,
1122 EntryPointSource::PackageJsonMain,
1123 );
1124 assert!(
1125 result.is_some(),
1126 "should resolve via extension fallback when exact path doesn't exist"
1127 );
1128 let ep = result.unwrap();
1129 assert!(
1130 ep.path.to_string_lossy().contains("index.ts"),
1131 "should find index.ts via extension fallback"
1132 );
1133 }
1134
1135 #[test]
1136 fn returns_none_for_nonexistent_file() {
1137 let dir = tempfile::tempdir().expect("create temp dir");
1138 let canonical = dunce::canonicalize(dir.path()).unwrap();
1139 let result = resolve_entry_path(
1140 dir.path(),
1141 "does/not/exist.ts",
1142 &canonical,
1143 EntryPointSource::PackageJsonMain,
1144 );
1145 assert!(result.is_none(), "should return None for nonexistent files");
1146 }
1147
1148 #[test]
1149 fn maps_dist_output_to_src() {
1150 let dir = tempfile::tempdir().expect("create temp dir");
1151 let src = dir.path().join("src");
1152 std::fs::create_dir_all(&src).unwrap();
1153 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1154
1155 let dist = dir.path().join("dist");
1157 std::fs::create_dir_all(&dist).unwrap();
1158 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1159
1160 let canonical = dunce::canonicalize(dir.path()).unwrap();
1161 let result = resolve_entry_path(
1162 dir.path(),
1163 "./dist/utils.js",
1164 &canonical,
1165 EntryPointSource::PackageJsonExports,
1166 );
1167 assert!(result.is_some(), "should resolve dist/ path to src/");
1168 let ep = result.unwrap();
1169 assert!(
1170 ep.path
1171 .to_string_lossy()
1172 .replace('\\', "/")
1173 .contains("src/utils.ts"),
1174 "should map ./dist/utils.js to src/utils.ts"
1175 );
1176 }
1177
1178 #[test]
1179 fn maps_build_output_to_src() {
1180 let dir = tempfile::tempdir().expect("create temp dir");
1181 let canonical = dunce::canonicalize(dir.path()).unwrap();
1183 let src = canonical.join("src");
1184 std::fs::create_dir_all(&src).unwrap();
1185 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1186
1187 let result = resolve_entry_path(
1188 &canonical,
1189 "./build/index.js",
1190 &canonical,
1191 EntryPointSource::PackageJsonExports,
1192 );
1193 assert!(result.is_some(), "should map build/ output to src/");
1194 let ep = result.unwrap();
1195 assert!(
1196 ep.path
1197 .to_string_lossy()
1198 .replace('\\', "/")
1199 .contains("src/index.tsx"),
1200 "should map ./build/index.js to src/index.tsx"
1201 );
1202 }
1203
1204 #[test]
1205 fn preserves_entry_point_source() {
1206 let dir = tempfile::tempdir().expect("create temp dir");
1207 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1208
1209 let canonical = dunce::canonicalize(dir.path()).unwrap();
1210 let result = resolve_entry_path(
1211 dir.path(),
1212 "index.ts",
1213 &canonical,
1214 EntryPointSource::PackageJsonScript,
1215 );
1216 assert!(result.is_some());
1217 assert!(
1218 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1219 "should preserve the source kind"
1220 );
1221 }
1222 }
1223
1224 mod output_to_source_tests {
1226 use super::*;
1227
1228 #[test]
1229 fn maps_dist_to_src_with_ts_extension() {
1230 let dir = tempfile::tempdir().expect("create temp dir");
1231 let src = dir.path().join("src");
1232 std::fs::create_dir_all(&src).unwrap();
1233 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1234
1235 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1236 assert!(result.is_some());
1237 assert!(
1238 result
1239 .unwrap()
1240 .to_string_lossy()
1241 .replace('\\', "/")
1242 .contains("src/utils.ts")
1243 );
1244 }
1245
1246 #[test]
1247 fn returns_none_when_no_source_file_exists() {
1248 let dir = tempfile::tempdir().expect("create temp dir");
1249 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1251 assert!(result.is_none());
1252 }
1253
1254 #[test]
1255 fn ignores_non_output_directories() {
1256 let dir = tempfile::tempdir().expect("create temp dir");
1257 let src = dir.path().join("src");
1258 std::fs::create_dir_all(&src).unwrap();
1259 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1260
1261 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1263 assert!(result.is_none());
1264 }
1265
1266 #[test]
1267 fn maps_nested_output_path_preserving_prefix() {
1268 let dir = tempfile::tempdir().expect("create temp dir");
1269 let modules_src = dir.path().join("modules").join("src");
1270 std::fs::create_dir_all(&modules_src).unwrap();
1271 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1272
1273 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1274 assert!(result.is_some());
1275 assert!(
1276 result
1277 .unwrap()
1278 .to_string_lossy()
1279 .replace('\\', "/")
1280 .contains("modules/src/helper.ts")
1281 );
1282 }
1283 }
1284
1285 mod source_index_fallback_tests {
1287 use super::*;
1288
1289 #[test]
1290 fn detects_dist_entry_in_output_dir() {
1291 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1292 assert!(is_entry_in_output_dir("dist/index.js"));
1293 assert!(is_entry_in_output_dir("./build/index.js"));
1294 assert!(is_entry_in_output_dir("./out/main.js"));
1295 assert!(is_entry_in_output_dir("./esm/index.js"));
1296 assert!(is_entry_in_output_dir("./cjs/index.js"));
1297 }
1298
1299 #[test]
1300 fn rejects_non_output_entry_paths() {
1301 assert!(!is_entry_in_output_dir("./src/index.ts"));
1302 assert!(!is_entry_in_output_dir("src/main.ts"));
1303 assert!(!is_entry_in_output_dir("./index.js"));
1304 assert!(!is_entry_in_output_dir(""));
1305 }
1306
1307 #[test]
1308 fn rejects_substring_match_for_output_dir() {
1309 assert!(!is_entry_in_output_dir("./distro/index.js"));
1311 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1312 }
1313
1314 #[test]
1315 fn finds_src_index_ts() {
1316 let dir = tempfile::tempdir().expect("create temp dir");
1317 let src = dir.path().join("src");
1318 std::fs::create_dir_all(&src).unwrap();
1319 let index_path = src.join("index.ts");
1320 std::fs::write(&index_path, "export const a = 1;").unwrap();
1321
1322 let result = try_source_index_fallback(dir.path());
1323 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1324 }
1325
1326 #[test]
1327 fn finds_src_index_tsx_when_ts_missing() {
1328 let dir = tempfile::tempdir().expect("create temp dir");
1329 let src = dir.path().join("src");
1330 std::fs::create_dir_all(&src).unwrap();
1331 let index_path = src.join("index.tsx");
1332 std::fs::write(&index_path, "export default 1;").unwrap();
1333
1334 let result = try_source_index_fallback(dir.path());
1335 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1336 }
1337
1338 #[test]
1339 fn prefers_src_index_over_root_index() {
1340 let dir = tempfile::tempdir().expect("create temp dir");
1343 let src = dir.path().join("src");
1344 std::fs::create_dir_all(&src).unwrap();
1345 let src_index = src.join("index.ts");
1346 std::fs::write(&src_index, "export const a = 1;").unwrap();
1347 let root_index = dir.path().join("index.ts");
1348 std::fs::write(&root_index, "export const b = 2;").unwrap();
1349
1350 let result = try_source_index_fallback(dir.path());
1351 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1352 }
1353
1354 #[test]
1355 fn falls_back_to_src_main() {
1356 let dir = tempfile::tempdir().expect("create temp dir");
1357 let src = dir.path().join("src");
1358 std::fs::create_dir_all(&src).unwrap();
1359 let main_path = src.join("main.ts");
1360 std::fs::write(&main_path, "export const a = 1;").unwrap();
1361
1362 let result = try_source_index_fallback(dir.path());
1363 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1364 }
1365
1366 #[test]
1367 fn falls_back_to_root_index_when_no_src() {
1368 let dir = tempfile::tempdir().expect("create temp dir");
1369 let index_path = dir.path().join("index.js");
1370 std::fs::write(&index_path, "module.exports = {};").unwrap();
1371
1372 let result = try_source_index_fallback(dir.path());
1373 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1374 }
1375
1376 #[test]
1377 fn returns_none_when_nothing_matches() {
1378 let dir = tempfile::tempdir().expect("create temp dir");
1379 let result = try_source_index_fallback(dir.path());
1380 assert!(result.is_none());
1381 }
1382
1383 #[test]
1384 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1385 let dir = tempfile::tempdir().expect("create temp dir");
1386 let canonical = dunce::canonicalize(dir.path()).unwrap();
1387
1388 let dist_dir = canonical.join("dist").join("esm2022");
1393 std::fs::create_dir_all(&dist_dir).unwrap();
1394 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1395
1396 let src = canonical.join("src");
1397 std::fs::create_dir_all(&src).unwrap();
1398 let src_index = src.join("index.ts");
1399 std::fs::write(&src_index, "export const x = 1;").unwrap();
1400
1401 let result = resolve_entry_path(
1402 &canonical,
1403 "./dist/esm2022/index.js",
1404 &canonical,
1405 EntryPointSource::PackageJsonMain,
1406 );
1407 assert!(result.is_some());
1408 let entry = result.unwrap();
1409 assert_eq!(entry.path, src_index);
1410 }
1411
1412 #[test]
1413 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1414 let dir = tempfile::tempdir().expect("create temp dir");
1417 let canonical = dunce::canonicalize(dir.path()).unwrap();
1418
1419 let src_mirror = canonical.join("src").join("esm2022");
1420 std::fs::create_dir_all(&src_mirror).unwrap();
1421 let mirror_index = src_mirror.join("index.ts");
1422 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1423
1424 let src_index = canonical.join("src").join("index.ts");
1426 std::fs::write(&src_index, "export const y = 2;").unwrap();
1427
1428 let result = resolve_entry_path(
1429 &canonical,
1430 "./dist/esm2022/index.js",
1431 &canonical,
1432 EntryPointSource::PackageJsonMain,
1433 );
1434 assert_eq!(result.map(|e| e.path), Some(mirror_index));
1435 }
1436 }
1437
1438 mod default_fallback_tests {
1440 use super::*;
1441
1442 #[test]
1443 fn finds_src_index_ts_as_fallback() {
1444 let dir = tempfile::tempdir().expect("create temp dir");
1445 let src = dir.path().join("src");
1446 std::fs::create_dir_all(&src).unwrap();
1447 let index_path = src.join("index.ts");
1448 std::fs::write(&index_path, "export const a = 1;").unwrap();
1449
1450 let files = vec![DiscoveredFile {
1451 id: FileId(0),
1452 path: index_path.clone(),
1453 size_bytes: 20,
1454 }];
1455
1456 let entries = apply_default_fallback(&files, dir.path(), None);
1457 assert_eq!(entries.len(), 1);
1458 assert_eq!(entries[0].path, index_path);
1459 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1460 }
1461
1462 #[test]
1463 fn finds_root_index_js_as_fallback() {
1464 let dir = tempfile::tempdir().expect("create temp dir");
1465 let index_path = dir.path().join("index.js");
1466 std::fs::write(&index_path, "module.exports = {};").unwrap();
1467
1468 let files = vec![DiscoveredFile {
1469 id: FileId(0),
1470 path: index_path.clone(),
1471 size_bytes: 21,
1472 }];
1473
1474 let entries = apply_default_fallback(&files, dir.path(), None);
1475 assert_eq!(entries.len(), 1);
1476 assert_eq!(entries[0].path, index_path);
1477 }
1478
1479 #[test]
1480 fn returns_empty_when_no_index_file() {
1481 let dir = tempfile::tempdir().expect("create temp dir");
1482 let other_path = dir.path().join("src").join("utils.ts");
1483
1484 let files = vec![DiscoveredFile {
1485 id: FileId(0),
1486 path: other_path,
1487 size_bytes: 10,
1488 }];
1489
1490 let entries = apply_default_fallback(&files, dir.path(), None);
1491 assert!(
1492 entries.is_empty(),
1493 "non-index files should not match default fallback"
1494 );
1495 }
1496
1497 #[test]
1498 fn workspace_filter_restricts_scope() {
1499 let dir = tempfile::tempdir().expect("create temp dir");
1500 let ws_a = dir.path().join("packages").join("a").join("src");
1501 std::fs::create_dir_all(&ws_a).unwrap();
1502 let ws_b = dir.path().join("packages").join("b").join("src");
1503 std::fs::create_dir_all(&ws_b).unwrap();
1504
1505 let index_a = ws_a.join("index.ts");
1506 let index_b = ws_b.join("index.ts");
1507
1508 let files = vec![
1509 DiscoveredFile {
1510 id: FileId(0),
1511 path: index_a.clone(),
1512 size_bytes: 10,
1513 },
1514 DiscoveredFile {
1515 id: FileId(1),
1516 path: index_b,
1517 size_bytes: 10,
1518 },
1519 ];
1520
1521 let ws_root = dir.path().join("packages").join("a");
1523 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1524 assert_eq!(entries.len(), 1);
1525 assert_eq!(entries[0].path, index_a);
1526 }
1527 }
1528
1529 mod wildcard_entry_tests {
1531 use super::*;
1532
1533 #[test]
1534 fn expands_wildcard_css_entries() {
1535 let dir = tempfile::tempdir().expect("create temp dir");
1538 let themes = dir.path().join("src").join("themes");
1539 std::fs::create_dir_all(&themes).unwrap();
1540 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1541 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1542
1543 let canonical = dunce::canonicalize(dir.path()).unwrap();
1544 let mut entries = Vec::new();
1545 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1546
1547 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1548 let paths: Vec<String> = entries
1549 .iter()
1550 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1551 .collect();
1552 assert!(paths.contains(&"dark.css".to_string()));
1553 assert!(paths.contains(&"light.css".to_string()));
1554 assert!(
1555 entries
1556 .iter()
1557 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1558 );
1559 }
1560
1561 #[test]
1562 fn wildcard_does_not_match_nonexistent_files() {
1563 let dir = tempfile::tempdir().expect("create temp dir");
1564 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1566
1567 let canonical = dunce::canonicalize(dir.path()).unwrap();
1568 let mut entries = Vec::new();
1569 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1570
1571 assert!(
1572 entries.is_empty(),
1573 "should return empty when no files match the wildcard"
1574 );
1575 }
1576
1577 #[test]
1578 fn wildcard_only_matches_specified_extension() {
1579 let dir = tempfile::tempdir().expect("create temp dir");
1581 let themes = dir.path().join("src").join("themes");
1582 std::fs::create_dir_all(&themes).unwrap();
1583 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1584 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1585
1586 let canonical = dunce::canonicalize(dir.path()).unwrap();
1587 let mut entries = Vec::new();
1588 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1589
1590 assert_eq!(entries.len(), 1, "should only match CSS files");
1591 assert!(
1592 entries[0]
1593 .path
1594 .file_name()
1595 .unwrap()
1596 .to_string_lossy()
1597 .ends_with(".css")
1598 );
1599 }
1600 }
1601}