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