1use std::path::{Path, PathBuf};
4
5use fallow_config::{AuthoredRule, LogicalGroup, LogicalGroupStatus, ResolvedBoundaryConfig};
6use fallow_output::{ListEntryPointOutput, WorkspaceInfo as WorkspaceOutputInfo, WorkspacesOutput};
7use fallow_types::discover::{DiscoveredFile, EntryPoint};
8use fallow_types::path_util::display_relative;
9use rustc_hash::FxHashMap;
10
11use crate::{
12 AnalysisOptions, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
13 BoundariesListing, ListJsonEnvelope, ListJsonOutputInput, ProgrammaticError,
14 analysis_context::changed_files_for_run, resolve_programmatic_analysis_context,
15 serialize_list_json_output,
16};
17
18type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
19
20#[derive(Debug, Clone, Default)]
22pub struct ProjectInfoOptions {
23 pub analysis: AnalysisOptions,
25 pub entry_points: bool,
27 pub files: bool,
29 pub plugins: bool,
31 pub boundaries: bool,
33}
34
35#[derive(Debug, Clone, Default)]
37pub struct ListBoundariesOptions {
38 pub analysis: AnalysisOptions,
40}
41
42#[derive(Debug, Clone)]
44pub struct ProjectInfoProgrammaticOutput {
45 pub plugins: Option<Vec<String>>,
47 pub files: Option<Vec<String>>,
49 pub entry_points: Option<Vec<ListEntryPointOutput>>,
51 pub boundaries: Option<BoundariesListing>,
53 pub workspaces: Option<WorkspacesOutput<fallow_config::WorkspaceDiagnostic>>,
55 pub plugin_diagnostics: Vec<fallow_config::WorkspaceDiagnostic>,
58 pub envelope: ListJsonEnvelope,
60}
61
62pub fn serialize_project_info_programmatic_json(
68 output: ProjectInfoProgrammaticOutput,
69) -> ProgrammaticResult<serde_json::Value> {
70 serialize_list_json_output(
71 ListJsonOutputInput {
72 plugins: output.plugins,
73 files: output.files,
74 entry_points: output.entry_points,
75 boundaries: output.boundaries,
76 entry_weight: None,
77 workspaces: output.workspaces,
78 plugin_diagnostics: output.plugin_diagnostics,
79 },
80 output.envelope,
81 )
82 .map_err(|err| {
83 ProgrammaticError::new(format!("failed to serialize project info output: {err}"), 2)
84 .with_code("FALLOW_PROJECT_INFO_SERIALIZE_FAILED")
85 .with_context("project_info")
86 })
87}
88
89#[derive(Debug, Clone)]
91pub struct ListBoundariesProgrammaticOutput {
92 pub boundaries: BoundariesListing,
94}
95
96pub fn serialize_list_boundaries_programmatic_json(
102 output: ListBoundariesProgrammaticOutput,
103) -> ProgrammaticResult<serde_json::Value> {
104 serialize_list_json_output(
105 ListJsonOutputInput::<BoundariesListing, serde_json::Value> {
106 plugins: None,
107 files: None,
108 entry_points: None,
109 boundaries: Some(output.boundaries),
110 entry_weight: None,
111 workspaces: None,
112 plugin_diagnostics: Vec::new(),
113 },
114 ListJsonEnvelope::Boundaries,
115 )
116 .map_err(|err| {
117 ProgrammaticError::new(
118 format!("failed to serialize list boundaries output: {err}"),
119 2,
120 )
121 .with_code("FALLOW_LIST_BOUNDARIES_SERIALIZE_FAILED")
122 .with_context("list_boundaries")
123 })
124}
125
126#[derive(Debug, Clone)]
128pub struct BoundaryData {
129 pub zones: Vec<ZoneInfo>,
131 pub rules: Vec<RuleInfo>,
133 pub logical_groups: Vec<LogicalGroupInfo>,
135 pub is_empty: bool,
137}
138
139#[derive(Debug, Clone)]
141pub struct ZoneInfo {
142 pub name: String,
144 pub patterns: Vec<String>,
146 pub file_count: usize,
148}
149
150#[derive(Debug, Clone)]
152pub struct RuleInfo {
153 pub from: String,
155 pub allow: Vec<String>,
157}
158
159#[derive(Debug, Clone)]
161pub struct LogicalGroupInfo {
162 pub name: String,
164 pub children: Vec<String>,
166 pub auto_discover: Vec<String>,
168 pub authored_rule: Option<AuthoredRule>,
170 pub fallback_zone: Option<String>,
172 pub source_zone_index: usize,
174 pub status: LogicalGroupStatus,
176 pub file_count: usize,
178 pub child_file_count: usize,
180 pub fallback_file_count: usize,
182 pub merged_from: Option<Vec<usize>>,
184 pub original_zone_root: Option<String>,
186 pub child_source_indices: Vec<usize>,
188}
189
190pub fn run_list_boundaries(
200 options: &ListBoundariesOptions,
201) -> ProgrammaticResult<ListBoundariesProgrammaticOutput> {
202 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
203 resolved.install(|| {
204 resolved.ensure_not_cancelled("config load and file discovery")?;
205 let project_config = load_list_project_config(&resolved)?;
206 let session = fallow_engine::session::AnalysisSession::from_config(project_config);
207 resolved.ensure_not_cancelled("the boundary listing")?;
208 let changed_files = changed_files_for_run(&resolved)?;
209 let discovered = scoped_discovered_files(session.files(), changed_files.as_ref());
210 let data = compute_boundary_data(session.config(), Some(&discovered));
211
212 Ok(ListBoundariesProgrammaticOutput {
213 boundaries: boundary_data_to_output(&data),
214 })
215 })
216}
217
218pub fn run_project_info(
228 options: &ProjectInfoOptions,
229) -> ProgrammaticResult<ProjectInfoProgrammaticOutput> {
230 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
231 resolved.install(|| {
232 resolved.ensure_not_cancelled("config load and file discovery")?;
233 let project_config = load_list_project_config(&resolved)?;
234 let session = fallow_engine::session::AnalysisSession::from_config(project_config);
235 resolved.ensure_not_cancelled("the project listing")?;
236 let config = session.config();
237 let workspaces = session.workspaces();
238 let show_all = project_info_should_show_all(options);
239 let changed_files = changed_files_for_run(&resolved)?;
240 let discovered =
241 project_info_discovered_files(options, show_all, &session, changed_files.as_ref());
242 let discovered_ref = discovered.as_deref();
243
244 let inventory = collect_inventory(&session, options, show_all)?;
245 let (plugin_result, entry_points) = match inventory {
246 Some(inventory) => (
247 Some(inventory.plugins),
248 inventory
249 .entry_points
250 .map(|entries| scoped_entry_points(entries, changed_files.as_ref())),
251 ),
252 None => (None, None),
253 };
254 let plugin_diagnostics = plugin_result
255 .as_ref()
256 .map(|result| {
257 result
258 .plugin_diagnostics(&config.root)
259 .into_iter()
260 .map(|diagnostic| diagnostic.into_root_relative(&config.root))
261 .collect()
262 })
263 .unwrap_or_default();
264 let boundaries = options
265 .boundaries
266 .then(|| boundary_data_to_output(&compute_boundary_data(config, discovered_ref)));
267 let workspaces = if show_all {
268 Some(collect_workspace_output(
269 resolved.root(),
270 workspaces,
271 session.workspace_diagnostics(),
272 ))
273 } else {
274 None
275 };
276 let envelope = if boundaries.is_some() {
277 ListJsonEnvelope::Boundaries
278 } else {
279 ListJsonEnvelope::Plain
280 };
281
282 Ok(ProjectInfoProgrammaticOutput {
283 plugins: collect_plugins(options, show_all, plugin_result.as_ref()),
284 files: collect_files(options, show_all, discovered_ref, resolved.root()),
285 entry_points: entry_points
286 .map(|entries| entry_points_to_output(&entries, resolved.root())),
287 boundaries,
288 workspaces,
289 plugin_diagnostics,
290 envelope,
291 })
292 })
293}
294
295fn project_info_discovered_files(
296 options: &ProjectInfoOptions,
297 show_all: bool,
298 session: &fallow_engine::session::AnalysisSession,
299 changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
300) -> Option<Vec<DiscoveredFile>> {
301 let needs_discovery =
302 options.files || options.entry_points || options.boundaries || options.plugins || show_all;
303 needs_discovery.then(|| scoped_discovered_files(session.files(), changed_files))
304}
305
306fn scoped_discovered_files(
307 files: &[DiscoveredFile],
308 changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
309) -> Vec<DiscoveredFile> {
310 let Some(changed_files) = changed_files else {
311 return files.to_vec();
312 };
313 files
314 .iter()
315 .filter(|file| changed_files.contains(&file.path))
316 .cloned()
317 .collect()
318}
319
320fn load_list_project_config(
321 resolved: &crate::ProgrammaticAnalysisContext,
322) -> ProgrammaticResult<fallow_engine::project_config::ProjectConfig> {
323 fallow_engine::project_config::config_for_project_analysis(
324 resolved.root(),
325 resolved.config_path().as_deref(),
326 fallow_engine::project_config::ProjectConfigOptions {
327 output: fallow_types::output_format::OutputFormat::Json,
328 no_cache: resolved.no_cache(),
329 threads: resolved.threads(),
330 production_override: resolved.production_override(),
331 quiet: true,
332 analysis: fallow_config::ProductionAnalysis::DeadCode,
333 allow_remote_extends: resolved.allow_remote_extends(),
334 },
335 )
336 .map_err(|err| {
337 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
338 .with_code("FALLOW_CONFIG_LOAD_FAILED")
339 .with_context("analysis.configPath")
340 })
341}
342
343const fn project_info_should_show_all(options: &ProjectInfoOptions) -> bool {
344 !options.entry_points && !options.files && !options.plugins && !options.boundaries
345}
346
347fn collect_plugins(
348 options: &ProjectInfoOptions,
349 show_all: bool,
350 plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
351) -> Option<Vec<String>> {
352 if options.plugins || show_all {
353 plugin_result.map(|plugin_result| plugin_result.active_plugins().to_vec())
354 } else {
355 None
356 }
357}
358
359fn collect_files(
360 options: &ProjectInfoOptions,
361 show_all: bool,
362 discovered: Option<&[DiscoveredFile]>,
363 root: &Path,
364) -> Option<Vec<String>> {
365 if options.files || show_all {
366 discovered.map(|files| {
367 files
368 .iter()
369 .map(|file| display_relative(root, &file.path))
370 .collect()
371 })
372 } else {
373 None
374 }
375}
376
377fn collect_inventory(
381 session: &fallow_engine::session::AnalysisSession,
382 options: &ProjectInfoOptions,
383 show_all: bool,
384) -> ProgrammaticResult<Option<fallow_engine::list_inventory::ListingInventory>> {
385 if !(options.plugins || options.entry_points || show_all) {
386 return Ok(None);
387 }
388 fallow_engine::list_inventory::collect_listing_inventory(
389 session,
390 options.entry_points || show_all,
391 )
392 .map(Some)
393 .map_err(|err| {
394 ProgrammaticError::new(err.message(), 2)
395 .with_code("FALLOW_PLUGIN_REGEX_FAILED")
396 .with_context("project_info.plugins")
397 })
398}
399
400fn scoped_entry_points(
403 entry_points: Vec<EntryPoint>,
404 changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
405) -> Vec<EntryPoint> {
406 let Some(changed_files) = changed_files else {
407 return entry_points;
408 };
409 entry_points
410 .into_iter()
411 .filter(|entry| changed_files.contains(&entry.path))
412 .collect()
413}
414
415fn entry_points_to_output(entries: &[EntryPoint], root: &Path) -> Vec<ListEntryPointOutput> {
416 entries
417 .iter()
418 .map(|entry| ListEntryPointOutput {
419 path: display_relative(root, &entry.path),
420 source: entry.source.to_string(),
421 })
422 .collect()
423}
424
425fn collect_workspace_output(
426 root: &Path,
427 workspaces: &[fallow_config::WorkspaceInfo],
428 diagnostics: &[fallow_config::WorkspaceDiagnostic],
429) -> WorkspacesOutput<fallow_config::WorkspaceDiagnostic> {
430 let workspaces = workspaces
431 .iter()
432 .map(|workspace| {
433 let relative = workspace.root.strip_prefix(root).unwrap_or(&workspace.root);
434 WorkspaceOutputInfo {
435 name: workspace.name.clone(),
436 path: relative.display().to_string().replace('\\', "/"),
437 is_internal_dependency: workspace.is_internal_dependency,
438 }
439 })
440 .collect::<Vec<_>>();
441 WorkspacesOutput {
442 workspace_count: workspaces.len(),
443 workspaces,
444 workspace_diagnostics: diagnostics
448 .iter()
449 .map(|diagnostic| diagnostic.clone().into_root_relative(root))
450 .collect(),
451 }
452}
453
454#[must_use]
456pub fn compute_boundary_data(
457 config: &fallow_config::ResolvedConfig,
458 discovered: Option<&[DiscoveredFile]>,
459) -> BoundaryData {
460 let boundaries = &config.boundaries;
461
462 if boundaries.is_empty() {
463 return BoundaryData {
464 zones: vec![],
465 rules: vec![],
466 logical_groups: vec![],
467 is_empty: true,
468 };
469 }
470
471 let zones = build_boundary_zones(&config.root, boundaries, discovered);
472 let rules = build_boundary_rules(boundaries);
473 let logical_groups = build_logical_groups(boundaries, &zones);
474
475 BoundaryData {
476 zones,
477 rules,
478 logical_groups,
479 is_empty: false,
480 }
481}
482
483fn build_boundary_zones(
484 root: &Path,
485 boundaries: &ResolvedBoundaryConfig,
486 discovered: Option<&[DiscoveredFile]>,
487) -> Vec<ZoneInfo> {
488 let file_counts = count_boundary_files_by_zone(root, boundaries, discovered);
489
490 boundaries
491 .zones
492 .iter()
493 .map(|zone| ZoneInfo {
494 name: zone.name.clone(),
495 patterns: zone.matchers.iter().map(|m| m.glob().to_string()).collect(),
496 file_count: file_counts.get(zone.name.as_str()).copied().unwrap_or(0),
497 })
498 .collect()
499}
500
501fn count_boundary_files_by_zone<'a>(
502 root: &Path,
503 boundaries: &'a ResolvedBoundaryConfig,
504 discovered: Option<&[DiscoveredFile]>,
505) -> FxHashMap<&'a str, usize> {
506 let mut file_counts = FxHashMap::default();
507 let Some(files) = discovered else {
508 return file_counts;
509 };
510
511 for file in files {
512 let Ok(relative_path) = file.path.strip_prefix(root) else {
513 continue;
514 };
515 let relative_path = relative_path.to_string_lossy().replace('\\', "/");
516 if let Some(zone_name) = boundaries.classify_zone(&relative_path) {
517 *file_counts.entry(zone_name).or_default() += 1;
518 }
519 }
520
521 file_counts
522}
523
524fn build_boundary_rules(boundaries: &ResolvedBoundaryConfig) -> Vec<RuleInfo> {
525 boundaries
526 .rules
527 .iter()
528 .map(|rule| RuleInfo {
529 from: rule.from_zone.clone(),
530 allow: rule.allowed_zones.clone(),
531 })
532 .collect()
533}
534
535fn build_logical_groups(
536 boundaries: &ResolvedBoundaryConfig,
537 zones: &[ZoneInfo],
538) -> Vec<LogicalGroupInfo> {
539 let zone_count_by_name: FxHashMap<&str, usize> = zones
540 .iter()
541 .map(|zone| (zone.name.as_str(), zone.file_count))
542 .collect();
543
544 boundaries
545 .logical_groups
546 .iter()
547 .map(|group| logical_group_info(group, &zone_count_by_name))
548 .collect()
549}
550
551fn logical_group_info(
552 group: &LogicalGroup,
553 zone_count_by_name: &FxHashMap<&str, usize>,
554) -> LogicalGroupInfo {
555 let child_file_count: usize = group
556 .children
557 .iter()
558 .filter_map(|child| zone_count_by_name.get(child.as_str()).copied())
559 .sum();
560 let fallback_file_count = group
561 .fallback_zone
562 .as_deref()
563 .and_then(|fallback| zone_count_by_name.get(fallback).copied())
564 .unwrap_or(0);
565
566 LogicalGroupInfo {
567 name: group.name.clone(),
568 children: group.children.clone(),
569 auto_discover: group.auto_discover.clone(),
570 authored_rule: group.authored_rule.clone(),
571 fallback_zone: group.fallback_zone.clone(),
572 source_zone_index: group.source_zone_index,
573 status: group.status,
574 file_count: child_file_count + fallback_file_count,
575 child_file_count,
576 fallback_file_count,
577 merged_from: group.merged_from.clone(),
578 original_zone_root: group.original_zone_root.clone(),
579 child_source_indices: group.child_source_indices.clone(),
580 }
581}
582
583#[must_use]
585pub fn boundary_data_to_output(data: &BoundaryData) -> BoundariesListing {
586 if data.is_empty {
587 return BoundariesListing {
588 configured: false,
589 zone_count: 0,
590 zones: Vec::new(),
591 rule_count: 0,
592 rules: Vec::new(),
593 logical_group_count: 0,
594 logical_groups: Vec::new(),
595 };
596 }
597
598 BoundariesListing {
599 configured: true,
600 zone_count: data.zones.len(),
601 zones: data
602 .zones
603 .iter()
604 .map(|zone| BoundariesListZone {
605 name: zone.name.clone(),
606 patterns: zone.patterns.clone(),
607 file_count: zone.file_count,
608 })
609 .collect(),
610 rule_count: data.rules.len(),
611 rules: data
612 .rules
613 .iter()
614 .map(|rule| BoundariesListRule {
615 from: rule.from.clone(),
616 allow: rule.allow.clone(),
617 })
618 .collect(),
619 logical_group_count: data.logical_groups.len(),
620 logical_groups: data
621 .logical_groups
622 .iter()
623 .map(logical_group_info_to_output)
624 .collect(),
625 }
626}
627
628fn logical_group_info_to_output(group: &LogicalGroupInfo) -> BoundariesListLogicalGroup {
629 BoundariesListLogicalGroup {
630 name: group.name.clone(),
631 children: group.children.clone(),
632 auto_discover: group.auto_discover.clone(),
633 status: group.status,
634 source_zone_index: group.source_zone_index,
635 file_count: group.file_count,
636 authored_rule: group.authored_rule.clone(),
637 fallback_zone: group.fallback_zone.clone(),
638 merged_from: group.merged_from.clone(),
639 original_zone_root: group.original_zone_root.clone(),
640 child_source_indices: group.child_source_indices.clone(),
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use std::process::Command;
647
648 use fallow_config::ResolvedZone;
649 use fallow_types::discover::FileId;
650 use globset::Glob;
651 use serde_json::json;
652
653 use super::*;
654
655 #[test]
660 fn workspace_output_emits_project_relative_diagnostic_paths() {
661 let root = Path::new("/project");
662 let output = collect_workspace_output(
663 root,
664 &[],
665 &[fallow_config::WorkspaceDiagnostic::new(
666 root,
667 root.join("packages/inner"),
668 fallow_config::WorkspaceDiagnosticKind::UndeclaredWorkspace,
669 )],
670 );
671
672 let value = serde_json::to_value(&output).expect("workspaces output serializes");
673 assert_eq!(value["workspace_diagnostics"][0]["path"], "packages/inner");
674 }
675
676 fn empty_boundary_data() -> BoundaryData {
677 BoundaryData {
678 zones: vec![],
679 rules: vec![],
680 logical_groups: vec![],
681 is_empty: true,
682 }
683 }
684
685 fn boundary_data_to_json(data: &BoundaryData) -> serde_json::Value {
686 serde_json::to_value(boundary_data_to_output(data))
687 .expect("boundary list output should serialize")
688 }
689
690 fn resolved_zone(name: &str, patterns: &[&str]) -> ResolvedZone {
691 ResolvedZone {
692 name: name.to_string(),
693 patterns: patterns
694 .iter()
695 .map(|pattern| (*pattern).to_string())
696 .collect(),
697 matchers: patterns
698 .iter()
699 .map(|pattern| {
700 Glob::new(pattern)
701 .expect("valid test glob")
702 .compile_matcher()
703 })
704 .collect(),
705 root: None,
706 }
707 }
708
709 fn discovered_files(root: &Path, relative_paths: &[&str]) -> Vec<DiscoveredFile> {
710 relative_paths
711 .iter()
712 .enumerate()
713 .map(|(index, relative_path)| DiscoveredFile {
714 id: FileId(u32::try_from(index).expect("test file count fits in u32")),
715 path: root.join(relative_path),
716 size_bytes: 0,
717 })
718 .collect()
719 }
720
721 fn git(project: &Path, args: &[&str]) {
722 let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
723 .args(args)
724 .current_dir(project)
725 .status()
726 .expect("git command should run");
727 assert!(status.success(), "git {args:?} failed");
728 }
729
730 fn setup_changed_boundary_project() -> tempfile::TempDir {
731 let project = tempfile::tempdir().expect("project");
732 std::fs::write(
733 project.path().join("package.json"),
734 r#"{"name":"changed-list-api","main":"src/app/index.ts"}"#,
735 )
736 .expect("write package");
737 std::fs::write(
738 project.path().join(".fallowrc.json"),
739 r#"{
740 "boundaries": {
741 "zones": [
742 { "name": "app", "patterns": ["src/app/**"] },
743 { "name": "shared", "patterns": ["src/shared/**"] }
744 ]
745 }
746 }"#,
747 )
748 .expect("write config");
749 std::fs::create_dir_all(project.path().join("src/app")).expect("create app");
750 std::fs::create_dir_all(project.path().join("src/shared")).expect("create shared");
751 std::fs::write(
752 project.path().join("src/app/index.ts"),
753 "export const app = 1;\n",
754 )
755 .expect("write app");
756 std::fs::write(
757 project.path().join("src/shared/index.ts"),
758 "export const shared = 1;\n",
759 )
760 .expect("write shared");
761
762 git(project.path(), &["init", "-q"]);
763 git(
764 project.path(),
765 &["config", "user.email", "test@example.com"],
766 );
767 git(project.path(), &["config", "user.name", "Test User"]);
768 git(project.path(), &["config", "commit.gpgsign", "false"]);
769 git(project.path(), &["add", "."]);
770 git(project.path(), &["commit", "-qm", "initial"]);
771 std::fs::write(
772 project.path().join("src/app/index.ts"),
773 "export const app = 2;\n",
774 )
775 .expect("modify app");
776 project
777 }
778
779 #[test]
780 fn project_info_default_sections_match_plain_list_contract() {
781 let project = tempfile::tempdir().expect("project");
782 std::fs::write(
783 project.path().join("package.json"),
784 r#"{"name":"project-info-api","main":"src/index.ts"}"#,
785 )
786 .expect("write package");
787 std::fs::create_dir_all(project.path().join("src")).expect("create src");
788 std::fs::write(
789 project.path().join("src/index.ts"),
790 "export const value = 1;\n",
791 )
792 .expect("write source");
793
794 let output = serialize_project_info_programmatic_json(
795 run_project_info(&ProjectInfoOptions {
796 analysis: AnalysisOptions {
797 root: Some(project.path().to_path_buf()),
798 no_cache: true,
799 ..AnalysisOptions::default()
800 },
801 ..ProjectInfoOptions::default()
802 })
803 .expect("project info should run"),
804 )
805 .expect("project info should serialize");
806
807 assert_eq!(output["file_count"], 1);
808 assert_eq!(output["files"][0], "src/index.ts");
809 assert_eq!(output["entry_point_count"], 1);
810 assert_eq!(output["workspace_count"], 0);
811 assert!(output.get("kind").is_none());
812 }
813
814 #[test]
815 fn project_info_surfaces_malformed_root_package_json() {
816 let project = tempfile::tempdir().expect("project");
817 std::fs::write(project.path().join("package.json"), "{").expect("write package");
818
819 let err = run_project_info(&ProjectInfoOptions {
820 analysis: AnalysisOptions {
821 root: Some(project.path().to_path_buf()),
822 no_cache: true,
823 ..AnalysisOptions::default()
824 },
825 ..ProjectInfoOptions::default()
826 })
827 .expect_err("malformed root package.json must fail project info");
828
829 assert_eq!(err.exit_code, 2);
830 assert_eq!(err.code.as_deref(), Some("FALLOW_CONFIG_LOAD_FAILED"));
831 assert!(
832 err.message.contains("package.json"),
833 "error should name the malformed root package.json"
834 );
835 }
836
837 #[test]
838 fn project_info_default_sections_include_undeclared_workspace_diagnostic() {
839 let project = tempfile::tempdir().expect("project");
840 std::fs::write(
841 project.path().join("package.json"),
842 r#"{"name":"project-info-api","workspaces":["packages/*"]}"#,
843 )
844 .expect("write package");
845 std::fs::create_dir_all(project.path().join("packages/app")).expect("workspace dir");
846 std::fs::write(
847 project.path().join("packages/app/package.json"),
848 r#"{"name":"app","main":"src/index.ts"}"#,
849 )
850 .expect("write workspace package");
851 std::fs::create_dir_all(project.path().join("tools/extra")).expect("extra package dir");
852 std::fs::write(
853 project.path().join("tools/extra/package.json"),
854 r#"{"name":"extra"}"#,
855 )
856 .expect("write extra package");
857
858 let output = serialize_project_info_programmatic_json(
859 run_project_info(&ProjectInfoOptions {
860 analysis: AnalysisOptions {
861 root: Some(project.path().to_path_buf()),
862 no_cache: true,
863 ..AnalysisOptions::default()
864 },
865 ..ProjectInfoOptions::default()
866 })
867 .expect("project info should run"),
868 )
869 .expect("project info should serialize");
870
871 let diagnostics = output["workspace_diagnostics"]
872 .as_array()
873 .expect("project info should include workspace_diagnostics");
874 assert!(
875 diagnostics.iter().any(|diagnostic| {
876 diagnostic["kind"].as_str() == Some("undeclared-workspace")
877 && diagnostic["path"].as_str() == Some("tools/extra")
880 }),
881 "project info must include undeclared workspace diagnostics from the reused session, got {diagnostics:#?}"
882 );
883 }
884
885 #[test]
890 fn project_info_reports_one_entry_per_directory_for_a_glob_in_two_manifests() {
891 let project = tempfile::tempdir().expect("project");
892 let root = project.path();
893 std::fs::create_dir_all(root.join("pkgs/aaa")).expect("first package-less dir");
894 std::fs::create_dir_all(root.join("pkgs/bbb")).expect("second package-less dir");
895 std::fs::create_dir_all(root.join("src")).expect("source dir");
896 std::fs::write(
897 root.join("package.json"),
898 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
899 )
900 .expect("write root manifest");
901 std::fs::write(
902 root.join("pnpm-workspace.yaml"),
903 "packages:\n - \"pkgs/*\"\n",
904 )
905 .expect("write pnpm workspace manifest");
906 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
907 .expect("write source");
908
909 let output = serialize_project_info_programmatic_json(
910 run_project_info(&ProjectInfoOptions {
911 analysis: AnalysisOptions {
912 root: Some(root.to_path_buf()),
913 no_cache: true,
914 ..AnalysisOptions::default()
915 },
916 ..ProjectInfoOptions::default()
917 })
918 .expect("project info should run"),
919 )
920 .expect("project info should serialize");
921
922 let reported: Vec<(&str, &str)> = output["workspace_diagnostics"]
923 .as_array()
924 .expect("project info should include workspace_diagnostics")
925 .iter()
926 .filter(|diagnostic| diagnostic["kind"] == "glob-matched-no-package-json")
927 .map(|diagnostic| {
928 (
929 diagnostic["pattern"].as_str().unwrap_or_default(),
930 diagnostic["path"].as_str().unwrap_or_default(),
931 )
932 })
933 .collect();
934
935 assert_eq!(
936 reported,
937 vec![("pkgs/*", "pkgs/aaa"), ("pkgs/*", "pkgs/bbb")],
938 "project_info agrees with the CLI envelopes: {}",
939 output["workspace_diagnostics"]
940 );
941 }
942
943 #[test]
944 fn list_runtimes_scope_files_and_boundary_counts_to_changed_since() {
945 let project = setup_changed_boundary_project();
946 let analysis = AnalysisOptions {
947 root: Some(project.path().to_path_buf()),
948 changed_since: Some("HEAD".to_string()),
949 no_cache: true,
950 ..AnalysisOptions::default()
951 };
952
953 let project_info = serialize_project_info_programmatic_json(
954 run_project_info(&ProjectInfoOptions {
955 analysis: analysis.clone(),
956 files: true,
957 boundaries: true,
958 ..ProjectInfoOptions::default()
959 })
960 .expect("project info should run"),
961 )
962 .expect("project info should serialize");
963 let files = project_info["files"].as_array().expect("files array");
964 assert_eq!(files, &[json!("src/app/index.ts")]);
965 assert_eq!(project_info["boundaries"]["zones"][0]["file_count"], 1);
966 assert_eq!(project_info["boundaries"]["zones"][1]["file_count"], 0);
967
968 let boundaries = serialize_list_boundaries_programmatic_json(
969 run_list_boundaries(&ListBoundariesOptions { analysis })
970 .expect("list boundaries should run"),
971 )
972 .expect("list boundaries should serialize");
973 assert_eq!(boundaries["boundaries"]["zones"][0]["file_count"], 1);
974 assert_eq!(boundaries["boundaries"]["zones"][1]["file_count"], 0);
975 }
976
977 #[test]
978 fn boundary_zone_counts_preserve_first_match_for_overlapping_zones() {
979 let root = Path::new("project");
980 let boundaries = ResolvedBoundaryConfig {
981 zones: vec![
982 resolved_zone("source", &["src/**"]),
983 resolved_zone("shared", &["src/shared/**"]),
984 ],
985 ..ResolvedBoundaryConfig::default()
986 };
987 let files = discovered_files(root, &["src/app.ts", "src/shared/index.ts"]);
988
989 let zones = build_boundary_zones(root, &boundaries, Some(&files));
990
991 assert_eq!(zones[0].name, "source");
992 assert_eq!(zones[0].file_count, 2);
993 assert_eq!(zones[1].name, "shared");
994 assert_eq!(zones[1].file_count, 0);
995 }
996
997 #[test]
998 fn boundary_zone_counts_ignore_unmatched_and_out_of_root_files() {
999 let root = Path::new("project");
1000 let boundaries = ResolvedBoundaryConfig {
1001 zones: vec![resolved_zone("matched", &["src/matched/**"])],
1002 ..ResolvedBoundaryConfig::default()
1003 };
1004 let mut files = discovered_files(root, &["src/matched/index.ts", "src/unmatched/index.ts"]);
1005 files.push(DiscoveredFile {
1006 id: FileId(2),
1007 path: PathBuf::from("outside/index.ts"),
1008 size_bytes: 0,
1009 });
1010
1011 let zones = build_boundary_zones(root, &boundaries, Some(&files));
1012
1013 assert_eq!(zones[0].file_count, 1);
1014 }
1015
1016 #[test]
1017 fn boundary_zone_counts_preserve_duplicate_name_semantics() {
1018 let root = Path::new("project");
1019 let boundaries = ResolvedBoundaryConfig {
1020 zones: vec![
1021 resolved_zone("shared", &["src/first/**"]),
1022 resolved_zone("shared", &["src/second/**"]),
1023 ],
1024 ..ResolvedBoundaryConfig::default()
1025 };
1026 let files = discovered_files(root, &["src/first/index.ts", "src/second/index.ts"]);
1027
1028 let zones = build_boundary_zones(root, &boundaries, Some(&files));
1029
1030 assert_eq!(zones[0].patterns, ["src/first/**"]);
1031 assert_eq!(zones[1].patterns, ["src/second/**"]);
1032 assert_eq!(zones[0].file_count, 2);
1033 assert_eq!(zones[1].file_count, 2);
1034 }
1035
1036 #[test]
1037 fn boundary_zone_output_is_deterministic_and_keeps_config_order() {
1038 let root = Path::new("project");
1039 let boundaries = ResolvedBoundaryConfig {
1040 zones: vec![
1041 resolved_zone("zeta", &["src/zeta/**"]),
1042 resolved_zone("alpha", &["src/alpha/**"]),
1043 resolved_zone("middle", &["src/middle/**"]),
1044 ],
1045 ..ResolvedBoundaryConfig::default()
1046 };
1047 let files = discovered_files(
1048 root,
1049 &[
1050 "src/middle/two.ts",
1051 "src/zeta/one.ts",
1052 "src/alpha/one.ts",
1053 "src/middle/one.ts",
1054 ],
1055 );
1056
1057 let first = build_boundary_zones(root, &boundaries, Some(&files));
1058 let second = build_boundary_zones(root, &boundaries, Some(&files));
1059 let signature = |zones: &[ZoneInfo]| {
1060 zones
1061 .iter()
1062 .map(|zone| (zone.name.clone(), zone.file_count))
1063 .collect::<Vec<_>>()
1064 };
1065
1066 assert_eq!(signature(&first), signature(&second));
1067 assert_eq!(
1068 signature(&first),
1069 vec![
1070 ("zeta".to_string(), 1),
1071 ("alpha".to_string(), 1),
1072 ("middle".to_string(), 2),
1073 ]
1074 );
1075 }
1076
1077 #[test]
1078 fn boundary_json_empty_includes_logical_groups_key() {
1079 let value = boundary_data_to_json(&empty_boundary_data());
1080
1081 assert_eq!(value["configured"], false);
1082 assert_eq!(value["zone_count"], 0);
1083 assert_eq!(value["rule_count"], 0);
1084 assert_eq!(value["logical_group_count"], 0);
1085 assert_eq!(value["logical_groups"], json!([]));
1086 }
1087
1088 #[test]
1089 fn boundary_json_logical_group_carries_all_fields() {
1090 let data = BoundaryData {
1091 zones: vec![ZoneInfo {
1092 name: "features/auth".to_string(),
1093 patterns: vec!["src/features/auth/**".to_string()],
1094 file_count: 3,
1095 }],
1096 rules: vec![],
1097 logical_groups: vec![LogicalGroupInfo {
1098 name: "features".to_string(),
1099 children: vec!["features/auth".to_string()],
1100 auto_discover: vec!["./src/features/".to_string()],
1101 authored_rule: Some(AuthoredRule {
1102 allow: vec!["shared".to_string()],
1103 allow_type_only: vec!["types".to_string()],
1104 }),
1105 fallback_zone: None,
1106 source_zone_index: 1,
1107 status: LogicalGroupStatus::Ok,
1108 file_count: 3,
1109 child_file_count: 3,
1110 fallback_file_count: 0,
1111 merged_from: None,
1112 original_zone_root: None,
1113 child_source_indices: vec![],
1114 }],
1115 is_empty: false,
1116 };
1117
1118 let value = boundary_data_to_json(&data);
1119 let group = &value["logical_groups"][0];
1120
1121 assert_eq!(value["logical_group_count"], 1);
1122 assert_eq!(group["name"], "features");
1123 assert_eq!(group["children"][0], "features/auth");
1124 assert_eq!(group["auto_discover"][0], "./src/features/");
1125 assert_eq!(group["status"], "ok");
1126 assert_eq!(group["source_zone_index"], 1);
1127 assert_eq!(group["file_count"], 3);
1128 assert_eq!(group["authored_rule"]["allow"][0], "shared");
1129 assert_eq!(group["authored_rule"]["allow_type_only"][0], "types");
1130 assert!(group.get("fallback_zone").is_none());
1131 assert!(group.get("merged_from").is_none());
1132 assert!(group.get("original_zone_root").is_none());
1133 assert!(group.get("child_source_indices").is_none());
1134 }
1135
1136 #[test]
1137 fn boundary_json_logical_group_optional_fields_round_trip() {
1138 let data = BoundaryData {
1139 zones: vec![],
1140 rules: vec![],
1141 logical_groups: vec![LogicalGroupInfo {
1142 name: "features".to_string(),
1143 children: vec!["features/auth".to_string(), "features/billing".to_string()],
1144 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1145 authored_rule: None,
1146 fallback_zone: Some("features".to_string()),
1147 source_zone_index: 0,
1148 status: LogicalGroupStatus::Empty,
1149 file_count: 2,
1150 child_file_count: 0,
1151 fallback_file_count: 2,
1152 merged_from: Some(vec![0, 3]),
1153 original_zone_root: Some("packages/app/".to_string()),
1154 child_source_indices: vec![0, 1],
1155 }],
1156 is_empty: false,
1157 };
1158
1159 let group = &boundary_data_to_json(&data)["logical_groups"][0];
1160
1161 assert_eq!(group["status"], "empty");
1162 assert_eq!(group["fallback_zone"], "features");
1163 assert_eq!(group["merged_from"][1], 3);
1164 assert_eq!(group["original_zone_root"], "packages/app/");
1165 assert_eq!(group["child_source_indices"][1], 1);
1166 }
1167}