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