From fe766053db8c0173358a2f9acd9188bc2fe28f3a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Mon, 16 Mar 2026 22:22:05 +0100 Subject: [PATCH 1/7] add analyze subcommand for doing basic DCE of schema fields --- compiler/Cargo.lock | 4 + compiler/crates/relay-bin/Cargo.toml | 4 + .../relay-bin/src/analyze_schema_dce.rs | 550 ++++++++++++++++++ compiler/crates/relay-bin/src/errors.rs | 3 + compiler/crates/relay-bin/src/main.rs | 3 + 5 files changed, 564 insertions(+) create mode 100644 compiler/crates/relay-bin/src/analyze_schema_dce.rs diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock index aa1b63adfa03b..cfee897e1e9be 100644 --- a/compiler/Cargo.lock +++ b/compiler/Cargo.lock @@ -1846,13 +1846,17 @@ version = "20.1.1" dependencies = [ "clap", "common", + "graphql-ir", "intern", "log", "relay-codemod", "relay-compiler", "relay-lsp", + "relay-transforms", "schema", "schema-documentation", + "serde", + "serde_json", "simplelog", "thiserror 2.0.17", "tokio", diff --git a/compiler/crates/relay-bin/Cargo.toml b/compiler/crates/relay-bin/Cargo.toml index 10beebfede024..0ef092bd6d0fa 100644 --- a/compiler/crates/relay-bin/Cargo.toml +++ b/compiler/crates/relay-bin/Cargo.toml @@ -13,11 +13,15 @@ clap = { version = "4.5.42", features = ["derive", "env", "string", "unicode", " common = { path = "../common" } intern = { path = "../intern" } log = { version = "0.4.27", features = ["kv_unstable", "kv_unstable_std"] } +graphql-ir = { path = "../graphql-ir" } relay-codemod = { path = "../relay-codemod" } relay-compiler = { path = "../relay-compiler" } relay-lsp = { path = "../relay-lsp" } +relay-transforms = { path = "../relay-transforms" } schema = { path = "../schema" } schema-documentation = { path = "../schema-documentation" } simplelog = "0.12.2" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" thiserror = "2.0.12" tokio = { version = "1.46.1", features = ["full", "test-util", "tracing"] } diff --git a/compiler/crates/relay-bin/src/analyze_schema_dce.rs b/compiler/crates/relay-bin/src/analyze_schema_dce.rs new file mode 100644 index 0000000000000..a1213d134f1a6 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze_schema_dce.rs @@ -0,0 +1,550 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; + +use clap::Parser; +use common::ConsoleLogger; +use graphql_ir::FragmentDefinition; +use graphql_ir::FragmentDefinitionName; +use graphql_ir::Selection; +use intern::Lookup; +use relay_compiler::get_programs; +use relay_compiler::ProjectName; +use schema::Field; +use schema::FieldID; +use schema::SDLSchema; +use schema::Schema; +use schema::Type; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +#[derive(Parser)] +#[clap(rename_all = "snake_case", about = "Schema analysis helpers.")] +pub struct AnalyzeCommand { + /// Schema analysis commands. + #[clap(subcommand)] + command: AnalyzeSubcommand, +} + +#[derive(clap::Subcommand)] +enum AnalyzeSubcommand { + /// Find unused schema fields in Relay operations. + #[clap(name = "schema-dce")] + SchemaDce(AnalyzeSchemaDCECommand), +} + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find unused schema fields in Relay operations." +)] +pub struct AnalyzeSchemaDCECommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Analyze using this config file. If not provided, searches for a config in + /// package.json under the `relay` key or `relay.config.json` files among other up + /// from the current working directory. + config: Option, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeSchemaDceTypeReport { + type_name: String, + type_description: String, + type_referenced: bool, + dead_fields: Vec, + dead_union_members: Vec, + #[serde(skip)] + existing_field_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeSchemaDceReport { + project: String, + dead_fields: Vec, + dead_field_count: usize, + dead_union_member_count: usize, +} + +pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error> { + match command.command { + AnalyzeSubcommand::SchemaDce(command) => { + handle_analyze_schema_dce_command(command).await + } + } +} + +fn ensure_single_project_config(config: &relay_compiler::config::Config) -> Result { + if config.projects.len() != 1 { + return Err(Error::AnalyzeError { + details: "The analyze command currently only supports single-project configurations." + .into(), + }); + } + + let project_name = config + .projects + .keys() + .next() + .cloned() + .ok_or_else(|| Error::AnalyzeError { + details: "No project found in config.".into(), + })?; + Ok(project_name) +} + +async fn handle_analyze_schema_dce_command( + command: AnalyzeSchemaDCECommand, +) -> Result<(), Error> { + let mut config = get_config(command.config)?; + let project_name = ensure_single_project_config(&config)?; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, _config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_dead_fields(project_name, program.as_ref(), json)?; + Ok(()) +} + +fn collect_referenced_field_ids_and_types( + program: &relay_transforms::Programs, +) -> ( + HashSet, + HashSet, + HashMap>, +) { + let mut referenced_fields = HashSet::default(); + let mut referenced_types: HashSet = HashSet::default(); + let mut referenced_union_members: HashMap> = HashMap::default(); + let mut fragments_by_name: HashMap = + HashMap::default(); + + let schema = &program.source.schema; + + for fragment in program.source.fragments() { + let name = fragment.name.item; + fragments_by_name.insert(name, fragment); + + referenced_types.insert(schema.get_type_name(fragment.type_condition).to_string()); + } + + for operation in program.source.operations() { + referenced_types.insert(schema.get_type_name(operation.type_).to_string()); + } + + let mut visited_fragments: HashSet<(FragmentDefinitionName, Type)> = HashSet::default(); + for operation in program.source.operations() { + let mut selection_types = vec![operation.type_]; + collect_referenced_field_ids_from_selections( + &operation.selections, + schema, + &fragments_by_name, + &mut referenced_types, + &mut referenced_fields, + &mut selection_types, + &mut referenced_union_members, + &mut visited_fragments, + ); + } + + ( + referenced_fields, + referenced_types, + referenced_union_members, + ) +} + +fn collect_referenced_field_ids_from_selections( + selections: &[Selection], + schema: &SDLSchema, + fragments_by_name: &HashMap, + referenced_types: &mut HashSet, + referenced_fields: &mut HashSet, + selection_types: &mut Vec, + referenced_union_members: &mut HashMap>, + visited_fragments: &mut HashSet<(FragmentDefinitionName, Type)>, +) { + for selection in selections { + match selection { + Selection::ScalarField(scalar_field) => { + referenced_fields.insert(scalar_field.definition.item); + } + Selection::LinkedField(linked_field) => { + let field = schema.field(linked_field.definition.item); + let field_type = field.type_.inner(); + if let Type::Union(union_id) = field_type { + referenced_types + .insert(schema.get_type_name(Type::Union(union_id)).to_string()); + } + referenced_fields.insert(linked_field.definition.item); + selection_types.push(field_type); + collect_referenced_field_ids_from_selections( + &linked_field.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } + Selection::FragmentSpread(fragment_spread) => { + let fragment_name = fragment_spread.fragment.item; + if let Some(fragment) = fragments_by_name.get(&fragment_name) { + let parent_type = selection_types.last().copied(); + let type_condition = fragment.type_condition; + referenced_types.insert(schema.get_type_name(type_condition).to_string()); + if let Some(parent_type) = parent_type { + mark_referenced_union_member( + schema, + parent_type, + type_condition, + referenced_union_members, + ); + } + let key = (fragment_name, parent_type.unwrap_or_else(|| type_condition)); + if visited_fragments.insert(key) { + selection_types.push(type_condition); + collect_referenced_field_ids_from_selections( + &fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } + } + } + Selection::InlineFragment(inline_fragment) => { + if let Some(type_condition) = inline_fragment.type_condition { + referenced_types.insert(schema.get_type_name(type_condition).to_string()); + if let Some(parent_type) = selection_types.last().copied() { + mark_referenced_union_member( + schema, + parent_type, + type_condition, + referenced_union_members, + ); + } + selection_types.push(type_condition); + collect_referenced_field_ids_from_selections( + &inline_fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } else { + collect_referenced_field_ids_from_selections( + &inline_fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + } + } + Selection::Condition(condition) => { + collect_referenced_field_ids_from_selections( + &condition.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + } + } + } +} + +fn mark_referenced_union_member( + schema: &SDLSchema, + parent_type: Type, + member_type: Type, + referenced_union_members: &mut HashMap>, +) { + match (parent_type, member_type) { + (Type::Union(union_id), Type::Object(object_id)) => { + if schema.union(union_id).members.contains(&object_id) { + referenced_union_members + .entry(schema.get_type_name(Type::Union(union_id)).to_string()) + .or_default() + .insert(schema.get_type_name(Type::Object(object_id)).to_string()); + } + } + _ => {} + } +} + +fn should_ignore_internal_field(field_name: &str) -> bool { + field_name == "id" || field_name.starts_with("__") +} + +fn analyze_project_dead_fields( + project_name: ProjectName, + programs: &relay_transforms::Programs, + json: bool, +) -> Result<(), Error> { + let (referenced_fields, mut referenced_types, referenced_union_members) = + collect_referenced_field_ids_and_types(programs); + let schema = &programs.source.schema; + + for field_id in referenced_fields.iter() { + let field = schema.field(*field_id); + if let Some(parent_type) = field.parent_type { + referenced_types.insert(schema.get_type_name(parent_type).to_string()); + } + } + + let mut dead_fields_by_type: BTreeMap = BTreeMap::new(); + + for object in schema.objects() { + let type_name = object.name.item.lookup().to_string(); + let type_description = if object.is_extension { + "schema extension object".to_owned() + } else { + "object".to_owned() + }; + let mut dead_fields: Vec = Vec::new(); + let mut existing_field_count = 0usize; + let dead_union_members: Vec = Vec::new(); + let type_referenced = referenced_types.contains(&type_name); + + for field_id in &object.fields { + let field: &Field = schema.field(*field_id); + let field_name = field.name.item.lookup().to_string(); + if should_ignore_internal_field(&field_name) { + continue; + } + if let Some(parent_type) = field.parent_type { + existing_field_count += 1; + debug_assert_eq!( + schema.get_type_name(parent_type).lookup().to_string(), + type_name + ); + if referenced_fields.contains(field_id) { + continue; + } + dead_fields.push(field_name); + } + } + + if !dead_fields.is_empty() || !type_referenced { + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields, + dead_union_members, + existing_field_count, + }, + ); + } + } + + for interface in schema.interfaces() { + let type_name = interface.name.item.lookup().to_string(); + let type_description = if interface.is_extension { + "schema extension interface".to_owned() + } else { + "interface".to_owned() + }; + let mut dead_fields: Vec = Vec::new(); + let mut existing_field_count = 0usize; + let dead_union_members: Vec = Vec::new(); + let type_referenced = referenced_types.contains(&type_name); + + for field_id in &interface.fields { + let field: &Field = schema.field(*field_id); + let field_name = field.name.item.lookup().to_string(); + if should_ignore_internal_field(&field_name) { + continue; + } + if let Some(parent_type) = field.parent_type { + existing_field_count += 1; + if referenced_fields.contains(field_id) { + continue; + } + debug_assert_eq!( + schema.get_type_name(parent_type).lookup().to_string(), + type_name + ); + dead_fields.push(field_name); + } + } + + if !dead_fields.is_empty() || !type_referenced { + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields, + dead_union_members, + existing_field_count, + }, + ); + } + } + + for union in schema.unions() { + let type_name = union.name.item.lookup().to_string(); + let type_description = if union.is_extension { + "schema extension union".to_owned() + } else { + "union".to_owned() + }; + let selected_members = referenced_union_members + .get(&type_name) + .cloned() + .unwrap_or_default(); + let mut dead_union_members: Vec = union + .members + .iter() + .map(|member_id| schema.get_type_name(Type::Object(*member_id)).to_string()) + .filter(|member_name| !selected_members.contains(member_name)) + .collect(); + let type_referenced = referenced_types.contains(&type_name); + + if !dead_union_members.is_empty() || !type_referenced { + dead_union_members.sort_unstable(); + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields: Vec::new(), + dead_union_members, + existing_field_count: 0, + }, + ); + } + } + + let mut report = AnalyzeSchemaDceReport { + project: project_name.to_string(), + dead_fields: dead_fields_by_type + .into_values() + .map(|mut entry| { + entry.dead_fields.sort_unstable(); + entry.dead_union_members.sort_unstable(); + entry + }) + .collect(), + dead_field_count: 0, + dead_union_member_count: 0, + }; + + report.dead_field_count = report + .dead_fields + .iter() + .map(|entry| entry.dead_fields.len()) + .sum(); + report.dead_union_member_count = report + .dead_fields + .iter() + .map(|entry| entry.dead_union_members.len()) + .sum(); + + if json { + let json_output = + serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { + details: format!("Unable to serialize analyze output: {err}"), + })?; + println!("{}", json_output); + } else { + print_analyze_schema_dce_text_report(report) + } + + Ok(()) +} + +fn print_analyze_schema_dce_text_report(report: AnalyzeSchemaDceReport) { + if report.dead_fields.is_empty() { + println!( + "Project {}: no dead schema fields or union members found", + report.project + ); + return; + } + + println!( + "Project {}: dead schema items by type ({} dead field(s), {} unselected union member(s), {} dead type(s))", + report.project, + report.dead_field_count, + report.dead_union_member_count, + report.dead_fields.len() + ); + for entry in report.dead_fields { + if entry.type_referenced { + println!(" {} ({})", entry.type_name, entry.type_description); + } else { + println!( + " {} ({}): not referenced by any operation", + entry.type_name, entry.type_description + ); + } + + if entry.type_referenced && !entry.dead_fields.is_empty() { + println!( + " Dead fields ({}/{}):", + entry.dead_fields.len(), + entry.existing_field_count + ); + for field in &entry.dead_fields { + println!(" {field}"); + } + } + + if entry.type_referenced && !entry.dead_union_members.is_empty() { + println!(" Unselected union members:"); + for member in &entry.dead_union_members { + println!(" {member}"); + } + } + } +} diff --git a/compiler/crates/relay-bin/src/errors.rs b/compiler/crates/relay-bin/src/errors.rs index 7aa20884f1710..65a4abbdacb7b 100644 --- a/compiler/crates/relay-bin/src/errors.rs +++ b/compiler/crates/relay-bin/src/errors.rs @@ -23,4 +23,7 @@ pub enum Error { #[error("Unable to run relay codemod. Error details: \n{details}")] CodemodError { details: String }, + + #[error("Unable to run relay analysis. Error details: \n{details}")] + AnalyzeError { details: String }, } diff --git a/compiler/crates/relay-bin/src/main.rs b/compiler/crates/relay-bin/src/main.rs index 4e5ff54baff9c..d047d1576161b 100644 --- a/compiler/crates/relay-bin/src/main.rs +++ b/compiler/crates/relay-bin/src/main.rs @@ -44,6 +44,7 @@ use simplelog::LevelFilter; use simplelog::TermLogger; use simplelog::TerminalMode; +mod analyze_schema_dce; mod errors; use errors::Error; @@ -153,6 +154,7 @@ enum Commands { Lsp(LspCommand), ConfigJsonSchema(ConfigJsonSchemaCommand), Codemod(CodemodCommand), + Analyze(analyze_schema_dce::AnalyzeCommand), } #[derive(ValueEnum, Clone, Copy)] @@ -214,6 +216,7 @@ async fn main() { Ok(()) } Commands::Codemod(command) => handle_codemod_command(command).await, + Commands::Analyze(command) => analyze_schema_dce::handle_analyze_command(command).await, }; if let Err(err) = result { From 49d3773c6981a91f8d501d9daf1356104f924d59 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Mon, 16 Mar 2026 22:42:02 +0100 Subject: [PATCH 2/7] add executable-definitions subcommand to analyze --- .../src/{analyze_schema_dce.rs => analyze.rs} | 352 +++++++++++++++++- compiler/crates/relay-bin/src/main.rs | 6 +- 2 files changed, 354 insertions(+), 4 deletions(-) rename compiler/crates/relay-bin/src/{analyze_schema_dce.rs => analyze.rs} (62%) diff --git a/compiler/crates/relay-bin/src/analyze_schema_dce.rs b/compiler/crates/relay-bin/src/analyze.rs similarity index 62% rename from compiler/crates/relay-bin/src/analyze_schema_dce.rs rename to compiler/crates/relay-bin/src/analyze.rs index a1213d134f1a6..e7ad3f17fc6b0 100644 --- a/compiler/crates/relay-bin/src/analyze_schema_dce.rs +++ b/compiler/crates/relay-bin/src/analyze.rs @@ -1,17 +1,22 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; use std::path::PathBuf; +use std::cmp::{max, min}; use std::sync::Arc; use clap::Parser; -use common::ConsoleLogger; +use common::{ConsoleLogger, Location}; use graphql_ir::FragmentDefinition; use graphql_ir::FragmentDefinitionName; use graphql_ir::Selection; use intern::Lookup; use relay_compiler::get_programs; +use relay_compiler::source_for_location; use relay_compiler::ProjectName; +use relay_compiler::FsSourceReader; +use common::Span; use schema::Field; use schema::FieldID; use schema::SDLSchema; @@ -35,6 +40,10 @@ enum AnalyzeSubcommand { /// Find unused schema fields in Relay operations. #[clap(name = "schema-dce")] SchemaDce(AnalyzeSchemaDCECommand), + + /// Find operations/fragments by selection size/depth. + #[clap(name = "executable-definitions")] + ExecutableDefinitions(AnalyzeExecutableDefinitionsCommand), } #[derive(Parser)] @@ -58,6 +67,35 @@ pub struct AnalyzeSchemaDCECommand { json: bool, } +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find operations and fragments by selection size/depth." +)] +pub struct AnalyzeExecutableDefinitionsCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Analyze using this config file. If not provided, searches for a config in + /// package.json under the `relay` key or `relay.config.json` files among other up + /// from the current working directory. + config: Option, + + /// Minimum number of line breaks covered by selections. + #[clap(long = "min-selection-lines")] + min_selection_lines: Option, + + /// Minimum depth of selection nesting. + #[clap(long = "min-selection-depth")] + min_selection_depth: Option, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AnalyzeSchemaDceTypeReport { @@ -79,11 +117,323 @@ struct AnalyzeSchemaDceReport { dead_union_member_count: usize, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionsReport { + project: String, + min_selection_lines: Option, + min_selection_depth: Option, + total_operations: usize, + total_fragments: usize, + matches: Vec, + match_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionMatch { + kind: String, + name: String, + location: AnalyzeExecutableDefinitionLocation, + selection_lines: usize, + selection_depth: usize, + violations: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error> { match command.command { AnalyzeSubcommand::SchemaDce(command) => { handle_analyze_schema_dce_command(command).await } + AnalyzeSubcommand::ExecutableDefinitions(command) => { + handle_analyze_executable_definitions_command(command).await + } + } +} + +async fn handle_analyze_executable_definitions_command( + command: AnalyzeExecutableDefinitionsCommand, +) -> Result<(), Error> { + let mut config = get_config(command.config)?; + let project_name = ensure_single_project_config(&config)?; + set_project_flag(&mut config, command.projects)?; + + if command.min_selection_lines.is_none() && command.min_selection_depth.is_none() { + return Err(Error::AnalyzeError { + details: "At least one executable-definitions criterion must be provided." + .into(), + }); + } + + if command.min_selection_lines == Some(0) { + return Err(Error::AnalyzeError { + details: "min-selection-lines must be greater than zero.".into(), + }); + } + + if command.min_selection_depth == Some(0) { + return Err(Error::AnalyzeError { + details: "min-selection-depth must be greater than zero.".into(), + }); + } + + let json = command.json; + let min_selection_lines = command.min_selection_lines; + let min_selection_depth = command.min_selection_depth; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_executable_definitions( + project_name, + program.as_ref(), + &config.root_dir, + min_selection_lines, + min_selection_depth, + json, + )?; + Ok(()) +} + +fn analyze_project_executable_definitions( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + min_selection_lines: Option, + min_selection_depth: Option, + json: bool, +) -> Result<(), Error> { + let mut matches = Vec::new(); + + for operation in programs.source.operations() { + if let Some(match_entry) = analyze_executable_definition( + "operation", + operation.name.item.lookup().to_string(), + operation.name.location, + &operation.selections, + min_selection_lines, + min_selection_depth, + root_dir, + )? { + matches.push(match_entry); + } + } + + for fragment in programs.source.fragments() { + if let Some(match_entry) = analyze_executable_definition( + "fragment", + fragment.name.item.lookup().to_string(), + fragment.name.location, + &fragment.selections, + min_selection_lines, + min_selection_depth, + root_dir, + )? { + matches.push(match_entry); + } + } + + matches.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then(a.name.cmp(&b.name)) + .then(a.selection_lines.cmp(&b.selection_lines)) + .then(a.selection_depth.cmp(&b.selection_depth)) + }); + + let report = AnalyzeExecutableDefinitionsReport { + project: project_name.to_string(), + min_selection_lines, + min_selection_depth, + total_operations: programs.source.operations().count(), + total_fragments: programs.source.fragments().count(), + match_count: matches.len(), + matches, + }; + + if json { + let json_output = + serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { + details: format!("Unable to serialize analyze output: {err}"), + })?; + println!("{}", json_output); + } else { + print_analyze_executable_definitions_text_report(&report) + } + + Ok(()) +} + +fn analyze_executable_definition( + kind: &'static str, + name: String, + name_location: Location, + selections: &[Selection], + min_selection_lines: Option, + min_selection_depth: Option, + root_dir: &Path, +) -> Result, Error> { + let (selection_span, selection_depth) = get_selection_span_and_depth(selections); + let source_location = name_location.source_location(); + let location_source_text = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for definition '{}'.", + source_location.path(), + name + ), + })? + .text_source() + .to_owned(); + + let (selection_span_for_location, selection_lines) = if let Some(span) = selection_span { + let range = location_source_text.to_span_range(span); + let selection_lines = (range.end.line - range.start.line + 1) as usize; + (span, selection_lines) + } else { + (name_location.span(), 0) + }; + + let range = location_source_text.to_span_range(selection_span_for_location); + let mut violations = Vec::new(); + if let Some(min_selection_lines) = min_selection_lines { + if selection_lines >= min_selection_lines { + violations.push(format!( + "selection body covers {} line(s), meets minimum line threshold of {}", + selection_lines, min_selection_lines + )); + } + } + if let Some(min_selection_depth) = min_selection_depth { + if selection_depth >= min_selection_depth { + violations.push(format!( + "selection depth is {}, meets minimum depth threshold of {}", + selection_depth, min_selection_depth + )); + } + } + + if violations.is_empty() { + return Ok(None); + } + + Ok(Some(AnalyzeExecutableDefinitionMatch { + kind: kind.to_string(), + name, + location: AnalyzeExecutableDefinitionLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }, + selection_lines, + selection_depth, + violations, + })) +} + +fn get_selection_span_and_depth(selections: &[Selection]) -> (Option, usize) { + let mut total_selection_span: Option = None; + let mut max_depth = 0; + + for selection in selections { + let selection_span = selection.location().span(); + let (depth, nested_span) = match selection { + Selection::ScalarField(_) | Selection::FragmentSpread(_) => (1, None), + Selection::LinkedField(linked_field) => { + let (nested_span, nested_depth) = get_selection_span_and_depth(&linked_field.selections); + (1 + nested_depth, Some(nested_span)) + } + Selection::InlineFragment(inline_fragment) => { + let (nested_span, nested_depth) = + get_selection_span_and_depth(&inline_fragment.selections); + (1 + nested_depth, Some(nested_span)) + } + Selection::Condition(condition) => { + let (nested_span, nested_depth) = get_selection_span_and_depth(&condition.selections); + (1 + nested_depth, Some(nested_span)) + } + }; + + let selection_span = match nested_span { + Some(nested_span) => maybe_merge_spans(Some(selection_span), nested_span), + None => Some(selection_span), + }; + total_selection_span = maybe_merge_spans(total_selection_span, selection_span); + max_depth = max(max_depth, depth); + } + + (total_selection_span, max_depth) +} + +fn maybe_merge_spans(first: Option, second: Option) -> Option { + match (first, second) { + (Some(first_span), Some(second_span)) => Some(Span::new( + min(first_span.start, second_span.start), + max(first_span.end, second_span.end), + )), + (Some(first_span), None) => Some(first_span), + (None, Some(second_span)) => Some(second_span), + (None, None) => None, + } +} + +fn print_analyze_executable_definitions_text_report( + report: &AnalyzeExecutableDefinitionsReport, +) { + if report.match_count == 0 { + println!( + "Project {}: no executable definitions match the provided criteria.", + report.project + ); + return; + } + + println!( + "Project {}: {} match(es) across {} operation(s), {} fragment(s).", + report.project, + report.match_count, + report.total_operations, + report.total_fragments + ); + + for match_entry in &report.matches { + println!( + " {} {} @ {}:{}:{}-{}:{} (lines={}, depth={})", + match_entry.kind, + match_entry.name, + match_entry.location.filename, + match_entry.location.start_line, + match_entry.location.start_column, + match_entry.location.end_line, + match_entry.location.end_column, + match_entry.selection_lines, + match_entry.selection_depth + ); + println!( + " violations: {}", + match_entry.violations.join(", ") + ); } } diff --git a/compiler/crates/relay-bin/src/main.rs b/compiler/crates/relay-bin/src/main.rs index d047d1576161b..e150e587b2b96 100644 --- a/compiler/crates/relay-bin/src/main.rs +++ b/compiler/crates/relay-bin/src/main.rs @@ -44,7 +44,7 @@ use simplelog::LevelFilter; use simplelog::TermLogger; use simplelog::TerminalMode; -mod analyze_schema_dce; +mod analyze; mod errors; use errors::Error; @@ -154,7 +154,7 @@ enum Commands { Lsp(LspCommand), ConfigJsonSchema(ConfigJsonSchemaCommand), Codemod(CodemodCommand), - Analyze(analyze_schema_dce::AnalyzeCommand), + Analyze(analyze::AnalyzeCommand), } #[derive(ValueEnum, Clone, Copy)] @@ -216,7 +216,7 @@ async fn main() { Ok(()) } Commands::Codemod(command) => handle_codemod_command(command).await, - Commands::Analyze(command) => analyze_schema_dce::handle_analyze_command(command).await, + Commands::Analyze(command) => analyze::handle_analyze_command(command).await, }; if let Err(err) = result { From 53aeb1d0480c3ec352d357f8937394c11c91d97e Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 17 Mar 2026 15:26:39 +0100 Subject: [PATCH 3/7] add find-references and print-operation sub commands --- compiler/Cargo.lock | 1 + compiler/crates/relay-bin/Cargo.toml | 1 + compiler/crates/relay-bin/src/analyze.rs | 604 +++++++++++++++++++++++ 3 files changed, 606 insertions(+) diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock index cfee897e1e9be..07ee90f13aa8e 100644 --- a/compiler/Cargo.lock +++ b/compiler/Cargo.lock @@ -1847,6 +1847,7 @@ dependencies = [ "clap", "common", "graphql-ir", + "graphql-text-printer", "intern", "log", "relay-codemod", diff --git a/compiler/crates/relay-bin/Cargo.toml b/compiler/crates/relay-bin/Cargo.toml index 0ef092bd6d0fa..0324189f1e016 100644 --- a/compiler/crates/relay-bin/Cargo.toml +++ b/compiler/crates/relay-bin/Cargo.toml @@ -21,6 +21,7 @@ relay-transforms = { path = "../relay-transforms" } schema = { path = "../schema" } schema-documentation = { path = "../schema-documentation" } simplelog = "0.12.2" +graphql-text-printer = { path = "../graphql-text-printer" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" thiserror = "2.0.12" diff --git a/compiler/crates/relay-bin/src/analyze.rs b/compiler/crates/relay-bin/src/analyze.rs index e7ad3f17fc6b0..507d70b3e5e2b 100644 --- a/compiler/crates/relay-bin/src/analyze.rs +++ b/compiler/crates/relay-bin/src/analyze.rs @@ -8,20 +8,26 @@ use std::sync::Arc; use clap::Parser; use common::{ConsoleLogger, Location}; +use graphql_ir::OperationDefinitionName; use graphql_ir::FragmentDefinition; use graphql_ir::FragmentDefinitionName; use graphql_ir::Selection; +use graphql_ir::Visitor; use intern::Lookup; +use intern::string_key::Intern; +use graphql_text_printer::print_full_operation; use relay_compiler::get_programs; use relay_compiler::source_for_location; use relay_compiler::ProjectName; use relay_compiler::FsSourceReader; +use relay_lsp::find_field_usages::get_usages; use common::Span; use schema::Field; use schema::FieldID; use schema::SDLSchema; use schema::Schema; use schema::Type; +use relay_transforms::apply_transforms; use serde::Serialize; use crate::errors::Error; @@ -37,6 +43,14 @@ pub struct AnalyzeCommand { #[derive(clap::Subcommand)] enum AnalyzeSubcommand { + /// Find references for a schema path. + #[clap(name = "find-references")] + FindReferences(AnalyzeFindReferencesCommand), + + /// Print the full text for a named GraphQL operation. + #[clap(name = "print-operation")] + PrintOperation(AnalyzePrintOperationCommand), + /// Find unused schema fields in Relay operations. #[clap(name = "schema-dce")] SchemaDce(AnalyzeSchemaDCECommand), @@ -46,6 +60,58 @@ enum AnalyzeSubcommand { ExecutableDefinitions(AnalyzeExecutableDefinitionsCommand), } +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find references for schema type/field paths." +)] +pub struct AnalyzeFindReferencesCommand { + /// A schema path: either `Type` or `Type.field`. + payload: String, + + /// Include the full line containing the reference for each match. + #[clap(long = "with-snippet")] + with_snippet: bool, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Analyze using this config file. If not provided, searches for a config in + /// package.json under the `relay` key or `relay.config.json` files among other up + /// from the current working directory. + config: Option, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Print the full text for a named GraphQL operation." +)] +pub struct AnalyzePrintOperationCommand { + /// The name of the operation to print. + operation: String, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Analyze using this config file. If not provided, searches for a config in + /// package.json under the `relay` key or `relay.config.json` files among other up + /// from the current working directory. + config: Option, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + #[derive(Parser)] #[clap( rename_all = "camel_case", @@ -152,6 +218,12 @@ struct AnalyzeExecutableDefinitionLocation { pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error> { match command.command { + AnalyzeSubcommand::FindReferences(command) => { + handle_analyze_find_references_command(command).await + } + AnalyzeSubcommand::PrintOperation(command) => { + handle_analyze_print_operation_command(command).await + } AnalyzeSubcommand::SchemaDce(command) => { handle_analyze_schema_dce_command(command).await } @@ -161,6 +233,538 @@ pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error } } +async fn handle_analyze_find_references_command( + command: AnalyzeFindReferencesCommand, +) -> Result<(), Error> { + let mut config = get_config(command.config)?; + let project_name = ensure_single_project_config(&config)?; + let payload = parse_find_references_payload(&command.payload)?; + let with_snippet = command.with_snippet; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_find_references( + project_name, + program.as_ref(), + &config.root_dir, + payload, + with_snippet, + json, + )?; + Ok(()) +} + +async fn handle_analyze_print_operation_command( + command: AnalyzePrintOperationCommand, +) -> Result<(), Error> { + let mut config = get_config(command.config)?; + let project_name = ensure_single_project_config(&config)?; + let operation_name = command.operation; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_print_operation( + project_name, + program.as_ref(), + &config, + operation_name, + json, + )?; + Ok(()) +} + +#[derive(Debug)] +struct AnalyzeFindReferencesPayload { + type_name: String, + field_name: Option, +} + +fn parse_find_references_payload(payload: &str) -> Result { + let payload = payload.trim(); + if payload.is_empty() { + return Err(Error::AnalyzeError { + details: "A payload is required, e.g. `User` or `User.name`.".to_string(), + }); + } + + let parts: Vec<&str> = payload.split('.').map(str::trim).collect(); + match parts.as_slice() { + [] => Err(Error::AnalyzeError { + details: "A payload is required, e.g. `User` or `User.name`.".to_string(), + }), + [type_name] => { + if type_name.is_empty() { + return Err(Error::AnalyzeError { + details: "Expected a type name, e.g. `User`.".to_string(), + }); + } + Ok(AnalyzeFindReferencesPayload { + type_name: (*type_name).to_string(), + field_name: None, + }) + } + [type_name, field_name] => { + if type_name.is_empty() || field_name.is_empty() { + return Err(Error::AnalyzeError { + details: "Expected payload in the format `Type` or `Type.field`." + .to_string(), + }); + } + Ok(AnalyzeFindReferencesPayload { + type_name: (*type_name).to_string(), + field_name: Some((*field_name).to_string()), + }) + } + _ => Err(Error::AnalyzeError { + details: "Expected payload in the format `Type` or `Type.field`.".to_string(), + }), + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesReport { + project: String, + target_type: String, + target_field: Option, + with_snippet: bool, + matches: Vec, + match_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesMatch { + kind: String, + containing_definition: String, + location: AnalyzeFindReferencesLocation, + snippet: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzePrintOperationReport { + project: String, + operation_name: String, + operation_text: String, +} + +#[derive(Debug)] +struct AnalyzeFindReferenceItem { + kind: String, + container: String, + location: Location, +} + +fn analyze_project_find_references( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + payload: AnalyzeFindReferencesPayload, + with_snippet: bool, + json: bool, +) -> Result<(), Error> { + let schema = &programs.source.schema; + let type_name = payload.type_name.clone(); + let type_name_key = type_name.clone().intern(); + let type_ = schema + .get_type(type_name_key) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Type `{}` was not found in the schema.", type_name), + })?; + + let mut items = Vec::new(); + if let Some(field_name) = payload.field_name.as_deref() { + let usages = get_usages(&programs.source, schema, type_name_key, field_name.intern()) + .map_err(|err| Error::AnalyzeError { + details: format!("Unable to find references: {err:?}"), + })?; + for (label, location) in usages { + items.push(AnalyzeFindReferenceItem { + kind: "field".to_string(), + container: normalize_containing_definition(&label), + location, + }); + } + } else { + items = collect_type_condition_references(programs, type_); + } + + let mut matches = items + .into_iter() + .map(|item| { + let location = location_from_reference(root_dir, &item.location)?; + let snippet = if with_snippet { + Some(reference_line_snippet(root_dir, &item.location)?) + } else { + None + }; + Ok(AnalyzeFindReferencesMatch { + kind: item.kind, + containing_definition: item.container, + location, + snippet, + }) + }) + .collect::, Error>>()?; + + matches.sort_by(|a, b| { + a.location + .filename + .cmp(&b.location.filename) + .then(a.location.start_line.cmp(&b.location.start_line)) + .then(a.location.start_column.cmp(&b.location.start_column)) + .then(a.location.end_line.cmp(&b.location.end_line)) + .then(a.location.end_column.cmp(&b.location.end_column)) + .then(a.containing_definition.cmp(&b.containing_definition)) + }); + + let report = AnalyzeFindReferencesReport { + project: project_name.to_string(), + target_type: payload.type_name, + target_field: payload.field_name, + with_snippet, + match_count: matches.len(), + matches, + }; + + if json { + let json_output = + serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { + details: format!("Unable to serialize analyze output: {err}"), + })?; + println!("{}", json_output); + } else { + print_analyze_find_references_text_report(&report); + } + Ok(()) +} + +fn analyze_project_print_operation( + project_name: ProjectName, + programs: &relay_transforms::Programs, + config: &relay_compiler::config::Config, + operation_name: String, + json: bool, +) -> Result<(), Error> { + let operation_name = OperationDefinitionName(operation_name.clone().intern()); + let operation = programs + .source + .operation(operation_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Operation `{}` was not found in source documents.", + operation_name + ), + })?; + + let project_config = config + .enabled_projects() + .find(|project_config| project_config.name == project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to get project config for project {project_name}."), + })?; + + let operation_only_program = get_operation_only_program(Arc::clone(&operation), vec![], &programs.source); + let transformed_programs = apply_transforms( + project_config, + Arc::new(operation_only_program), + Default::default(), + Arc::new(ConsoleLogger), + None, + config.custom_transforms.as_ref(), + config.transferrable_refetchable_query_directives.clone(), + ) + .map_err(|errors| Error::AnalyzeError { + details: format!( + "Unable to run transforms for operation `{}`: {errors:?}", + operation_name + ), + })?; + + let operation_to_print = transformed_programs + .operation_text + .operation(operation_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to print operation `{}` after transforms.", + operation_name + ), + })?; + + let report = AnalyzePrintOperationReport { + project: project_name.to_string(), + operation_name: operation_name.to_string(), + operation_text: print_full_operation( + &transformed_programs.operation_text, + operation_to_print, + Default::default(), + ), + }; + + if json { + let json_output = + serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { + details: format!("Unable to serialize analyze output: {err}"), + })?; + println!("{}", json_output); + } else { + print_analyze_print_operation_text_report(&report); + } + Ok(()) +} + +fn get_operation_only_program( + operation: std::sync::Arc, + fragments: Vec>, + program: &graphql_ir::Program, +) -> graphql_ir::Program { + use std::collections::HashSet; + + let mut selections_to_visit: Vec<&[graphql_ir::Selection]> = vec![&operation.selections]; + let mut next_program = graphql_ir::Program::new(program.schema.clone()); + let mut visited_fragments: HashSet = HashSet::default(); + + next_program.insert_operation(Arc::clone(&operation)); + for fragment in fragments.iter() { + selections_to_visit.push(&fragment.selections); + next_program.insert_fragment(Arc::clone(fragment)); + } + + while let Some(current_selections) = selections_to_visit.pop() { + for selection in current_selections { + match selection { + graphql_ir::Selection::FragmentSpread(spread) => { + if visited_fragments.contains(&spread.fragment.item) { + continue; + } + visited_fragments.insert(spread.fragment.item); + if let Some(fragment) = program.fragment(spread.fragment.item) { + selections_to_visit.push(&fragment.selections); + next_program.insert_fragment(Arc::clone(&fragment)); + } + } + graphql_ir::Selection::Condition(condition) => { + selections_to_visit.push(&condition.selections); + } + graphql_ir::Selection::LinkedField(linked_field) => { + selections_to_visit.push(&linked_field.selections); + } + graphql_ir::Selection::InlineFragment(inline_fragment) => { + selections_to_visit.push(&inline_fragment.selections); + } + graphql_ir::Selection::ScalarField(_) => {} + } + } + } + + next_program +} + +fn print_analyze_print_operation_text_report(report: &AnalyzePrintOperationReport) { + println!("Project {}: operation {}.", report.project, report.operation_name); + println!("{}", report.operation_text); +} + +fn collect_type_condition_references( + programs: &relay_transforms::Programs, + target_type: Type, +) -> Vec { + let mut visitor = TypeReferenceFinder { + target_type, + container: None, + items: Vec::new(), + }; + visitor.visit_program(&programs.source); + visitor.items +} + +fn location_from_reference( + root_dir: &Path, + location: &Location, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for reference.", + source_location.path() + ), + })?; + let source_text = source.text_source(); + let range = source_text.to_span_range(location.span()); + + Ok(AnalyzeFindReferencesLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }) +} + +fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for snippet lookup.", + source_location.path() + ), + })?; + let text_source = source.text_source(); + let range = text_source.to_span_range(location.span()); + let local_line = range + .start + .line + .checked_sub(text_source.line_index as u32) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to resolve snippet line for {}.", source_location.path()), + })?; + text_source + .text + .lines() + .nth(local_line as usize) + .map(|line| line.to_string()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to resolve snippet line for {}:{}.", + source_location.path(), + range.start.line + 1 + ), + }) +} + +fn normalize_containing_definition(label: &str) -> String { + label.split(" - ").next().unwrap_or(label).to_string() +} + +struct TypeReferenceFinder { + target_type: Type, + container: Option, + items: Vec, +} + +impl TypeReferenceFinder { + fn push_reference(&mut self, location: Location, kind: &str) { + if let Some(container) = &self.container { + self.items.push(AnalyzeFindReferenceItem { + kind: kind.to_string(), + container: container.clone(), + location, + }); + } + } +} + +impl Visitor for TypeReferenceFinder { + const NAME: &'static str = "TypeReferenceFinder"; + const VISIT_ARGUMENTS: bool = false; + const VISIT_DIRECTIVES: bool = false; + + fn visit_operation(&mut self, operation: &graphql_ir::OperationDefinition) { + let prev_container = self.container.replace(operation.name.item.0.to_string()); + self.default_visit_operation(operation); + self.container = prev_container; + } + + fn visit_fragment(&mut self, fragment: &FragmentDefinition) { + let prev_container = self.container.replace(fragment.name.item.0.to_string()); + if fragment.type_condition == self.target_type { + self.push_reference(fragment.name.location, "fragment"); + } + self.default_visit_fragment(fragment); + self.container = prev_container; + } + + fn visit_inline_fragment(&mut self, inline_fragment: &graphql_ir::InlineFragment) { + if let Some(type_condition) = inline_fragment.type_condition { + if type_condition == self.target_type { + self.push_reference(inline_fragment.spread_location, "inline-fragment"); + } + } + self.default_visit_inline_fragment(inline_fragment); + } +} + +fn print_analyze_find_references_text_report(report: &AnalyzeFindReferencesReport) { + if report.matches.is_empty() { + if let Some(target_field) = &report.target_field { + println!( + "Project {}: no references found for {}.{}.", + report.project, report.target_type, target_field + ); + } else { + println!( + "Project {}: no references found for {}.", + report.project, report.target_type + ); + } + return; + } + + println!( + "Project {}: {} match(es) found for {}{}.", + report.project, + report.match_count, + report.target_type, + report + .target_field + .as_ref() + .map(|field| format!(".{}", field)) + .unwrap_or_default() + ); + for reference in &report.matches { + println!( + " {} {} @ {}:{}:{}-{}:{}", + reference.kind, + reference.containing_definition, + reference.location.filename, + reference.location.start_line, + reference.location.start_column, + reference.location.end_line, + reference.location.end_column + ); + if let Some(snippet) = &reference.snippet { + println!(" line: {}", snippet); + } + } +} + async fn handle_analyze_executable_definitions_command( command: AnalyzeExecutableDefinitionsCommand, ) -> Result<(), Error> { From c5363b9ff592d4c9fa0fcfdf2cb41fe554bafb2b Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 17 Mar 2026 15:35:18 +0100 Subject: [PATCH 4/7] refactor --- compiler/crates/relay-bin/src/analyze.rs | 1504 ----------------- .../src/analyze/executable_definitions.rs | 341 ++++ .../relay-bin/src/analyze/find_references.rs | 405 +++++ compiler/crates/relay-bin/src/analyze/mod.rs | 58 + .../relay-bin/src/analyze/print_operation.rs | 201 +++ .../relay-bin/src/analyze/schema_dce.rs | 496 ++++++ .../crates/relay-bin/src/analyze/utils.rs | 32 + 7 files changed, 1533 insertions(+), 1504 deletions(-) delete mode 100644 compiler/crates/relay-bin/src/analyze.rs create mode 100644 compiler/crates/relay-bin/src/analyze/executable_definitions.rs create mode 100644 compiler/crates/relay-bin/src/analyze/find_references.rs create mode 100644 compiler/crates/relay-bin/src/analyze/mod.rs create mode 100644 compiler/crates/relay-bin/src/analyze/print_operation.rs create mode 100644 compiler/crates/relay-bin/src/analyze/schema_dce.rs create mode 100644 compiler/crates/relay-bin/src/analyze/utils.rs diff --git a/compiler/crates/relay-bin/src/analyze.rs b/compiler/crates/relay-bin/src/analyze.rs deleted file mode 100644 index 507d70b3e5e2b..0000000000000 --- a/compiler/crates/relay-bin/src/analyze.rs +++ /dev/null @@ -1,1504 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::collections::HashSet; -use std::path::Path; -use std::path::PathBuf; -use std::cmp::{max, min}; -use std::sync::Arc; - -use clap::Parser; -use common::{ConsoleLogger, Location}; -use graphql_ir::OperationDefinitionName; -use graphql_ir::FragmentDefinition; -use graphql_ir::FragmentDefinitionName; -use graphql_ir::Selection; -use graphql_ir::Visitor; -use intern::Lookup; -use intern::string_key::Intern; -use graphql_text_printer::print_full_operation; -use relay_compiler::get_programs; -use relay_compiler::source_for_location; -use relay_compiler::ProjectName; -use relay_compiler::FsSourceReader; -use relay_lsp::find_field_usages::get_usages; -use common::Span; -use schema::Field; -use schema::FieldID; -use schema::SDLSchema; -use schema::Schema; -use schema::Type; -use relay_transforms::apply_transforms; -use serde::Serialize; - -use crate::errors::Error; -use crate::{get_config, set_project_flag}; - -#[derive(Parser)] -#[clap(rename_all = "snake_case", about = "Schema analysis helpers.")] -pub struct AnalyzeCommand { - /// Schema analysis commands. - #[clap(subcommand)] - command: AnalyzeSubcommand, -} - -#[derive(clap::Subcommand)] -enum AnalyzeSubcommand { - /// Find references for a schema path. - #[clap(name = "find-references")] - FindReferences(AnalyzeFindReferencesCommand), - - /// Print the full text for a named GraphQL operation. - #[clap(name = "print-operation")] - PrintOperation(AnalyzePrintOperationCommand), - - /// Find unused schema fields in Relay operations. - #[clap(name = "schema-dce")] - SchemaDce(AnalyzeSchemaDCECommand), - - /// Find operations/fragments by selection size/depth. - #[clap(name = "executable-definitions")] - ExecutableDefinitions(AnalyzeExecutableDefinitionsCommand), -} - -#[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find references for schema type/field paths." -)] -pub struct AnalyzeFindReferencesCommand { - /// A schema path: either `Type` or `Type.field`. - payload: String, - - /// Include the full line containing the reference for each match. - #[clap(long = "with-snippet")] - with_snippet: bool, - - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. - #[clap(name = "project", long, short)] - projects: Vec, - - /// Analyze using this config file. If not provided, searches for a config in - /// package.json under the `relay` key or `relay.config.json` files among other up - /// from the current working directory. - config: Option, - - /// Emit JSON output. - #[clap(long)] - json: bool, -} - -#[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Print the full text for a named GraphQL operation." -)] -pub struct AnalyzePrintOperationCommand { - /// The name of the operation to print. - operation: String, - - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. - #[clap(name = "project", long, short)] - projects: Vec, - - /// Analyze using this config file. If not provided, searches for a config in - /// package.json under the `relay` key or `relay.config.json` files among other up - /// from the current working directory. - config: Option, - - /// Emit JSON output. - #[clap(long)] - json: bool, -} - -#[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find unused schema fields in Relay operations." -)] -pub struct AnalyzeSchemaDCECommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. - #[clap(name = "project", long, short)] - projects: Vec, - - /// Analyze using this config file. If not provided, searches for a config in - /// package.json under the `relay` key or `relay.config.json` files among other up - /// from the current working directory. - config: Option, - - /// Emit JSON output. - #[clap(long)] - json: bool, -} - -#[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find operations and fragments by selection size/depth." -)] -pub struct AnalyzeExecutableDefinitionsCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. - #[clap(name = "project", long, short)] - projects: Vec, - - /// Analyze using this config file. If not provided, searches for a config in - /// package.json under the `relay` key or `relay.config.json` files among other up - /// from the current working directory. - config: Option, - - /// Minimum number of line breaks covered by selections. - #[clap(long = "min-selection-lines")] - min_selection_lines: Option, - - /// Minimum depth of selection nesting. - #[clap(long = "min-selection-depth")] - min_selection_depth: Option, - - /// Emit JSON output. - #[clap(long)] - json: bool, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeSchemaDceTypeReport { - type_name: String, - type_description: String, - type_referenced: bool, - dead_fields: Vec, - dead_union_members: Vec, - #[serde(skip)] - existing_field_count: usize, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeSchemaDceReport { - project: String, - dead_fields: Vec, - dead_field_count: usize, - dead_union_member_count: usize, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeExecutableDefinitionsReport { - project: String, - min_selection_lines: Option, - min_selection_depth: Option, - total_operations: usize, - total_fragments: usize, - matches: Vec, - match_count: usize, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeExecutableDefinitionMatch { - kind: String, - name: String, - location: AnalyzeExecutableDefinitionLocation, - selection_lines: usize, - selection_depth: usize, - violations: Vec, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeExecutableDefinitionLocation { - filename: String, - start_line: u32, - start_column: u32, - end_line: u32, - end_column: u32, -} - -pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error> { - match command.command { - AnalyzeSubcommand::FindReferences(command) => { - handle_analyze_find_references_command(command).await - } - AnalyzeSubcommand::PrintOperation(command) => { - handle_analyze_print_operation_command(command).await - } - AnalyzeSubcommand::SchemaDce(command) => { - handle_analyze_schema_dce_command(command).await - } - AnalyzeSubcommand::ExecutableDefinitions(command) => { - handle_analyze_executable_definitions_command(command).await - } - } -} - -async fn handle_analyze_find_references_command( - command: AnalyzeFindReferencesCommand, -) -> Result<(), Error> { - let mut config = get_config(command.config)?; - let project_name = ensure_single_project_config(&config)?; - let payload = parse_find_references_payload(&command.payload)?; - let with_snippet = command.with_snippet; - let json = command.json; - set_project_flag(&mut config, command.projects)?; - - let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; - if programs_by_project.is_empty() { - return Err(Error::AnalyzeError { - details: "No programs were produced by analyze.".to_string(), - }); - } - - let program = programs_by_project - .get(&project_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Project {project_name} was not built for analyze."), - })?; - analyze_project_find_references( - project_name, - program.as_ref(), - &config.root_dir, - payload, - with_snippet, - json, - )?; - Ok(()) -} - -async fn handle_analyze_print_operation_command( - command: AnalyzePrintOperationCommand, -) -> Result<(), Error> { - let mut config = get_config(command.config)?; - let project_name = ensure_single_project_config(&config)?; - let operation_name = command.operation; - let json = command.json; - set_project_flag(&mut config, command.projects)?; - - let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; - if programs_by_project.is_empty() { - return Err(Error::AnalyzeError { - details: "No programs were produced by analyze.".to_string(), - }); - } - - let program = programs_by_project - .get(&project_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Project {project_name} was not built for analyze."), - })?; - analyze_project_print_operation( - project_name, - program.as_ref(), - &config, - operation_name, - json, - )?; - Ok(()) -} - -#[derive(Debug)] -struct AnalyzeFindReferencesPayload { - type_name: String, - field_name: Option, -} - -fn parse_find_references_payload(payload: &str) -> Result { - let payload = payload.trim(); - if payload.is_empty() { - return Err(Error::AnalyzeError { - details: "A payload is required, e.g. `User` or `User.name`.".to_string(), - }); - } - - let parts: Vec<&str> = payload.split('.').map(str::trim).collect(); - match parts.as_slice() { - [] => Err(Error::AnalyzeError { - details: "A payload is required, e.g. `User` or `User.name`.".to_string(), - }), - [type_name] => { - if type_name.is_empty() { - return Err(Error::AnalyzeError { - details: "Expected a type name, e.g. `User`.".to_string(), - }); - } - Ok(AnalyzeFindReferencesPayload { - type_name: (*type_name).to_string(), - field_name: None, - }) - } - [type_name, field_name] => { - if type_name.is_empty() || field_name.is_empty() { - return Err(Error::AnalyzeError { - details: "Expected payload in the format `Type` or `Type.field`." - .to_string(), - }); - } - Ok(AnalyzeFindReferencesPayload { - type_name: (*type_name).to_string(), - field_name: Some((*field_name).to_string()), - }) - } - _ => Err(Error::AnalyzeError { - details: "Expected payload in the format `Type` or `Type.field`.".to_string(), - }), - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFindReferencesReport { - project: String, - target_type: String, - target_field: Option, - with_snippet: bool, - matches: Vec, - match_count: usize, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFindReferencesMatch { - kind: String, - containing_definition: String, - location: AnalyzeFindReferencesLocation, - snippet: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFindReferencesLocation { - filename: String, - start_line: u32, - start_column: u32, - end_line: u32, - end_column: u32, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzePrintOperationReport { - project: String, - operation_name: String, - operation_text: String, -} - -#[derive(Debug)] -struct AnalyzeFindReferenceItem { - kind: String, - container: String, - location: Location, -} - -fn analyze_project_find_references( - project_name: ProjectName, - programs: &relay_transforms::Programs, - root_dir: &Path, - payload: AnalyzeFindReferencesPayload, - with_snippet: bool, - json: bool, -) -> Result<(), Error> { - let schema = &programs.source.schema; - let type_name = payload.type_name.clone(); - let type_name_key = type_name.clone().intern(); - let type_ = schema - .get_type(type_name_key) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Type `{}` was not found in the schema.", type_name), - })?; - - let mut items = Vec::new(); - if let Some(field_name) = payload.field_name.as_deref() { - let usages = get_usages(&programs.source, schema, type_name_key, field_name.intern()) - .map_err(|err| Error::AnalyzeError { - details: format!("Unable to find references: {err:?}"), - })?; - for (label, location) in usages { - items.push(AnalyzeFindReferenceItem { - kind: "field".to_string(), - container: normalize_containing_definition(&label), - location, - }); - } - } else { - items = collect_type_condition_references(programs, type_); - } - - let mut matches = items - .into_iter() - .map(|item| { - let location = location_from_reference(root_dir, &item.location)?; - let snippet = if with_snippet { - Some(reference_line_snippet(root_dir, &item.location)?) - } else { - None - }; - Ok(AnalyzeFindReferencesMatch { - kind: item.kind, - containing_definition: item.container, - location, - snippet, - }) - }) - .collect::, Error>>()?; - - matches.sort_by(|a, b| { - a.location - .filename - .cmp(&b.location.filename) - .then(a.location.start_line.cmp(&b.location.start_line)) - .then(a.location.start_column.cmp(&b.location.start_column)) - .then(a.location.end_line.cmp(&b.location.end_line)) - .then(a.location.end_column.cmp(&b.location.end_column)) - .then(a.containing_definition.cmp(&b.containing_definition)) - }); - - let report = AnalyzeFindReferencesReport { - project: project_name.to_string(), - target_type: payload.type_name, - target_field: payload.field_name, - with_snippet, - match_count: matches.len(), - matches, - }; - - if json { - let json_output = - serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { - details: format!("Unable to serialize analyze output: {err}"), - })?; - println!("{}", json_output); - } else { - print_analyze_find_references_text_report(&report); - } - Ok(()) -} - -fn analyze_project_print_operation( - project_name: ProjectName, - programs: &relay_transforms::Programs, - config: &relay_compiler::config::Config, - operation_name: String, - json: bool, -) -> Result<(), Error> { - let operation_name = OperationDefinitionName(operation_name.clone().intern()); - let operation = programs - .source - .operation(operation_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Operation `{}` was not found in source documents.", - operation_name - ), - })?; - - let project_config = config - .enabled_projects() - .find(|project_config| project_config.name == project_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Unable to get project config for project {project_name}."), - })?; - - let operation_only_program = get_operation_only_program(Arc::clone(&operation), vec![], &programs.source); - let transformed_programs = apply_transforms( - project_config, - Arc::new(operation_only_program), - Default::default(), - Arc::new(ConsoleLogger), - None, - config.custom_transforms.as_ref(), - config.transferrable_refetchable_query_directives.clone(), - ) - .map_err(|errors| Error::AnalyzeError { - details: format!( - "Unable to run transforms for operation `{}`: {errors:?}", - operation_name - ), - })?; - - let operation_to_print = transformed_programs - .operation_text - .operation(operation_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to print operation `{}` after transforms.", - operation_name - ), - })?; - - let report = AnalyzePrintOperationReport { - project: project_name.to_string(), - operation_name: operation_name.to_string(), - operation_text: print_full_operation( - &transformed_programs.operation_text, - operation_to_print, - Default::default(), - ), - }; - - if json { - let json_output = - serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { - details: format!("Unable to serialize analyze output: {err}"), - })?; - println!("{}", json_output); - } else { - print_analyze_print_operation_text_report(&report); - } - Ok(()) -} - -fn get_operation_only_program( - operation: std::sync::Arc, - fragments: Vec>, - program: &graphql_ir::Program, -) -> graphql_ir::Program { - use std::collections::HashSet; - - let mut selections_to_visit: Vec<&[graphql_ir::Selection]> = vec![&operation.selections]; - let mut next_program = graphql_ir::Program::new(program.schema.clone()); - let mut visited_fragments: HashSet = HashSet::default(); - - next_program.insert_operation(Arc::clone(&operation)); - for fragment in fragments.iter() { - selections_to_visit.push(&fragment.selections); - next_program.insert_fragment(Arc::clone(fragment)); - } - - while let Some(current_selections) = selections_to_visit.pop() { - for selection in current_selections { - match selection { - graphql_ir::Selection::FragmentSpread(spread) => { - if visited_fragments.contains(&spread.fragment.item) { - continue; - } - visited_fragments.insert(spread.fragment.item); - if let Some(fragment) = program.fragment(spread.fragment.item) { - selections_to_visit.push(&fragment.selections); - next_program.insert_fragment(Arc::clone(&fragment)); - } - } - graphql_ir::Selection::Condition(condition) => { - selections_to_visit.push(&condition.selections); - } - graphql_ir::Selection::LinkedField(linked_field) => { - selections_to_visit.push(&linked_field.selections); - } - graphql_ir::Selection::InlineFragment(inline_fragment) => { - selections_to_visit.push(&inline_fragment.selections); - } - graphql_ir::Selection::ScalarField(_) => {} - } - } - } - - next_program -} - -fn print_analyze_print_operation_text_report(report: &AnalyzePrintOperationReport) { - println!("Project {}: operation {}.", report.project, report.operation_name); - println!("{}", report.operation_text); -} - -fn collect_type_condition_references( - programs: &relay_transforms::Programs, - target_type: Type, -) -> Vec { - let mut visitor = TypeReferenceFinder { - target_type, - container: None, - items: Vec::new(), - }; - visitor.visit_program(&programs.source); - visitor.items -} - -fn location_from_reference( - root_dir: &Path, - location: &Location, -) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for reference.", - source_location.path() - ), - })?; - let source_text = source.text_source(); - let range = source_text.to_span_range(location.span()); - - Ok(AnalyzeFindReferencesLocation { - filename: source_location.path().to_string(), - start_line: range.start.line + 1, - start_column: range.start.character + 1, - end_line: range.end.line + 1, - end_column: range.end.character + 1, - }) -} - -fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for snippet lookup.", - source_location.path() - ), - })?; - let text_source = source.text_source(); - let range = text_source.to_span_range(location.span()); - let local_line = range - .start - .line - .checked_sub(text_source.line_index as u32) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Unable to resolve snippet line for {}.", source_location.path()), - })?; - text_source - .text - .lines() - .nth(local_line as usize) - .map(|line| line.to_string()) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to resolve snippet line for {}:{}.", - source_location.path(), - range.start.line + 1 - ), - }) -} - -fn normalize_containing_definition(label: &str) -> String { - label.split(" - ").next().unwrap_or(label).to_string() -} - -struct TypeReferenceFinder { - target_type: Type, - container: Option, - items: Vec, -} - -impl TypeReferenceFinder { - fn push_reference(&mut self, location: Location, kind: &str) { - if let Some(container) = &self.container { - self.items.push(AnalyzeFindReferenceItem { - kind: kind.to_string(), - container: container.clone(), - location, - }); - } - } -} - -impl Visitor for TypeReferenceFinder { - const NAME: &'static str = "TypeReferenceFinder"; - const VISIT_ARGUMENTS: bool = false; - const VISIT_DIRECTIVES: bool = false; - - fn visit_operation(&mut self, operation: &graphql_ir::OperationDefinition) { - let prev_container = self.container.replace(operation.name.item.0.to_string()); - self.default_visit_operation(operation); - self.container = prev_container; - } - - fn visit_fragment(&mut self, fragment: &FragmentDefinition) { - let prev_container = self.container.replace(fragment.name.item.0.to_string()); - if fragment.type_condition == self.target_type { - self.push_reference(fragment.name.location, "fragment"); - } - self.default_visit_fragment(fragment); - self.container = prev_container; - } - - fn visit_inline_fragment(&mut self, inline_fragment: &graphql_ir::InlineFragment) { - if let Some(type_condition) = inline_fragment.type_condition { - if type_condition == self.target_type { - self.push_reference(inline_fragment.spread_location, "inline-fragment"); - } - } - self.default_visit_inline_fragment(inline_fragment); - } -} - -fn print_analyze_find_references_text_report(report: &AnalyzeFindReferencesReport) { - if report.matches.is_empty() { - if let Some(target_field) = &report.target_field { - println!( - "Project {}: no references found for {}.{}.", - report.project, report.target_type, target_field - ); - } else { - println!( - "Project {}: no references found for {}.", - report.project, report.target_type - ); - } - return; - } - - println!( - "Project {}: {} match(es) found for {}{}.", - report.project, - report.match_count, - report.target_type, - report - .target_field - .as_ref() - .map(|field| format!(".{}", field)) - .unwrap_or_default() - ); - for reference in &report.matches { - println!( - " {} {} @ {}:{}:{}-{}:{}", - reference.kind, - reference.containing_definition, - reference.location.filename, - reference.location.start_line, - reference.location.start_column, - reference.location.end_line, - reference.location.end_column - ); - if let Some(snippet) = &reference.snippet { - println!(" line: {}", snippet); - } - } -} - -async fn handle_analyze_executable_definitions_command( - command: AnalyzeExecutableDefinitionsCommand, -) -> Result<(), Error> { - let mut config = get_config(command.config)?; - let project_name = ensure_single_project_config(&config)?; - set_project_flag(&mut config, command.projects)?; - - if command.min_selection_lines.is_none() && command.min_selection_depth.is_none() { - return Err(Error::AnalyzeError { - details: "At least one executable-definitions criterion must be provided." - .into(), - }); - } - - if command.min_selection_lines == Some(0) { - return Err(Error::AnalyzeError { - details: "min-selection-lines must be greater than zero.".into(), - }); - } - - if command.min_selection_depth == Some(0) { - return Err(Error::AnalyzeError { - details: "min-selection-depth must be greater than zero.".into(), - }); - } - - let json = command.json; - let min_selection_lines = command.min_selection_lines; - let min_selection_depth = command.min_selection_depth; - - let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; - if programs_by_project.is_empty() { - return Err(Error::AnalyzeError { - details: "No programs were produced by analyze.".to_string(), - }); - } - - let program = programs_by_project - .get(&project_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Project {project_name} was not built for analyze."), - })?; - analyze_project_executable_definitions( - project_name, - program.as_ref(), - &config.root_dir, - min_selection_lines, - min_selection_depth, - json, - )?; - Ok(()) -} - -fn analyze_project_executable_definitions( - project_name: ProjectName, - programs: &relay_transforms::Programs, - root_dir: &Path, - min_selection_lines: Option, - min_selection_depth: Option, - json: bool, -) -> Result<(), Error> { - let mut matches = Vec::new(); - - for operation in programs.source.operations() { - if let Some(match_entry) = analyze_executable_definition( - "operation", - operation.name.item.lookup().to_string(), - operation.name.location, - &operation.selections, - min_selection_lines, - min_selection_depth, - root_dir, - )? { - matches.push(match_entry); - } - } - - for fragment in programs.source.fragments() { - if let Some(match_entry) = analyze_executable_definition( - "fragment", - fragment.name.item.lookup().to_string(), - fragment.name.location, - &fragment.selections, - min_selection_lines, - min_selection_depth, - root_dir, - )? { - matches.push(match_entry); - } - } - - matches.sort_by(|a, b| { - a.kind - .cmp(&b.kind) - .then(a.name.cmp(&b.name)) - .then(a.selection_lines.cmp(&b.selection_lines)) - .then(a.selection_depth.cmp(&b.selection_depth)) - }); - - let report = AnalyzeExecutableDefinitionsReport { - project: project_name.to_string(), - min_selection_lines, - min_selection_depth, - total_operations: programs.source.operations().count(), - total_fragments: programs.source.fragments().count(), - match_count: matches.len(), - matches, - }; - - if json { - let json_output = - serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { - details: format!("Unable to serialize analyze output: {err}"), - })?; - println!("{}", json_output); - } else { - print_analyze_executable_definitions_text_report(&report) - } - - Ok(()) -} - -fn analyze_executable_definition( - kind: &'static str, - name: String, - name_location: Location, - selections: &[Selection], - min_selection_lines: Option, - min_selection_depth: Option, - root_dir: &Path, -) -> Result, Error> { - let (selection_span, selection_depth) = get_selection_span_and_depth(selections); - let source_location = name_location.source_location(); - let location_source_text = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for definition '{}'.", - source_location.path(), - name - ), - })? - .text_source() - .to_owned(); - - let (selection_span_for_location, selection_lines) = if let Some(span) = selection_span { - let range = location_source_text.to_span_range(span); - let selection_lines = (range.end.line - range.start.line + 1) as usize; - (span, selection_lines) - } else { - (name_location.span(), 0) - }; - - let range = location_source_text.to_span_range(selection_span_for_location); - let mut violations = Vec::new(); - if let Some(min_selection_lines) = min_selection_lines { - if selection_lines >= min_selection_lines { - violations.push(format!( - "selection body covers {} line(s), meets minimum line threshold of {}", - selection_lines, min_selection_lines - )); - } - } - if let Some(min_selection_depth) = min_selection_depth { - if selection_depth >= min_selection_depth { - violations.push(format!( - "selection depth is {}, meets minimum depth threshold of {}", - selection_depth, min_selection_depth - )); - } - } - - if violations.is_empty() { - return Ok(None); - } - - Ok(Some(AnalyzeExecutableDefinitionMatch { - kind: kind.to_string(), - name, - location: AnalyzeExecutableDefinitionLocation { - filename: source_location.path().to_string(), - start_line: range.start.line + 1, - start_column: range.start.character + 1, - end_line: range.end.line + 1, - end_column: range.end.character + 1, - }, - selection_lines, - selection_depth, - violations, - })) -} - -fn get_selection_span_and_depth(selections: &[Selection]) -> (Option, usize) { - let mut total_selection_span: Option = None; - let mut max_depth = 0; - - for selection in selections { - let selection_span = selection.location().span(); - let (depth, nested_span) = match selection { - Selection::ScalarField(_) | Selection::FragmentSpread(_) => (1, None), - Selection::LinkedField(linked_field) => { - let (nested_span, nested_depth) = get_selection_span_and_depth(&linked_field.selections); - (1 + nested_depth, Some(nested_span)) - } - Selection::InlineFragment(inline_fragment) => { - let (nested_span, nested_depth) = - get_selection_span_and_depth(&inline_fragment.selections); - (1 + nested_depth, Some(nested_span)) - } - Selection::Condition(condition) => { - let (nested_span, nested_depth) = get_selection_span_and_depth(&condition.selections); - (1 + nested_depth, Some(nested_span)) - } - }; - - let selection_span = match nested_span { - Some(nested_span) => maybe_merge_spans(Some(selection_span), nested_span), - None => Some(selection_span), - }; - total_selection_span = maybe_merge_spans(total_selection_span, selection_span); - max_depth = max(max_depth, depth); - } - - (total_selection_span, max_depth) -} - -fn maybe_merge_spans(first: Option, second: Option) -> Option { - match (first, second) { - (Some(first_span), Some(second_span)) => Some(Span::new( - min(first_span.start, second_span.start), - max(first_span.end, second_span.end), - )), - (Some(first_span), None) => Some(first_span), - (None, Some(second_span)) => Some(second_span), - (None, None) => None, - } -} - -fn print_analyze_executable_definitions_text_report( - report: &AnalyzeExecutableDefinitionsReport, -) { - if report.match_count == 0 { - println!( - "Project {}: no executable definitions match the provided criteria.", - report.project - ); - return; - } - - println!( - "Project {}: {} match(es) across {} operation(s), {} fragment(s).", - report.project, - report.match_count, - report.total_operations, - report.total_fragments - ); - - for match_entry in &report.matches { - println!( - " {} {} @ {}:{}:{}-{}:{} (lines={}, depth={})", - match_entry.kind, - match_entry.name, - match_entry.location.filename, - match_entry.location.start_line, - match_entry.location.start_column, - match_entry.location.end_line, - match_entry.location.end_column, - match_entry.selection_lines, - match_entry.selection_depth - ); - println!( - " violations: {}", - match_entry.violations.join(", ") - ); - } -} - -fn ensure_single_project_config(config: &relay_compiler::config::Config) -> Result { - if config.projects.len() != 1 { - return Err(Error::AnalyzeError { - details: "The analyze command currently only supports single-project configurations." - .into(), - }); - } - - let project_name = config - .projects - .keys() - .next() - .cloned() - .ok_or_else(|| Error::AnalyzeError { - details: "No project found in config.".into(), - })?; - Ok(project_name) -} - -async fn handle_analyze_schema_dce_command( - command: AnalyzeSchemaDCECommand, -) -> Result<(), Error> { - let mut config = get_config(command.config)?; - let project_name = ensure_single_project_config(&config)?; - let json = command.json; - set_project_flag(&mut config, command.projects)?; - - let (programs_by_project, _, _config) = get_programs(config, Arc::new(ConsoleLogger)).await; - if programs_by_project.is_empty() { - return Err(Error::AnalyzeError { - details: "No programs were produced by analyze.".to_string(), - }); - } - - let program = programs_by_project - .get(&project_name) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Project {project_name} was not built for analyze."), - })?; - analyze_project_dead_fields(project_name, program.as_ref(), json)?; - Ok(()) -} - -fn collect_referenced_field_ids_and_types( - program: &relay_transforms::Programs, -) -> ( - HashSet, - HashSet, - HashMap>, -) { - let mut referenced_fields = HashSet::default(); - let mut referenced_types: HashSet = HashSet::default(); - let mut referenced_union_members: HashMap> = HashMap::default(); - let mut fragments_by_name: HashMap = - HashMap::default(); - - let schema = &program.source.schema; - - for fragment in program.source.fragments() { - let name = fragment.name.item; - fragments_by_name.insert(name, fragment); - - referenced_types.insert(schema.get_type_name(fragment.type_condition).to_string()); - } - - for operation in program.source.operations() { - referenced_types.insert(schema.get_type_name(operation.type_).to_string()); - } - - let mut visited_fragments: HashSet<(FragmentDefinitionName, Type)> = HashSet::default(); - for operation in program.source.operations() { - let mut selection_types = vec![operation.type_]; - collect_referenced_field_ids_from_selections( - &operation.selections, - schema, - &fragments_by_name, - &mut referenced_types, - &mut referenced_fields, - &mut selection_types, - &mut referenced_union_members, - &mut visited_fragments, - ); - } - - ( - referenced_fields, - referenced_types, - referenced_union_members, - ) -} - -fn collect_referenced_field_ids_from_selections( - selections: &[Selection], - schema: &SDLSchema, - fragments_by_name: &HashMap, - referenced_types: &mut HashSet, - referenced_fields: &mut HashSet, - selection_types: &mut Vec, - referenced_union_members: &mut HashMap>, - visited_fragments: &mut HashSet<(FragmentDefinitionName, Type)>, -) { - for selection in selections { - match selection { - Selection::ScalarField(scalar_field) => { - referenced_fields.insert(scalar_field.definition.item); - } - Selection::LinkedField(linked_field) => { - let field = schema.field(linked_field.definition.item); - let field_type = field.type_.inner(); - if let Type::Union(union_id) = field_type { - referenced_types - .insert(schema.get_type_name(Type::Union(union_id)).to_string()); - } - referenced_fields.insert(linked_field.definition.item); - selection_types.push(field_type); - collect_referenced_field_ids_from_selections( - &linked_field.selections, - schema, - fragments_by_name, - referenced_types, - referenced_fields, - selection_types, - referenced_union_members, - visited_fragments, - ); - selection_types.pop(); - } - Selection::FragmentSpread(fragment_spread) => { - let fragment_name = fragment_spread.fragment.item; - if let Some(fragment) = fragments_by_name.get(&fragment_name) { - let parent_type = selection_types.last().copied(); - let type_condition = fragment.type_condition; - referenced_types.insert(schema.get_type_name(type_condition).to_string()); - if let Some(parent_type) = parent_type { - mark_referenced_union_member( - schema, - parent_type, - type_condition, - referenced_union_members, - ); - } - let key = (fragment_name, parent_type.unwrap_or_else(|| type_condition)); - if visited_fragments.insert(key) { - selection_types.push(type_condition); - collect_referenced_field_ids_from_selections( - &fragment.selections, - schema, - fragments_by_name, - referenced_types, - referenced_fields, - selection_types, - referenced_union_members, - visited_fragments, - ); - selection_types.pop(); - } - } - } - Selection::InlineFragment(inline_fragment) => { - if let Some(type_condition) = inline_fragment.type_condition { - referenced_types.insert(schema.get_type_name(type_condition).to_string()); - if let Some(parent_type) = selection_types.last().copied() { - mark_referenced_union_member( - schema, - parent_type, - type_condition, - referenced_union_members, - ); - } - selection_types.push(type_condition); - collect_referenced_field_ids_from_selections( - &inline_fragment.selections, - schema, - fragments_by_name, - referenced_types, - referenced_fields, - selection_types, - referenced_union_members, - visited_fragments, - ); - selection_types.pop(); - } else { - collect_referenced_field_ids_from_selections( - &inline_fragment.selections, - schema, - fragments_by_name, - referenced_types, - referenced_fields, - selection_types, - referenced_union_members, - visited_fragments, - ); - } - } - Selection::Condition(condition) => { - collect_referenced_field_ids_from_selections( - &condition.selections, - schema, - fragments_by_name, - referenced_types, - referenced_fields, - selection_types, - referenced_union_members, - visited_fragments, - ); - } - } - } -} - -fn mark_referenced_union_member( - schema: &SDLSchema, - parent_type: Type, - member_type: Type, - referenced_union_members: &mut HashMap>, -) { - match (parent_type, member_type) { - (Type::Union(union_id), Type::Object(object_id)) => { - if schema.union(union_id).members.contains(&object_id) { - referenced_union_members - .entry(schema.get_type_name(Type::Union(union_id)).to_string()) - .or_default() - .insert(schema.get_type_name(Type::Object(object_id)).to_string()); - } - } - _ => {} - } -} - -fn should_ignore_internal_field(field_name: &str) -> bool { - field_name == "id" || field_name.starts_with("__") -} - -fn analyze_project_dead_fields( - project_name: ProjectName, - programs: &relay_transforms::Programs, - json: bool, -) -> Result<(), Error> { - let (referenced_fields, mut referenced_types, referenced_union_members) = - collect_referenced_field_ids_and_types(programs); - let schema = &programs.source.schema; - - for field_id in referenced_fields.iter() { - let field = schema.field(*field_id); - if let Some(parent_type) = field.parent_type { - referenced_types.insert(schema.get_type_name(parent_type).to_string()); - } - } - - let mut dead_fields_by_type: BTreeMap = BTreeMap::new(); - - for object in schema.objects() { - let type_name = object.name.item.lookup().to_string(); - let type_description = if object.is_extension { - "schema extension object".to_owned() - } else { - "object".to_owned() - }; - let mut dead_fields: Vec = Vec::new(); - let mut existing_field_count = 0usize; - let dead_union_members: Vec = Vec::new(); - let type_referenced = referenced_types.contains(&type_name); - - for field_id in &object.fields { - let field: &Field = schema.field(*field_id); - let field_name = field.name.item.lookup().to_string(); - if should_ignore_internal_field(&field_name) { - continue; - } - if let Some(parent_type) = field.parent_type { - existing_field_count += 1; - debug_assert_eq!( - schema.get_type_name(parent_type).lookup().to_string(), - type_name - ); - if referenced_fields.contains(field_id) { - continue; - } - dead_fields.push(field_name); - } - } - - if !dead_fields.is_empty() || !type_referenced { - dead_fields_by_type.insert( - type_name.to_string(), - AnalyzeSchemaDceTypeReport { - type_name: type_name.to_string(), - type_referenced, - type_description: type_description.to_string(), - dead_fields, - dead_union_members, - existing_field_count, - }, - ); - } - } - - for interface in schema.interfaces() { - let type_name = interface.name.item.lookup().to_string(); - let type_description = if interface.is_extension { - "schema extension interface".to_owned() - } else { - "interface".to_owned() - }; - let mut dead_fields: Vec = Vec::new(); - let mut existing_field_count = 0usize; - let dead_union_members: Vec = Vec::new(); - let type_referenced = referenced_types.contains(&type_name); - - for field_id in &interface.fields { - let field: &Field = schema.field(*field_id); - let field_name = field.name.item.lookup().to_string(); - if should_ignore_internal_field(&field_name) { - continue; - } - if let Some(parent_type) = field.parent_type { - existing_field_count += 1; - if referenced_fields.contains(field_id) { - continue; - } - debug_assert_eq!( - schema.get_type_name(parent_type).lookup().to_string(), - type_name - ); - dead_fields.push(field_name); - } - } - - if !dead_fields.is_empty() || !type_referenced { - dead_fields_by_type.insert( - type_name.to_string(), - AnalyzeSchemaDceTypeReport { - type_name: type_name.to_string(), - type_referenced, - type_description: type_description.to_string(), - dead_fields, - dead_union_members, - existing_field_count, - }, - ); - } - } - - for union in schema.unions() { - let type_name = union.name.item.lookup().to_string(); - let type_description = if union.is_extension { - "schema extension union".to_owned() - } else { - "union".to_owned() - }; - let selected_members = referenced_union_members - .get(&type_name) - .cloned() - .unwrap_or_default(); - let mut dead_union_members: Vec = union - .members - .iter() - .map(|member_id| schema.get_type_name(Type::Object(*member_id)).to_string()) - .filter(|member_name| !selected_members.contains(member_name)) - .collect(); - let type_referenced = referenced_types.contains(&type_name); - - if !dead_union_members.is_empty() || !type_referenced { - dead_union_members.sort_unstable(); - dead_fields_by_type.insert( - type_name.to_string(), - AnalyzeSchemaDceTypeReport { - type_name: type_name.to_string(), - type_referenced, - type_description: type_description.to_string(), - dead_fields: Vec::new(), - dead_union_members, - existing_field_count: 0, - }, - ); - } - } - - let mut report = AnalyzeSchemaDceReport { - project: project_name.to_string(), - dead_fields: dead_fields_by_type - .into_values() - .map(|mut entry| { - entry.dead_fields.sort_unstable(); - entry.dead_union_members.sort_unstable(); - entry - }) - .collect(), - dead_field_count: 0, - dead_union_member_count: 0, - }; - - report.dead_field_count = report - .dead_fields - .iter() - .map(|entry| entry.dead_fields.len()) - .sum(); - report.dead_union_member_count = report - .dead_fields - .iter() - .map(|entry| entry.dead_union_members.len()) - .sum(); - - if json { - let json_output = - serde_json::to_string_pretty(&report).map_err(|err| Error::AnalyzeError { - details: format!("Unable to serialize analyze output: {err}"), - })?; - println!("{}", json_output); - } else { - print_analyze_schema_dce_text_report(report) - } - - Ok(()) -} - -fn print_analyze_schema_dce_text_report(report: AnalyzeSchemaDceReport) { - if report.dead_fields.is_empty() { - println!( - "Project {}: no dead schema fields or union members found", - report.project - ); - return; - } - - println!( - "Project {}: dead schema items by type ({} dead field(s), {} unselected union member(s), {} dead type(s))", - report.project, - report.dead_field_count, - report.dead_union_member_count, - report.dead_fields.len() - ); - for entry in report.dead_fields { - if entry.type_referenced { - println!(" {} ({})", entry.type_name, entry.type_description); - } else { - println!( - " {} ({}): not referenced by any operation", - entry.type_name, entry.type_description - ); - } - - if entry.type_referenced && !entry.dead_fields.is_empty() { - println!( - " Dead fields ({}/{}):", - entry.dead_fields.len(), - entry.existing_field_count - ); - for field in &entry.dead_fields { - println!(" {field}"); - } - } - - if entry.type_referenced && !entry.dead_union_members.is_empty() { - println!(" Unselected union members:"); - for member in &entry.dead_union_members { - println!(" {member}"); - } - } - } -} diff --git a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs new file mode 100644 index 0000000000000..77164ea371854 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs @@ -0,0 +1,341 @@ +use std::cmp::{max, min}; +use std::path::Path; +use std::sync::Arc; + +use clap::Parser; +use common::{ConsoleLogger, Location, Span}; +use graphql_ir::Selection; +use intern::Lookup; +use relay_compiler::{source_for_location, get_programs, FsSourceReader, ProjectName}; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find operations and fragments by selection size/depth." +)] +pub(crate) struct AnalyzeExecutableDefinitionsCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Minimum number of line breaks covered by selections. + #[clap(long = "min-selection-lines")] + min_selection_lines: Option, + + /// Minimum depth of selection nesting. + #[clap(long = "min-selection-depth")] + min_selection_depth: Option, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionsReport { + project: String, + min_selection_lines: Option, + min_selection_depth: Option, + total_operations: usize, + total_fragments: usize, + matches: Vec, + match_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionMatch { + kind: String, + name: String, + location: AnalyzeExecutableDefinitionLocation, + selection_lines: usize, + selection_depth: usize, + violations: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeExecutableDefinitionLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +pub(crate) async fn handle_analyze_executable_definitions_command( + command: AnalyzeExecutableDefinitionsCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + set_project_flag(&mut config, command.projects)?; + + if command.min_selection_lines.is_none() && command.min_selection_depth.is_none() { + return Err(Error::AnalyzeError { + details: "At least one executable-definitions criterion must be provided." + .into(), + }); + } + + if command.min_selection_lines == Some(0) { + return Err(Error::AnalyzeError { + details: "min-selection-lines must be greater than zero.".into(), + }); + } + + if command.min_selection_depth == Some(0) { + return Err(Error::AnalyzeError { + details: "min-selection-depth must be greater than zero.".into(), + }); + } + + let json = command.json; + let min_selection_lines = command.min_selection_lines; + let min_selection_depth = command.min_selection_depth; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_executable_definitions( + project_name, + program.as_ref(), + &config.root_dir, + min_selection_lines, + min_selection_depth, + json, + )?; + Ok(()) +} + +fn analyze_project_executable_definitions( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + min_selection_lines: Option, + min_selection_depth: Option, + json: bool, +) -> Result<(), Error> { + let mut matches = Vec::new(); + + for operation in programs.source.operations() { + if let Some(match_entry) = analyze_executable_definition( + "operation", + operation.name.item.lookup().to_string(), + operation.name.location, + &operation.selections, + min_selection_lines, + min_selection_depth, + root_dir, + )? { + matches.push(match_entry); + } + } + + for fragment in programs.source.fragments() { + if let Some(match_entry) = analyze_executable_definition( + "fragment", + fragment.name.item.lookup().to_string(), + fragment.name.location, + &fragment.selections, + min_selection_lines, + min_selection_depth, + root_dir, + )? { + matches.push(match_entry); + } + } + + matches.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then(a.name.cmp(&b.name)) + .then(a.selection_lines.cmp(&b.selection_lines)) + .then(a.selection_depth.cmp(&b.selection_depth)) + }); + + let report = AnalyzeExecutableDefinitionsReport { + project: project_name.to_string(), + min_selection_lines, + min_selection_depth, + total_operations: programs.source.operations().count(), + total_fragments: programs.source.fragments().count(), + match_count: matches.len(), + matches, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_executable_definitions_text_report(&report) + } + + Ok(()) +} + +fn analyze_executable_definition( + kind: &'static str, + name: String, + name_location: Location, + selections: &[Selection], + min_selection_lines: Option, + min_selection_depth: Option, + root_dir: &Path, +) -> Result, Error> { + let (selection_span, selection_depth) = get_selection_span_and_depth(selections); + let source_location = name_location.source_location(); + let location_source_text = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for definition '{}'.", + source_location.path(), + name + ), + })? + .text_source() + .to_owned(); + + let (selection_span_for_location, selection_lines) = if let Some(span) = selection_span { + let range = location_source_text.to_span_range(span); + let selection_lines = (range.end.line - range.start.line + 1) as usize; + (span, selection_lines) + } else { + (name_location.span(), 0) + }; + + let range = location_source_text.to_span_range(selection_span_for_location); + let mut violations = Vec::new(); + if let Some(min_selection_lines) = min_selection_lines { + if selection_lines >= min_selection_lines { + violations.push(format!( + "selection body covers {} line(s), meets minimum line threshold of {}", + selection_lines, min_selection_lines + )); + } + } + if let Some(min_selection_depth) = min_selection_depth { + if selection_depth >= min_selection_depth { + violations.push(format!( + "selection depth is {}, meets minimum depth threshold of {}", + selection_depth, min_selection_depth + )); + } + } + + if violations.is_empty() { + return Ok(None); + } + + Ok(Some(AnalyzeExecutableDefinitionMatch { + kind: kind.to_string(), + name, + location: AnalyzeExecutableDefinitionLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }, + selection_lines, + selection_depth, + violations, + })) +} + +fn get_selection_span_and_depth(selections: &[Selection]) -> (Option, usize) { + let mut total_selection_span: Option = None; + let mut max_depth = 0; + + for selection in selections { + let selection_span = selection.location().span(); + let (depth, nested_span) = match selection { + Selection::ScalarField(_) | Selection::FragmentSpread(_) => (1, None), + Selection::LinkedField(linked_field) => { + let (nested_span, nested_depth) = get_selection_span_and_depth(&linked_field.selections); + (1 + nested_depth, Some(nested_span)) + } + Selection::InlineFragment(inline_field) => { + let (nested_span, nested_depth) = + get_selection_span_and_depth(&inline_field.selections); + (1 + nested_depth, Some(nested_span)) + } + Selection::Condition(condition) => { + let (nested_span, nested_depth) = get_selection_span_and_depth(&condition.selections); + (1 + nested_depth, Some(nested_span)) + } + }; + + let selection_span = match nested_span { + Some(nested_span) => maybe_merge_spans(Some(selection_span), nested_span), + None => Some(selection_span), + }; + total_selection_span = maybe_merge_spans(total_selection_span, selection_span); + max_depth = max(max_depth, depth); + } + + (total_selection_span, max_depth) +} + +fn maybe_merge_spans(first: Option, second: Option) -> Option { + match (first, second) { + (Some(first_span), Some(second_span)) => Some(Span::new( + min(first_span.start, second_span.start), + max(first_span.end, second_span.end), + )), + (Some(first_span), None) => Some(first_span), + (None, Some(second_span)) => Some(second_span), + (None, None) => None, + } +} + +fn print_analyze_executable_definitions_text_report( + report: &AnalyzeExecutableDefinitionsReport, +) { + if report.match_count == 0 { + println!( + "Project {}: no executable definitions match the provided criteria.", + report.project + ); + return; + } + + println!( + "Project {}: {} match(es) across {} operation(s), {} fragment(s).", + report.project, + report.match_count, + report.total_operations, + report.total_fragments + ); + + for match_entry in &report.matches { + println!( + " {} {} @ {}:{}:{}-{}:{} (lines={}, depth={})", + match_entry.kind, + match_entry.name, + match_entry.location.filename, + match_entry.location.start_line, + match_entry.location.start_column, + match_entry.location.end_line, + match_entry.location.end_column, + match_entry.selection_lines, + match_entry.selection_depth + ); + println!(" violations: {}", match_entry.violations.join(", ")); + } +} diff --git a/compiler/crates/relay-bin/src/analyze/find_references.rs b/compiler/crates/relay-bin/src/analyze/find_references.rs new file mode 100644 index 0000000000000..eb2b069f70a8b --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/find_references.rs @@ -0,0 +1,405 @@ +use std::path::Path; +use std::sync::Arc; + +use clap::Parser; +use common::{ConsoleLogger, Location}; +use graphql_ir::FragmentDefinition; +use graphql_ir::Visitor; +use intern::string_key::Intern; +use relay_compiler::source_for_location; +use relay_compiler::ProjectName; +use relay_compiler::{FsSourceReader, get_programs}; +use relay_lsp::find_field_usages::get_usages; +use schema::Type; +use schema::Schema; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find references for schema type/field paths." +)] +pub(crate) struct AnalyzeFindReferencesCommand { + /// A schema path: either `Type` or `Type.field`. + payload: String, + + /// Include the full line containing the reference for each match. + #[clap(long = "with-snippet")] + with_snippet: bool, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesReport { + project: String, + target_type: String, + target_field: Option, + with_snippet: bool, + matches: Vec, + match_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesMatch { + kind: String, + containing_definition: String, + location: AnalyzeFindReferencesLocation, + snippet: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFindReferencesLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +#[derive(Debug)] +struct AnalyzeFindReferenceItem { + kind: String, + container: String, + location: Location, +} + +struct AnalyzeFindReferencesPayload { + type_name: String, + field_name: Option, +} + +pub(crate) async fn handle_analyze_find_references_command( + command: AnalyzeFindReferencesCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let payload = parse_find_references_payload(&command.payload)?; + let with_snippet = command.with_snippet; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_find_references( + project_name, + program.as_ref(), + &config.root_dir, + payload, + with_snippet, + json, + )?; + Ok(()) +} + +fn parse_find_references_payload(payload: &str) -> Result { + let payload = payload.trim(); + if payload.is_empty() { + return Err(Error::AnalyzeError { + details: "A payload is required, e.g. `User` or `User.name`.".to_string(), + }); + } + + let parts: Vec<&str> = payload.split('.').map(str::trim).collect(); + match parts.as_slice() { + [] => Err(Error::AnalyzeError { + details: "A payload is required, e.g. `User` or `User.name`.".to_string(), + }), + [type_name] => { + if type_name.is_empty() { + return Err(Error::AnalyzeError { + details: "Expected a type name, e.g. `User`.".to_string(), + }); + } + Ok(AnalyzeFindReferencesPayload { + type_name: (*type_name).to_string(), + field_name: None, + }) + } + [type_name, field_name] => { + if type_name.is_empty() || field_name.is_empty() { + return Err(Error::AnalyzeError { + details: "Expected payload in the format `Type` or `Type.field`." + .to_string(), + }); + } + Ok(AnalyzeFindReferencesPayload { + type_name: (*type_name).to_string(), + field_name: Some((*field_name).to_string()), + }) + } + _ => Err(Error::AnalyzeError { + details: "Expected payload in the format `Type` or `Type.field`.".to_string(), + }), + } +} + +fn analyze_project_find_references( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + payload: AnalyzeFindReferencesPayload, + with_snippet: bool, + json: bool, +) -> Result<(), Error> { + let schema = &programs.source.schema; + let type_name = payload.type_name.clone(); + let type_name_key = type_name.clone().intern(); + let type_ = schema + .get_type(type_name_key) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Type `{}` was not found in the schema.", type_name), + })?; + + let mut items = Vec::new(); + if let Some(field_name) = payload.field_name.as_deref() { + let usages = get_usages(&programs.source, schema, type_name_key, field_name.intern()) + .map_err(|err| Error::AnalyzeError { + details: format!("Unable to find references: {err:?}"), + })?; + for (label, location) in usages { + items.push(AnalyzeFindReferenceItem { + kind: "field".to_string(), + container: normalize_containing_definition(&label), + location, + }); + } + } else { + items = collect_type_condition_references(programs, type_); + } + + let mut matches = items + .into_iter() + .map(|item| { + let location = location_from_reference(root_dir, &item.location)?; + let snippet = if with_snippet { + Some(reference_line_snippet(root_dir, &item.location)?) + } else { + None + }; + Ok(AnalyzeFindReferencesMatch { + kind: item.kind, + containing_definition: item.container, + location, + snippet, + }) + }) + .collect::, Error>>()?; + + matches.sort_by(|a, b| { + a.location + .filename + .cmp(&b.location.filename) + .then(a.location.start_line.cmp(&b.location.start_line)) + .then(a.location.start_column.cmp(&b.location.start_column)) + .then(a.location.end_line.cmp(&b.location.end_line)) + .then(a.location.end_column.cmp(&b.location.end_column)) + .then(a.containing_definition.cmp(&b.containing_definition)) + }); + + let report = AnalyzeFindReferencesReport { + project: project_name.to_string(), + target_type: payload.type_name, + target_field: payload.field_name, + with_snippet, + match_count: matches.len(), + matches, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_find_references_text_report(&report); + } + Ok(()) +} + +fn collect_type_condition_references( + programs: &relay_transforms::Programs, + target_type: Type, +) -> Vec { + let mut visitor = TypeReferenceFinder { + target_type, + container: None, + items: Vec::new(), + }; + visitor.visit_program(&programs.source); + visitor.items +} + +fn location_from_reference( + root_dir: &Path, + location: &Location, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for reference.", + source_location.path() + ), + })?; + let source_text = source.text_source(); + let range = source_text.to_span_range(location.span()); + + Ok(AnalyzeFindReferencesLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }) +} + +fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for snippet lookup.", + source_location.path() + ), + })?; + let text_source = source.text_source(); + let range = text_source.to_span_range(location.span()); + let local_line = range + .start + .line + .checked_sub(text_source.line_index as u32) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to resolve snippet line for {}.", source_location.path()), + })?; + text_source + .text + .lines() + .nth(local_line as usize) + .map(|line| line.to_string()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to resolve snippet line for {}:{}.", + source_location.path(), + range.start.line + 1 + ), + }) +} + +fn normalize_containing_definition(label: &str) -> String { + label.split(" - ").next().unwrap_or(label).to_string() +} + +struct TypeReferenceFinder { + target_type: Type, + container: Option, + items: Vec, +} + +impl TypeReferenceFinder { + fn push_reference(&mut self, location: Location, kind: &str) { + if let Some(container) = &self.container { + self.items.push(AnalyzeFindReferenceItem { + kind: kind.to_string(), + container: container.clone(), + location, + }); + } + } +} + +impl Visitor for TypeReferenceFinder { + const NAME: &'static str = "TypeReferenceFinder"; + const VISIT_ARGUMENTS: bool = false; + const VISIT_DIRECTIVES: bool = false; + + fn visit_operation(&mut self, operation: &graphql_ir::OperationDefinition) { + let prev_container = self.container.replace(operation.name.item.0.to_string()); + self.default_visit_operation(operation); + self.container = prev_container; + } + + fn visit_fragment(&mut self, fragment: &FragmentDefinition) { + let prev_container = self.container.replace(fragment.name.item.0.to_string()); + if fragment.type_condition == self.target_type { + self.push_reference(fragment.name.location, "fragment"); + } + self.default_visit_fragment(fragment); + self.container = prev_container; + } + + fn visit_inline_fragment(&mut self, inline_fragment: &graphql_ir::InlineFragment) { + if let Some(type_condition) = inline_fragment.type_condition { + if type_condition == self.target_type { + self.push_reference(inline_fragment.spread_location, "inline-fragment"); + } + } + self.default_visit_inline_fragment(inline_fragment); + } +} + +fn print_analyze_find_references_text_report(report: &AnalyzeFindReferencesReport) { + if report.matches.is_empty() { + if let Some(target_field) = &report.target_field { + println!( + "Project {}: no references found for {}.{}.", + report.project, report.target_type, target_field + ); + } else { + println!( + "Project {}: no references found for {}.", + report.project, report.target_type + ); + } + return; + } + + println!( + "Project {}: {} match(es) found for {}{}.", + report.project, + report.match_count, + report.target_type, + report + .target_field + .as_ref() + .map(|field| format!(".{}", field)) + .unwrap_or_default() + ); + for reference in &report.matches { + println!( + " {} {} @ {}:{}:{}-{}:{}", + reference.kind, + reference.containing_definition, + reference.location.filename, + reference.location.start_line, + reference.location.start_column, + reference.location.end_line, + reference.location.end_column + ); + if let Some(snippet) = &reference.snippet { + println!(" line: {}", snippet); + } + } +} diff --git a/compiler/crates/relay-bin/src/analyze/mod.rs b/compiler/crates/relay-bin/src/analyze/mod.rs new file mode 100644 index 0000000000000..b1f25af1e163d --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/mod.rs @@ -0,0 +1,58 @@ +use clap::Parser; + +mod executable_definitions; +mod find_references; +mod print_operation; +mod schema_dce; +mod utils; + +use crate::errors::Error; + +use executable_definitions::AnalyzeExecutableDefinitionsCommand; +use find_references::AnalyzeFindReferencesCommand; +use print_operation::AnalyzePrintOperationCommand; +use schema_dce::AnalyzeSchemaDceCommand; + +#[derive(Parser)] +#[clap(rename_all = "snake_case", about = "Schema analysis helpers.")] +pub struct AnalyzeCommand { + /// Schema analysis commands. + #[clap(subcommand)] + command: AnalyzeSubcommand, +} + +#[derive(clap::Subcommand)] +enum AnalyzeSubcommand { + /// Find references for a schema path. + #[clap(name = "find-references")] + FindReferences(AnalyzeFindReferencesCommand), + + /// Print the full text for a named GraphQL operation. + #[clap(name = "print-operation")] + PrintOperation(AnalyzePrintOperationCommand), + + /// Find unused schema fields in Relay operations. + #[clap(name = "schema-dce")] + SchemaDce(AnalyzeSchemaDceCommand), + + /// Find operations/fragments by selection size/depth. + #[clap(name = "executable-definitions")] + ExecutableDefinitions(AnalyzeExecutableDefinitionsCommand), +} + +pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error> { + match command.command { + AnalyzeSubcommand::FindReferences(command) => { + find_references::handle_analyze_find_references_command(command).await + } + AnalyzeSubcommand::PrintOperation(command) => { + print_operation::handle_analyze_print_operation_command(command).await + } + AnalyzeSubcommand::SchemaDce(command) => { + schema_dce::handle_analyze_schema_dce_command(command).await + } + AnalyzeSubcommand::ExecutableDefinitions(command) => { + executable_definitions::handle_analyze_executable_definitions_command(command).await + } + } +} diff --git a/compiler/crates/relay-bin/src/analyze/print_operation.rs b/compiler/crates/relay-bin/src/analyze/print_operation.rs new file mode 100644 index 0000000000000..ccb87388eb8c0 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/print_operation.rs @@ -0,0 +1,201 @@ +use std::sync::Arc; + +use clap::Parser; +use common::ConsoleLogger; +use intern::string_key::Intern; +use graphql_ir::FragmentDefinition; +use graphql_ir::OperationDefinitionName; +use graphql_ir::Selection; +use graphql_text_printer::print_full_operation; +use relay_compiler::{get_programs, ProjectName}; +use relay_transforms::apply_transforms; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Print the full text for a named GraphQL operation." +)] +pub(crate) struct AnalyzePrintOperationCommand { + /// The name of the operation to print. + operation: String, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzePrintOperationReport { + project: String, + operation_name: String, + operation_text: String, +} + +pub(crate) async fn handle_analyze_print_operation_command( + command: AnalyzePrintOperationCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let operation_name = command.operation; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_print_operation( + project_name, + program.as_ref(), + &config, + operation_name, + json, + )?; + Ok(()) +} + +fn analyze_project_print_operation( + project_name: ProjectName, + programs: &relay_transforms::Programs, + config: &relay_compiler::config::Config, + operation_name: String, + json: bool, +) -> Result<(), Error> { + let operation_name = OperationDefinitionName(operation_name.clone().intern()); + let operation = programs + .source + .operation(operation_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Operation `{}` was not found in source documents.", + operation_name + ), + })?; + + let project_config = config + .enabled_projects() + .find(|project_config| project_config.name == project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to get project config for project {project_name}."), + })?; + + let operation_only_program = get_operation_only_program( + Arc::clone(&operation), + vec![], + &programs.source, + ); + let transformed_programs = apply_transforms( + project_config, + Arc::new(operation_only_program), + Default::default(), + Arc::new(ConsoleLogger), + None, + config.custom_transforms.as_ref(), + config.transferrable_refetchable_query_directives.clone(), + ) + .map_err(|errors| Error::AnalyzeError { + details: format!( + "Unable to run transforms for operation `{}`: {errors:?}", + operation_name + ), + })?; + + let operation_to_print = transformed_programs + .operation_text + .operation(operation_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to print operation `{}` after transforms.", + operation_name + ), + })?; + + let report = AnalyzePrintOperationReport { + project: project_name.to_string(), + operation_name: operation_name.to_string(), + operation_text: print_full_operation( + &transformed_programs.operation_text, + operation_to_print, + Default::default(), + ), + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_print_operation_text_report(&report); + } + Ok(()) +} + +fn get_operation_only_program( + operation: std::sync::Arc, + fragments: Vec>, + program: &graphql_ir::Program, +) -> graphql_ir::Program { + use std::collections::HashSet; + + let mut selections_to_visit: Vec<&[graphql_ir::Selection]> = vec![&operation.selections]; + let mut next_program = graphql_ir::Program::new(program.schema.clone()); + let mut visited_fragments: HashSet = HashSet::default(); + + next_program.insert_operation(Arc::clone(&operation)); + for fragment in fragments.iter() { + selections_to_visit.push(&fragment.selections); + next_program.insert_fragment(Arc::clone(fragment)); + } + + while let Some(current_selections) = selections_to_visit.pop() { + for selection in current_selections { + match selection { + graphql_ir::Selection::FragmentSpread(spread) => { + if visited_fragments.contains(&spread.fragment.item) { + continue; + } + visited_fragments.insert(spread.fragment.item); + if let Some(fragment) = program.fragment(spread.fragment.item) { + selections_to_visit.push(&fragment.selections); + next_program.insert_fragment(Arc::clone(&fragment)); + } + } + Selection::Condition(condition) => { + selections_to_visit.push(&condition.selections); + } + Selection::LinkedField(linked_field) => { + selections_to_visit.push(&linked_field.selections); + } + Selection::InlineFragment(inline_fragment) => { + selections_to_visit.push(&inline_fragment.selections); + } + Selection::ScalarField(_) => {} + } + } + } + + next_program +} + +fn print_analyze_print_operation_text_report(report: &AnalyzePrintOperationReport) { + println!("Project {}: operation {}.", report.project, report.operation_name); + println!("{}", report.operation_text); +} diff --git a/compiler/crates/relay-bin/src/analyze/schema_dce.rs b/compiler/crates/relay-bin/src/analyze/schema_dce.rs new file mode 100644 index 0000000000000..462b400ebb0e5 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/schema_dce.rs @@ -0,0 +1,496 @@ +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; + +use clap::Parser; +use graphql_ir::FragmentDefinition; +use intern::Lookup; +use relay_compiler::get_programs; +use relay_compiler::ProjectName; +use serde::Serialize; +use schema::Field; +use schema::FieldID; +use schema::SDLSchema; +use schema::Schema; +use schema::Type; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find unused schema fields in Relay operations." +)] +pub(crate) struct AnalyzeSchemaDceCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeSchemaDceTypeReport { + type_name: String, + type_description: String, + type_referenced: bool, + dead_fields: Vec, + dead_union_members: Vec, + #[serde(skip)] + existing_field_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeSchemaDceReport { + project: String, + dead_fields: Vec, + dead_field_count: usize, + dead_union_member_count: usize, +} + +pub(crate) async fn handle_analyze_schema_dce_command( + command: AnalyzeSchemaDceCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, _config) = get_programs(config, Arc::new(common::ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_dead_fields(project_name, program.as_ref(), json)?; + Ok(()) +} + +fn collect_referenced_field_ids_and_types( + program: &relay_transforms::Programs, +) -> ( + HashSet, + HashSet, + HashMap>, +) { + let mut referenced_fields = HashSet::default(); + let mut referenced_types: HashSet = HashSet::default(); + let mut referenced_union_members: HashMap> = HashMap::default(); + let mut fragments_by_name: HashMap = + HashMap::default(); + + let schema = &program.source.schema; + + for fragment in program.source.fragments() { + let name = fragment.name.item; + fragments_by_name.insert(name, fragment); + + referenced_types.insert(schema.get_type_name(fragment.type_condition).to_string()); + } + + for operation in program.source.operations() { + referenced_types.insert(schema.get_type_name(operation.type_).to_string()); + } + + let mut visited_fragments: HashSet<(graphql_ir::FragmentDefinitionName, Type)> = HashSet::default(); + for operation in program.source.operations() { + let mut selection_types = vec![operation.type_]; + collect_referenced_field_ids_from_selections( + &operation.selections, + schema, + &fragments_by_name, + &mut referenced_types, + &mut referenced_fields, + &mut selection_types, + &mut referenced_union_members, + &mut visited_fragments, + ); + } + + ( + referenced_fields, + referenced_types, + referenced_union_members, + ) +} + +fn collect_referenced_field_ids_from_selections( + selections: &[graphql_ir::Selection], + schema: &SDLSchema, + fragments_by_name: &HashMap, + referenced_types: &mut HashSet, + referenced_fields: &mut HashSet, + selection_types: &mut Vec, + referenced_union_members: &mut HashMap>, + visited_fragments: &mut HashSet<(graphql_ir::FragmentDefinitionName, Type)>, +) { + for selection in selections { + match selection { + graphql_ir::Selection::ScalarField(scalar_field) => { + referenced_fields.insert(scalar_field.definition.item); + } + graphql_ir::Selection::LinkedField(linked_field) => { + let field = schema.field(linked_field.definition.item); + let field_type = field.type_.inner(); + if let Type::Union(union_id) = field_type { + referenced_types.insert(schema.get_type_name(Type::Union(union_id)).to_string()); + } + referenced_fields.insert(linked_field.definition.item); + selection_types.push(field_type); + collect_referenced_field_ids_from_selections( + &linked_field.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } + graphql_ir::Selection::FragmentSpread(fragment_spread) => { + let fragment_name = fragment_spread.fragment.item; + if let Some(fragment) = fragments_by_name.get(&fragment_name) { + let parent_type = selection_types.last().copied(); + let type_condition = fragment.type_condition; + referenced_types.insert(schema.get_type_name(type_condition).to_string()); + if let Some(parent_type) = parent_type { + mark_referenced_union_member( + schema, + parent_type, + type_condition, + referenced_union_members, + ); + } + let key = (fragment_name, parent_type.unwrap_or_else(|| type_condition)); + if visited_fragments.insert(key) { + selection_types.push(type_condition); + collect_referenced_field_ids_from_selections( + &fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } + } + } + graphql_ir::Selection::InlineFragment(inline_fragment) => { + if let Some(type_condition) = inline_fragment.type_condition { + referenced_types.insert(schema.get_type_name(type_condition).to_string()); + if let Some(parent_type) = selection_types.last().copied() { + mark_referenced_union_member( + schema, + parent_type, + type_condition, + referenced_union_members, + ); + } + selection_types.push(type_condition); + collect_referenced_field_ids_from_selections( + &inline_fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + selection_types.pop(); + } else { + collect_referenced_field_ids_from_selections( + &inline_fragment.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + } + } + graphql_ir::Selection::Condition(condition) => { + collect_referenced_field_ids_from_selections( + &condition.selections, + schema, + fragments_by_name, + referenced_types, + referenced_fields, + selection_types, + referenced_union_members, + visited_fragments, + ); + } + } + } +} + +fn mark_referenced_union_member( + schema: &SDLSchema, + parent_type: Type, + member_type: Type, + referenced_union_members: &mut HashMap>, +) { + match (parent_type, member_type) { + (Type::Union(union_id), Type::Object(object_id)) => { + if schema.union(union_id).members.contains(&object_id) { + referenced_union_members + .entry(schema.get_type_name(Type::Union(union_id)).to_string()) + .or_default() + .insert(schema.get_type_name(Type::Object(object_id)).to_string()); + } + } + _ => {} + } +} + +fn should_ignore_internal_field(field_name: &str) -> bool { + field_name == "id" || field_name.starts_with("__") +} + +fn analyze_project_dead_fields( + project_name: ProjectName, + programs: &relay_transforms::Programs, + json: bool, +) -> Result<(), Error> { + let (referenced_fields, mut referenced_types, referenced_union_members) = + collect_referenced_field_ids_and_types(programs); + let schema = &programs.source.schema; + + for field_id in referenced_fields.iter() { + let field = schema.field(*field_id); + if let Some(parent_type) = field.parent_type { + referenced_types.insert(schema.get_type_name(parent_type).to_string()); + } + } + + let mut dead_fields_by_type: BTreeMap = BTreeMap::new(); + + for object in schema.objects() { + let type_name = object.name.item.lookup().to_string(); + let type_description = if object.is_extension { + "schema extension object".to_owned() + } else { + "object".to_owned() + }; + let mut dead_fields: Vec = Vec::new(); + let mut existing_field_count = 0usize; + let dead_union_members: Vec = Vec::new(); + let type_referenced = referenced_types.contains(&type_name); + + for field_id in &object.fields { + let field: &Field = schema.field(*field_id); + let field_name = field.name.item.lookup().to_string(); + if should_ignore_internal_field(&field_name) { + continue; + } + if let Some(parent_type) = field.parent_type { + existing_field_count += 1; + debug_assert_eq!( + schema.get_type_name(parent_type).lookup().to_string(), + type_name + ); + if referenced_fields.contains(field_id) { + continue; + } + dead_fields.push(field_name); + } + } + + if !dead_fields.is_empty() || !type_referenced { + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields, + dead_union_members, + existing_field_count, + }, + ); + } + } + + for interface in schema.interfaces() { + let type_name = interface.name.item.lookup().to_string(); + let type_description = if interface.is_extension { + "schema extension interface".to_owned() + } else { + "interface".to_owned() + }; + let mut dead_fields: Vec = Vec::new(); + let mut existing_field_count = 0usize; + let dead_union_members: Vec = Vec::new(); + let type_referenced = referenced_types.contains(&type_name); + + for field_id in &interface.fields { + let field: &Field = schema.field(*field_id); + let field_name = field.name.item.lookup().to_string(); + if should_ignore_internal_field(&field_name) { + continue; + } + if let Some(parent_type) = field.parent_type { + existing_field_count += 1; + if referenced_fields.contains(field_id) { + continue; + } + debug_assert_eq!( + schema.get_type_name(parent_type).lookup().to_string(), + type_name + ); + dead_fields.push(field_name); + } + } + + if !dead_fields.is_empty() || !type_referenced { + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields, + dead_union_members, + existing_field_count, + }, + ); + } + } + + for union in schema.unions() { + let type_name = union.name.item.lookup().to_string(); + let type_description = if union.is_extension { + "schema extension union".to_owned() + } else { + "union".to_owned() + }; + let selected_members = referenced_union_members + .get(&type_name) + .cloned() + .unwrap_or_default(); + let mut dead_union_members: Vec = union + .members + .iter() + .map(|member_id| schema.get_type_name(Type::Object(*member_id)).to_string()) + .filter(|member_name| !selected_members.contains(member_name)) + .collect(); + let type_referenced = referenced_types.contains(&type_name); + + if !dead_union_members.is_empty() || !type_referenced { + dead_union_members.sort_unstable(); + dead_fields_by_type.insert( + type_name.to_string(), + AnalyzeSchemaDceTypeReport { + type_name: type_name.to_string(), + type_referenced, + type_description: type_description.to_string(), + dead_fields: Vec::new(), + dead_union_members, + existing_field_count: 0, + }, + ); + } + } + + let mut report = AnalyzeSchemaDceReport { + project: project_name.to_string(), + dead_fields: dead_fields_by_type + .into_values() + .map(|mut entry| { + entry.dead_fields.sort_unstable(); + entry.dead_union_members.sort_unstable(); + entry + }) + .collect(), + dead_field_count: 0, + dead_union_member_count: 0, + }; + + report.dead_field_count = report + .dead_fields + .iter() + .map(|entry| entry.dead_fields.len()) + .sum(); + report.dead_union_member_count = report + .dead_fields + .iter() + .map(|entry| entry.dead_union_members.len()) + .sum(); + + if json { + print_json_report(&report)?; + } else { + print_analyze_schema_dce_text_report(report) + } + + Ok(()) +} + +fn print_analyze_schema_dce_text_report(report: AnalyzeSchemaDceReport) { + if report.dead_fields.is_empty() { + println!( + "Project {}: no dead schema fields or union members found", + report.project + ); + return; + } + + println!( + "Project {}: dead schema items by type ({} dead field(s), {} unselected union member(s), {} dead type(s))", + report.project, + report.dead_field_count, + report.dead_union_member_count, + report.dead_fields.len() + ); + for entry in report.dead_fields { + if entry.type_referenced { + println!(" {} ({})", entry.type_name, entry.type_description); + } else { + println!( + " {} ({}): not referenced by any operation", + entry.type_name, entry.type_description + ); + } + + if entry.type_referenced && !entry.dead_fields.is_empty() { + println!( + " Dead fields ({}/{}):", + entry.dead_fields.len(), + entry.existing_field_count + ); + for field in &entry.dead_fields { + println!(" {field}"); + } + } + + if entry.type_referenced && !entry.dead_union_members.is_empty() { + println!(" Unselected union members:"); + for member in &entry.dead_union_members { + println!(" {member}"); + } + } + } +} diff --git a/compiler/crates/relay-bin/src/analyze/utils.rs b/compiler/crates/relay-bin/src/analyze/utils.rs new file mode 100644 index 0000000000000..036bec91d52aa --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/utils.rs @@ -0,0 +1,32 @@ +use relay_compiler::config::Config; +use relay_compiler::ProjectName; +use serde::Serialize; + +use crate::errors::Error; + +pub(crate) fn ensure_single_project_config(config: &Config) -> Result { + if config.projects.len() != 1 { + return Err(Error::AnalyzeError { + details: "The analyze command currently only supports single-project configurations." + .into(), + }); + } + + let project_name = config + .projects + .keys() + .next() + .cloned() + .ok_or_else(|| Error::AnalyzeError { + details: "No project found in config.".into(), + })?; + Ok(project_name) +} + +pub(crate) fn print_json_report(report: &T) -> Result<(), Error> { + let json_output = serde_json::to_string_pretty(report).map_err(|err| Error::AnalyzeError { + details: format!("Unable to serialize analyze output: {err}"), + })?; + println!("{}", json_output); + Ok(()) +} From bfcf221878970429b587a6fe409ba0cd202d3129 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 17 Mar 2026 16:04:21 +0100 Subject: [PATCH 5/7] add more commands, and add limit param to relevant commands --- .../src/analyze/executable_definitions.rs | 23 + .../relay-bin/src/analyze/find_references.rs | 22 + .../src/analyze/fragment_dependents.rs | 408 ++++++++++++++++++ .../relay-bin/src/analyze/fragment_usage.rs | 281 ++++++++++++ compiler/crates/relay-bin/src/analyze/mod.rs | 18 + .../relay-bin/src/analyze/schema_dce.rs | 74 +++- 6 files changed, 814 insertions(+), 12 deletions(-) create mode 100644 compiler/crates/relay-bin/src/analyze/fragment_dependents.rs create mode 100644 compiler/crates/relay-bin/src/analyze/fragment_usage.rs diff --git a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs index 77164ea371854..b14c384c02e9d 100644 --- a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs +++ b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs @@ -25,6 +25,10 @@ pub(crate) struct AnalyzeExecutableDefinitionsCommand { #[clap(name = "project", long, short)] projects: Vec, + /// Limit the number of matches returned. + #[clap(long, default_value_t = 100)] + limit: usize, + /// Minimum number of line breaks covered by selections. #[clap(long = "min-selection-lines")] min_selection_lines: Option, @@ -48,6 +52,9 @@ struct AnalyzeExecutableDefinitionsReport { total_fragments: usize, matches: Vec, match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, } #[derive(Debug, Serialize)] @@ -100,6 +107,7 @@ pub(crate) async fn handle_analyze_executable_definitions_command( let json = command.json; let min_selection_lines = command.min_selection_lines; let min_selection_depth = command.min_selection_depth; + let limit = command.limit; let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { @@ -119,6 +127,7 @@ pub(crate) async fn handle_analyze_executable_definitions_command( &config.root_dir, min_selection_lines, min_selection_depth, + limit, json, )?; Ok(()) @@ -130,6 +139,7 @@ fn analyze_project_executable_definitions( root_dir: &Path, min_selection_lines: Option, min_selection_depth: Option, + limit: usize, json: bool, ) -> Result<(), Error> { let mut matches = Vec::new(); @@ -169,6 +179,9 @@ fn analyze_project_executable_definitions( .then(a.selection_lines.cmp(&b.selection_lines)) .then(a.selection_depth.cmp(&b.selection_depth)) }); + let total_count = matches.len(); + let truncated = total_count > limit; + matches.truncate(limit); let report = AnalyzeExecutableDefinitionsReport { project: project_name.to_string(), @@ -177,6 +190,9 @@ fn analyze_project_executable_definitions( total_operations: programs.source.operations().count(), total_fragments: programs.source.fragments().count(), match_count: matches.len(), + total_count, + limit, + truncated, matches, }; @@ -338,4 +354,11 @@ fn print_analyze_executable_definitions_text_report( ); println!(" violations: {}", match_entry.violations.join(", ")); } + + if report.truncated { + println!( + " showing {} of {} match(es) (use --limit to see more).", + report.match_count, report.total_count + ); + } } diff --git a/compiler/crates/relay-bin/src/analyze/find_references.rs b/compiler/crates/relay-bin/src/analyze/find_references.rs index eb2b069f70a8b..14402b0f0193a 100644 --- a/compiler/crates/relay-bin/src/analyze/find_references.rs +++ b/compiler/crates/relay-bin/src/analyze/find_references.rs @@ -37,6 +37,10 @@ pub(crate) struct AnalyzeFindReferencesCommand { #[clap(name = "project", long, short)] projects: Vec, + /// Limit the number of matches returned. + #[clap(long, default_value_t = 100)] + limit: usize, + /// Emit JSON output. #[clap(long)] json: bool, @@ -51,6 +55,9 @@ struct AnalyzeFindReferencesReport { with_snippet: bool, matches: Vec, match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, } #[derive(Debug, Serialize)] @@ -91,6 +98,7 @@ pub(crate) async fn handle_analyze_find_references_command( let project_name = ensure_single_project_config(&config)?; let payload = parse_find_references_payload(&command.payload)?; let with_snippet = command.with_snippet; + let limit = command.limit; let json = command.json; set_project_flag(&mut config, command.projects)?; @@ -112,6 +120,7 @@ pub(crate) async fn handle_analyze_find_references_command( &config.root_dir, payload, with_snippet, + limit, json, )?; Ok(()) @@ -165,6 +174,7 @@ fn analyze_project_find_references( root_dir: &Path, payload: AnalyzeFindReferencesPayload, with_snippet: bool, + limit: usize, json: bool, ) -> Result<(), Error> { let schema = &programs.source.schema; @@ -221,6 +231,9 @@ fn analyze_project_find_references( .then(a.location.end_column.cmp(&b.location.end_column)) .then(a.containing_definition.cmp(&b.containing_definition)) }); + let total_count = matches.len(); + let truncated = total_count > limit; + matches.truncate(limit); let report = AnalyzeFindReferencesReport { project: project_name.to_string(), @@ -228,6 +241,9 @@ fn analyze_project_find_references( target_field: payload.field_name, with_snippet, match_count: matches.len(), + total_count, + limit, + truncated, matches, }; @@ -387,6 +403,12 @@ fn print_analyze_find_references_text_report(report: &AnalyzeFindReferencesRepor .map(|field| format!(".{}", field)) .unwrap_or_default() ); + if report.truncated { + println!( + " showing {} of {} matches (use --limit to see more).", + report.match_count, report.total_count + ); + } for reference in &report.matches { println!( " {} {} @ {}:{}:{}-{}:{}", diff --git a/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs new file mode 100644 index 0000000000000..1c0874d212b5e --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs @@ -0,0 +1,408 @@ +use std::collections::{HashMap, VecDeque}; +use std::path::Path; + +use clap::Parser; +use common::ConsoleLogger; +use common::Location; +use graphql_ir::ExecutableDefinitionName; +use graphql_ir::FragmentDefinitionName; +use graphql_ir::Selection; +use intern::string_key::Intern; +use relay_compiler::{source_for_location, get_programs, FsSourceReader, ProjectName}; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find direct dependents of a fragment (operations/fragments that spread it)." +)] +pub(crate) struct AnalyzeFragmentDependentsCommand { + /// The name of the fragment to find dependents for. + fragment: String, + + /// Include the full line containing the dependent reference. + #[clap(long = "with-snippet")] + with_snippet: bool, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Limit the number of dependents returned. + #[clap(long, default_value_t = 100)] + limit: usize, + + /// Include transitive dependents (operations/fragments that depend on direct dependents). + /// + /// Direct dependents are the most common case (distance = 1), and usually represent + /// the immediate blast radius of a fragment change. + /// Transitive dependents are useful when you need complete impact analysis. + #[clap(long)] + transitive: bool, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentDependentsReport { + project: String, + fragment: String, + with_snippet: bool, + include_transitive: bool, + match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, + matches: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentDependentMatch { + kind: String, + containing_definition: String, + distance: usize, + location: AnalyzeFragmentDependentLocation, + snippet: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentDependentLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +#[derive(Debug, Clone)] +struct FragmentDependentEdge { + parent: ExecutableDefinitionName, + location: Location, +} + +pub(crate) async fn handle_analyze_fragment_dependents_command( + command: AnalyzeFragmentDependentsCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let root_fragment = parse_fragment_name(&command.fragment)?; + let with_snippet = command.with_snippet; + let include_transitive = command.transitive; + let limit = command.limit; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_fragment_dependents( + project_name, + program.as_ref(), + &config.root_dir, + root_fragment, + with_snippet, + include_transitive, + limit, + json, + )?; + + Ok(()) +} + +fn parse_fragment_name(fragment: &str) -> Result { + let fragment = fragment.trim(); + if fragment.is_empty() { + return Err(Error::AnalyzeError { + details: "A fragment name is required, e.g. `UserDataFragment`.".to_string(), + }); + } + + Ok(FragmentDefinitionName(fragment.intern())) +} + +fn analyze_project_fragment_dependents( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + root_fragment: FragmentDefinitionName, + with_snippet: bool, + include_transitive: bool, + limit: usize, + json: bool, +) -> Result<(), Error> { + if programs.source.fragment(root_fragment).is_none() { + return Err(Error::AnalyzeError { + details: format!( + "Fragment `{}` was not found in source documents.", + root_fragment + ), + }); + } + + let edges = collect_fragment_spread_edges(programs); + let mut dependents: Vec = Vec::new(); + let mut queue = VecDeque::new(); + let mut distance_by_definition: HashMap = HashMap::default(); + + distance_by_definition.insert(root_fragment.into(), 0); + queue.push_back(root_fragment); + + while let Some(current_fragment) = queue.pop_front() { + let current_distance = distance_by_definition + .get(¤t_fragment.into()) + .copied() + .unwrap_or(0); + + for edge in edges.get(¤t_fragment).into_iter().flatten() { + if distance_by_definition.contains_key(&edge.parent) { + continue; + } + + let distance = current_distance + 1; + let (kind, containing_definition) = match edge.parent { + ExecutableDefinitionName::OperationDefinitionName(operation_name) => { + ("operation".to_string(), operation_name.to_string()) + } + ExecutableDefinitionName::FragmentDefinitionName(fragment_name) => { + if fragment_name == root_fragment { + continue; + } + + ("fragment".to_string(), fragment_name.to_string()) + } + }; + + distance_by_definition.insert(edge.parent, distance); + + let location = location_from_reference(root_dir, &edge.location)?; + let snippet = if with_snippet { + Some(reference_line_snippet(root_dir, &edge.location)?) + } else { + None + }; + + dependents.push(AnalyzeFragmentDependentMatch { + kind, + containing_definition, + distance, + location, + snippet, + }); + + if include_transitive { + if let ExecutableDefinitionName::FragmentDefinitionName(parent_fragment) = edge.parent { + queue.push_back(parent_fragment); + } + } + } + } + + dependents.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then(a.containing_definition.cmp(&b.containing_definition)) + .then(a.distance.cmp(&b.distance)) + }); + let total_count = dependents.len(); + let truncated = total_count > limit; + dependents.truncate(limit); + + let report = AnalyzeFragmentDependentsReport { + project: project_name.to_string(), + fragment: root_fragment.to_string(), + with_snippet, + include_transitive, + match_count: dependents.len(), + total_count, + limit, + truncated, + matches: dependents, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_fragment_dependents_text_report(&report); + } + + Ok(()) +} + +fn collect_fragment_spread_edges( + programs: &relay_transforms::Programs, +) -> HashMap> { + let mut edges: HashMap> = HashMap::default(); + + for operation in programs.source.operations() { + collect_fragment_spreads_from_selections( + &operation.selections, + ExecutableDefinitionName::OperationDefinitionName(operation.name.item), + &mut edges, + ); + } + + for fragment in programs.source.fragments() { + collect_fragment_spreads_from_selections( + &fragment.selections, + ExecutableDefinitionName::FragmentDefinitionName(fragment.name.item), + &mut edges, + ); + } + + edges +} + +fn collect_fragment_spreads_from_selections( + selections: &[Selection], + parent: ExecutableDefinitionName, + edges: &mut HashMap>, +) { + for selection in selections { + match selection { + Selection::FragmentSpread(spread) => { + edges + .entry(spread.fragment.item) + .or_default() + .push(FragmentDependentEdge { + parent, + location: spread.fragment.location, + }); + } + Selection::Condition(condition) => { + collect_fragment_spreads_from_selections(&condition.selections, parent, edges); + } + Selection::InlineFragment(inline_fragment) => { + collect_fragment_spreads_from_selections(&inline_fragment.selections, parent, edges); + } + Selection::LinkedField(linked_field) => { + collect_fragment_spreads_from_selections(&linked_field.selections, parent, edges); + } + Selection::ScalarField(_) => {} + } + } +} + +fn location_from_reference( + root_dir: &Path, + location: &Location, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for dependent reference.", + source_location.path() + ), + })?; + let source_text = source.text_source(); + let range = source_text.to_span_range(location.span()); + + Ok(AnalyzeFragmentDependentLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }) +} + +fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for snippet lookup.", + source_location.path() + ), + })?; + let source_text = source.text_source(); + + let range = source_text.to_span_range(location.span()); + let local_line = range + .start + .line + .checked_sub(source_text.line_index as u32) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to resolve snippet line for {}.", source_location.path()), + })?; + + source_text + .text + .lines() + .nth(local_line as usize) + .map(|line| line.to_string()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to resolve snippet line for {}:{}.", + source_location.path(), + range.start.line + 1 + ), + }) +} + +fn print_analyze_fragment_dependents_text_report(report: &AnalyzeFragmentDependentsReport) { + if report.matches.is_empty() { + println!( + "Project {}: no dependents found for fragment {}.", + report.project, report.fragment + ); + return; + } + + let scope = if report.include_transitive { + "direct and transitive" + } else { + "direct" + }; + println!( + "Project {}: {} {} dependent(s) found for fragment {}.", + report.project, + report.match_count, + scope, + report.fragment + ); + + for dependent in &report.matches { + println!( + " {} {} (depth {}): {}:{}:{}-{}:{}", + dependent.kind, + dependent.containing_definition, + dependent.distance, + dependent.location.filename, + dependent.location.start_line, + dependent.location.start_column, + dependent.location.end_line, + dependent.location.end_column + ); + if let Some(snippet) = &dependent.snippet { + println!(" line: {}", snippet); + } + } + + if report.truncated { + println!( + " showing {} of {} dependent(s) (use --limit to see more).", + report.match_count, report.total_count + ); + } +} diff --git a/compiler/crates/relay-bin/src/analyze/fragment_usage.rs b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs new file mode 100644 index 0000000000000..c52dc3405e687 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs @@ -0,0 +1,281 @@ +use std::collections::HashMap; +use std::path::Path; + +use clap::Parser; +use common::{ConsoleLogger, Location}; +use graphql_ir::Selection; +use graphql_ir::FragmentDefinitionName; +use relay_compiler::source_for_location; +use relay_compiler::ProjectName; +use relay_compiler::{FsSourceReader, get_programs}; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ensure_single_project_config, print_json_report}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "List fragments sorted by spread usage count." +)] +pub(crate) struct AnalyzeFragmentUsageCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Sort order for usage results. + #[clap( + long, + default_value = "usage-desc", + value_parser = ["usage-desc", "usage-asc"] + )] + sort: String, + + /// Show only fragments with at least this many usages. + #[clap(long = "min-usage")] + min_usage: Option, + + /// Limit the number of fragments returned. + #[clap(long, default_value_t = 100)] + limit: usize, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum AnalyzeFragmentUsageSort { + UsageDesc, + UsageAsc, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentUsageReport { + project: String, + match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, + fragments: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentUsageMatch { + fragment_name: String, + usage_count: usize, + location: AnalyzeFragmentUsageLocation, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeFragmentUsageLocation { + filename: String, + start_line: u32, + start_column: u32, + end_line: u32, + end_column: u32, +} + +pub(crate) async fn handle_analyze_fragment_usage_command( + command: AnalyzeFragmentUsageCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let sort = match command.sort.as_str() { + "usage-desc" => AnalyzeFragmentUsageSort::UsageDesc, + "usage-asc" => AnalyzeFragmentUsageSort::UsageAsc, + _ => { + return Err(Error::AnalyzeError { + details: "sort must be usage-desc or usage-asc.".into(), + }) + } + }; + let min_usage = command.min_usage; + let limit = command.limit; + let json = command.json; + if let Some(0) = min_usage { + return Err(Error::AnalyzeError { + details: "min-usage must be greater than zero.".into(), + }); + } + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_fragment_usage( + project_name, + program.as_ref(), + &config.root_dir, + sort, + min_usage, + limit, + json, + )?; + + Ok(()) +} + +fn analyze_project_fragment_usage( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + sort: AnalyzeFragmentUsageSort, + min_usage: Option, + limit: usize, + json: bool, +) -> Result<(), Error> { + let mut usage_by_fragment: HashMap = programs + .source + .fragments() + .map(|fragment| (fragment.name.item, 0)) + .collect(); + + for operation in programs.source.operations() { + collect_fragment_spread_usages(&operation.selections, &mut usage_by_fragment); + } + + for fragment in programs.source.fragments() { + collect_fragment_spread_usages(&fragment.selections, &mut usage_by_fragment); + } + + let mut fragments = Vec::new(); + for fragment in programs.source.fragments() { + let usage_count = usage_by_fragment + .get(&fragment.name.item) + .copied() + .unwrap_or_default(); + let location = location_from_reference(root_dir, &fragment.name.location)?; + fragments.push(AnalyzeFragmentUsageMatch { + fragment_name: fragment.name.item.to_string(), + usage_count, + location, + }); + } + + if let Some(min_usage) = min_usage { + fragments.retain(|entry| entry.usage_count >= min_usage); + } + + match sort { + AnalyzeFragmentUsageSort::UsageDesc => fragments + .sort_by(|a, b| b.usage_count.cmp(&a.usage_count).then(a.fragment_name.cmp(&b.fragment_name))), + AnalyzeFragmentUsageSort::UsageAsc => fragments + .sort_by(|a, b| a.usage_count.cmp(&b.usage_count).then(a.fragment_name.cmp(&b.fragment_name))), + } + + let total_count = fragments.len(); + let truncated = total_count > limit; + fragments.truncate(limit); + + let report = AnalyzeFragmentUsageReport { + project: project_name.to_string(), + match_count: fragments.len(), + total_count, + limit, + truncated, + fragments, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_fragment_usage_text_report(&report); + } + + Ok(()) +} + +fn collect_fragment_spread_usages( + selections: &[Selection], + usage_by_fragment: &mut HashMap, +) { + for selection in selections { + match selection { + Selection::FragmentSpread(spread) => { + let entry = usage_by_fragment.entry(spread.fragment.item).or_insert(0); + *entry += 1; + } + Selection::Condition(condition) => { + collect_fragment_spread_usages(&condition.selections, usage_by_fragment); + } + Selection::InlineFragment(inline_fragment) => { + collect_fragment_spread_usages(&inline_fragment.selections, usage_by_fragment); + } + Selection::LinkedField(linked_field) => { + collect_fragment_spread_usages(&linked_field.selections, usage_by_fragment); + } + Selection::ScalarField(_) => {} + } + } +} + +fn location_from_reference( + root_dir: &Path, + location: &Location, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for fragment definition.", + source_location.path() + ), + })?; + let source_text = source.text_source(); + let range = source_text.to_span_range(location.span()); + + Ok(AnalyzeFragmentUsageLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }) +} + +fn print_analyze_fragment_usage_text_report(report: &AnalyzeFragmentUsageReport) { + if report.match_count == 0 { + println!("Project {}: no fragments found.", report.project); + return; + } + + println!( + "Project {}: {} fragment(s) sorted by usage count.", + report.project, report.match_count + ); + if report.truncated { + println!( + " showing {} of {} fragment(s) (use --limit to see more).", + report.match_count, report.total_count + ); + } + + for fragment in &report.fragments { + println!( + " {:>3} use(s): {} @ {}:{}:{}-{}:{}", + fragment.usage_count, + fragment.fragment_name, + fragment.location.filename, + fragment.location.start_line, + fragment.location.start_column, + fragment.location.end_line, + fragment.location.end_column + ); + } +} diff --git a/compiler/crates/relay-bin/src/analyze/mod.rs b/compiler/crates/relay-bin/src/analyze/mod.rs index b1f25af1e163d..c58b3e31c6189 100644 --- a/compiler/crates/relay-bin/src/analyze/mod.rs +++ b/compiler/crates/relay-bin/src/analyze/mod.rs @@ -1,6 +1,8 @@ use clap::Parser; mod executable_definitions; +mod fragment_dependents; +mod fragment_usage; mod find_references; mod print_operation; mod schema_dce; @@ -10,6 +12,8 @@ use crate::errors::Error; use executable_definitions::AnalyzeExecutableDefinitionsCommand; use find_references::AnalyzeFindReferencesCommand; +use fragment_dependents::AnalyzeFragmentDependentsCommand; +use fragment_usage::AnalyzeFragmentUsageCommand; use print_operation::AnalyzePrintOperationCommand; use schema_dce::AnalyzeSchemaDceCommand; @@ -31,6 +35,14 @@ enum AnalyzeSubcommand { #[clap(name = "print-operation")] PrintOperation(AnalyzePrintOperationCommand), + /// Find all dependent operations and fragments for a fragment. + #[clap(name = "fragment-dependents")] + FragmentDependents(AnalyzeFragmentDependentsCommand), + + /// List fragments by spread usage count (most used first). + #[clap(name = "fragment-usage")] + FragmentUsage(AnalyzeFragmentUsageCommand), + /// Find unused schema fields in Relay operations. #[clap(name = "schema-dce")] SchemaDce(AnalyzeSchemaDceCommand), @@ -51,6 +63,12 @@ pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error AnalyzeSubcommand::SchemaDce(command) => { schema_dce::handle_analyze_schema_dce_command(command).await } + AnalyzeSubcommand::FragmentDependents(command) => { + fragment_dependents::handle_analyze_fragment_dependents_command(command).await + } + AnalyzeSubcommand::FragmentUsage(command) => { + fragment_usage::handle_analyze_fragment_usage_command(command).await + } AnalyzeSubcommand::ExecutableDefinitions(command) => { executable_definitions::handle_analyze_executable_definitions_command(command).await } diff --git a/compiler/crates/relay-bin/src/analyze/schema_dce.rs b/compiler/crates/relay-bin/src/analyze/schema_dce.rs index 462b400ebb0e5..35885877b91c3 100644 --- a/compiler/crates/relay-bin/src/analyze/schema_dce.rs +++ b/compiler/crates/relay-bin/src/analyze/schema_dce.rs @@ -31,6 +31,10 @@ pub(crate) struct AnalyzeSchemaDceCommand { #[clap(name = "project", long, short)] projects: Vec, + /// Limit the number of types returned. + #[clap(long, default_value_t = 100)] + limit: usize, + /// Emit JSON output. #[clap(long)] json: bool, @@ -55,6 +59,11 @@ struct AnalyzeSchemaDceReport { dead_fields: Vec, dead_field_count: usize, dead_union_member_count: usize, + total_count: usize, + total_dead_field_count: usize, + total_dead_union_member_count: usize, + limit: usize, + truncated: bool, } pub(crate) async fn handle_analyze_schema_dce_command( @@ -62,6 +71,7 @@ pub(crate) async fn handle_analyze_schema_dce_command( ) -> Result<(), Error> { let mut config = get_config(None)?; let project_name = ensure_single_project_config(&config)?; + let limit = command.limit; let json = command.json; set_project_flag(&mut config, command.projects)?; @@ -77,7 +87,7 @@ pub(crate) async fn handle_analyze_schema_dce_command( .ok_or_else(|| Error::AnalyzeError { details: format!("Project {project_name} was not built for analyze."), })?; - analyze_project_dead_fields(project_name, program.as_ref(), json)?; + analyze_project_dead_fields(project_name, program.as_ref(), limit, json)?; Ok(()) } @@ -273,6 +283,7 @@ fn should_ignore_internal_field(field_name: &str) -> bool { fn analyze_project_dead_fields( project_name: ProjectName, programs: &relay_transforms::Programs, + limit: usize, json: bool, ) -> Result<(), Error> { let (referenced_fields, mut referenced_types, referenced_union_members) = @@ -415,18 +426,36 @@ fn analyze_project_dead_fields( } } + let mut dead_fields = dead_fields_by_type + .into_values() + .map(|mut entry| { + entry.dead_fields.sort_unstable(); + entry.dead_union_members.sort_unstable(); + entry + }) + .collect::>(); + let total_dead_field_count: usize = dead_fields + .iter() + .map(|entry| entry.dead_fields.len()) + .sum(); + let total_dead_union_member_count: usize = dead_fields + .iter() + .map(|entry| entry.dead_union_members.len()) + .sum(); + let total_count = dead_fields.len(); + let truncated = total_count > limit; + dead_fields.truncate(limit); + let mut report = AnalyzeSchemaDceReport { project: project_name.to_string(), - dead_fields: dead_fields_by_type - .into_values() - .map(|mut entry| { - entry.dead_fields.sort_unstable(); - entry.dead_union_members.sort_unstable(); - entry - }) - .collect(), + dead_fields, dead_field_count: 0, dead_union_member_count: 0, + total_count, + total_dead_field_count, + total_dead_union_member_count, + limit, + truncated, }; report.dead_field_count = report @@ -443,13 +472,13 @@ fn analyze_project_dead_fields( if json { print_json_report(&report)?; } else { - print_analyze_schema_dce_text_report(report) + print_analyze_schema_dce_text_report(&report) } Ok(()) } -fn print_analyze_schema_dce_text_report(report: AnalyzeSchemaDceReport) { +fn print_analyze_schema_dce_text_report(report: &AnalyzeSchemaDceReport) { if report.dead_fields.is_empty() { println!( "Project {}: no dead schema fields or union members found", @@ -465,7 +494,28 @@ fn print_analyze_schema_dce_text_report(report: AnalyzeSchemaDceReport) { report.dead_union_member_count, report.dead_fields.len() ); - for entry in report.dead_fields { + println!( + " total dead fields in project: {} ({} shown)", + report.total_dead_field_count, + report.dead_field_count + ); + println!( + " total unselected union members in project: {} ({} shown)", + report.total_dead_union_member_count, + report.dead_union_member_count + ); + println!( + " total dead type count in project: {}", + report.total_count + ); + if report.truncated { + println!( + " showing {} of {} types (use --limit to see more).", + report.dead_fields.len(), + report.total_count + ); + } + for entry in &report.dead_fields { if entry.type_referenced { println!(" {} ({})", entry.type_name, entry.type_description); } else { From 57d5925370f9fedfac26e4bda8f4364a220ca2ea Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 17 Mar 2026 16:16:54 +0100 Subject: [PATCH 6/7] refactor, and add more commands --- compiler/Cargo.lock | 2 + compiler/crates/relay-bin/Cargo.toml | 2 + .../relay-bin/src/analyze/deprecated_usage.rs | 311 +++++++++++++++ .../src/analyze/executable_definitions.rs | 18 +- .../relay-bin/src/analyze/find_references.rs | 98 +---- .../src/analyze/fragment_dependents.rs | 99 +---- .../relay-bin/src/analyze/fragment_usage.rs | 65 +-- compiler/crates/relay-bin/src/analyze/mod.rs | 29 +- .../relay-bin/src/analyze/rename_fragment.rs | 371 ++++++++++++++++++ .../relay-bin/src/analyze/schema_dce.rs | 14 +- .../relay-bin/src/analyze/unused_fragments.rs | 246 ++++++++++++ .../crates/relay-bin/src/analyze/utils.rs | 119 ++++++ 12 files changed, 1157 insertions(+), 217 deletions(-) create mode 100644 compiler/crates/relay-bin/src/analyze/deprecated_usage.rs create mode 100644 compiler/crates/relay-bin/src/analyze/rename_fragment.rs create mode 100644 compiler/crates/relay-bin/src/analyze/unused_fragments.rs diff --git a/compiler/Cargo.lock b/compiler/Cargo.lock index 07ee90f13aa8e..1d9644162cc1a 100644 --- a/compiler/Cargo.lock +++ b/compiler/Cargo.lock @@ -1847,9 +1847,11 @@ dependencies = [ "clap", "common", "graphql-ir", + "graphql-syntax", "graphql-text-printer", "intern", "log", + "lsp-types", "relay-codemod", "relay-compiler", "relay-lsp", diff --git a/compiler/crates/relay-bin/Cargo.toml b/compiler/crates/relay-bin/Cargo.toml index 0324189f1e016..8360755c09531 100644 --- a/compiler/crates/relay-bin/Cargo.toml +++ b/compiler/crates/relay-bin/Cargo.toml @@ -14,6 +14,8 @@ common = { path = "../common" } intern = { path = "../intern" } log = { version = "0.4.27", features = ["kv_unstable", "kv_unstable_std"] } graphql-ir = { path = "../graphql-ir" } +graphql-syntax = { path = "../graphql-syntax" } +lsp-types = "0.94.1" relay-codemod = { path = "../relay-codemod" } relay-compiler = { path = "../relay-compiler" } relay-lsp = { path = "../relay-lsp" } diff --git a/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs b/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs new file mode 100644 index 0000000000000..828e1ab7da69d --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs @@ -0,0 +1,311 @@ +use clap::Parser; +use common::ConsoleLogger; +use graphql_ir::ExecutableDefinition; +use relay_compiler::{ProjectName, get_programs}; +use relay_transforms::deprecated_fields_for_executable_definition; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, + source_location_to_analyze_location, + AnalyzeLocation, +}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find deprecated fields, arguments, and directives in executable definitions." +)] +pub(crate) struct AnalyzeDeprecatedUsageCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Limit the number of results returned. + #[clap(long, default_value_t = 100)] + limit: usize, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeDeprecatedUsageReport { + project: String, + match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, + usages: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeDeprecatedUsageEntry { + kind: String, + item: String, + reason: Option, + containing_definition: String, + containing_definition_kind: String, + location: AnalyzeDeprecatedUsageLocation, +} + +type AnalyzeDeprecatedUsageLocation = AnalyzeLocation; + +pub(crate) async fn handle_analyze_deprecated_usage_command( + command: AnalyzeDeprecatedUsageCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let json = command.json; + let limit = command.limit; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_deprecated_usage( + project_name, + program.as_ref(), + &config.root_dir, + limit, + json, + )?; + Ok(()) +} + +fn analyze_project_deprecated_usage( + project_name: ProjectName, + program: &relay_transforms::Programs, + root_dir: &std::path::Path, + limit: usize, + json: bool, +) -> Result<(), Error> { + let mut usages = Vec::new(); + let schema = &program.source.schema; + for operation in program.source.operations() { + collect_deprecated_usage( + &ExecutableDefinition::Operation(operation.as_ref().clone()), + &operation.name.item.to_string(), + "operation", + schema, + root_dir, + &mut usages, + )?; + } + + for fragment in program.source.fragments() { + collect_deprecated_usage( + &ExecutableDefinition::Fragment(fragment.as_ref().clone()), + &fragment.name.item.to_string(), + "fragment", + schema, + root_dir, + &mut usages, + )?; + } + + usages.sort_by(|a, b| { + a.kind + .cmp(&b.kind) + .then(a.containing_definition_kind.cmp(&b.containing_definition_kind)) + .then(a.containing_definition.cmp(&b.containing_definition)) + .then(a.item.cmp(&b.item)) + .then(a.location.filename.cmp(&b.location.filename)) + .then(a.location.start_line.cmp(&b.location.start_line)) + .then(a.location.start_column.cmp(&b.location.start_column)) + }); + + let limited_usages = apply_limit(usages, limit); + + let report = AnalyzeDeprecatedUsageReport { + project: project_name.to_string(), + match_count: limited_usages.match_count, + total_count: limited_usages.total_count, + limit, + truncated: limited_usages.truncated, + usages: limited_usages.entries, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_deprecated_usage_text_report(&report); + } + Ok(()) +} + +fn collect_deprecated_usage( + definition: &ExecutableDefinition, + definition_name: &str, + definition_kind: &'static str, + schema: &std::sync::Arc, + root_dir: &std::path::Path, + usages: &mut Vec, +) -> Result<(), Error> { + let warnings = deprecated_fields_for_executable_definition(schema, definition) + .map_err(|errors| Error::AnalyzeError { + details: format!( + "Unable to get deprecation diagnostics for `{definition_name}`: {errors:?}" + ), + })?; + + for warning in warnings { + let message = warning.message().to_string(); + let (kind, item, fallback) = parse_deprecated_warning(&message); + let location = source_location_to_analyze_location( + root_dir, + &warning.location(), + "deprecated usage", + )?; + usages.push(AnalyzeDeprecatedUsageEntry { + kind, + item: item.or(fallback).unwrap_or_default(), + reason: parse_deprecation_reason(&message), + containing_definition: definition_name.to_string(), + containing_definition_kind: definition_kind.to_string(), + location, + }); + } + + Ok(()) +} + +fn parse_deprecated_warning(message: &str) -> (String, Option, Option) { + let backticked = collect_backticked_segments(message); + let fallback = message.to_string(); + let (kind, item) = if message.starts_with("The field ") { + ( + "field".to_string(), + backticked.first().cloned(), + ) + } else if message.starts_with("The argument ") && message.contains("of the directive") { + let directive = backticked + .get(1) + .cloned() + .unwrap_or_default(); + let arg = backticked.first().cloned().unwrap_or_else(String::new); + let item = if directive.is_empty() || arg.is_empty() { + None + } else { + Some(format!("{directive} argument {arg}")) + }; + ("argument".to_string(), item) + } else if message.starts_with("The argument ") { + let parent = backticked.get(1).cloned().unwrap_or_else(String::new); + let arg = backticked.first().cloned().unwrap_or_else(String::new); + let item = if parent.is_empty() || arg.is_empty() { + None + } else { + Some(format!("{parent} argument {arg}")) + }; + ("argument".to_string(), item) + } else if message.starts_with("The directive ") { + ( + "directive".to_string(), + backticked.first().cloned(), + ) + } else { + ( + "deprecated".to_string(), + backticked.first().cloned(), + ) + }; + + let fallback_item = (if fallback.is_empty() { + None + } else { + Some(fallback) + }) + .filter(|_| item.is_none()); + + (kind, item, fallback_item) +} + +fn parse_deprecation_reason(message: &str) -> Option { + let marker = "Deprecation reason: \""; + let start = message.find(marker)?; + let after_marker = &message[(start + marker.len())..]; + let end = after_marker.find('"')?; + let reason = &after_marker[..end]; + if reason.is_empty() { + None + } else { + Some(reason.to_string()) + } +} + +fn collect_backticked_segments(message: &str) -> Vec { + let mut segments = Vec::new(); + let mut remaining = message; + + loop { + let start = match remaining.find('`') { + Some(value) => value + 1, + None => break, + }; + let tail = &remaining[start..]; + let end = match tail.find('`') { + Some(value) => value, + None => break, + }; + segments.push(tail[..end].to_string()); + remaining = &tail[end + 1..]; + } + + segments +} + +fn print_analyze_deprecated_usage_text_report(report: &AnalyzeDeprecatedUsageReport) { + if report.match_count == 0 { + println!("Project {}: no deprecated usages found.", report.project); + return; + } + + println!( + "Project {}: {} deprecated usage(s).", + report.project, report.match_count + ); + + if report.truncated { + println!( + " showing {} of {} deprecated usage(s) (use --limit to see more).", + report.match_count, report.total_count + ); + } + + for usage in &report.usages { + println!( + " {} usage '{}' in {} {} @ {}:{}:{}-{}:{}", + usage.kind, + usage.item, + usage.containing_definition_kind, + usage.containing_definition, + usage.location.filename, + usage.location.start_line, + usage.location.start_column, + usage.location.end_line, + usage.location.end_column + ); + if let Some(reason) = &usage.reason { + println!(" reason: {reason}"); + } + } +} diff --git a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs index b14c384c02e9d..482ba71c786b6 100644 --- a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs +++ b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs @@ -12,7 +12,11 @@ use serde::Serialize; use crate::errors::Error; use crate::{get_config, set_project_flag}; -use super::utils::{ensure_single_project_config, print_json_report}; +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, +}; #[derive(Parser)] #[clap( @@ -179,9 +183,7 @@ fn analyze_project_executable_definitions( .then(a.selection_lines.cmp(&b.selection_lines)) .then(a.selection_depth.cmp(&b.selection_depth)) }); - let total_count = matches.len(); - let truncated = total_count > limit; - matches.truncate(limit); + let limited_matches = apply_limit(matches, limit); let report = AnalyzeExecutableDefinitionsReport { project: project_name.to_string(), @@ -189,11 +191,11 @@ fn analyze_project_executable_definitions( min_selection_depth, total_operations: programs.source.operations().count(), total_fragments: programs.source.fragments().count(), - match_count: matches.len(), - total_count, + match_count: limited_matches.match_count, + total_count: limited_matches.total_count, limit, - truncated, - matches, + truncated: limited_matches.truncated, + matches: limited_matches.entries, }; if json { diff --git a/compiler/crates/relay-bin/src/analyze/find_references.rs b/compiler/crates/relay-bin/src/analyze/find_references.rs index 14402b0f0193a..e74822f8bb9e1 100644 --- a/compiler/crates/relay-bin/src/analyze/find_references.rs +++ b/compiler/crates/relay-bin/src/analyze/find_references.rs @@ -6,9 +6,8 @@ use common::{ConsoleLogger, Location}; use graphql_ir::FragmentDefinition; use graphql_ir::Visitor; use intern::string_key::Intern; -use relay_compiler::source_for_location; use relay_compiler::ProjectName; -use relay_compiler::{FsSourceReader, get_programs}; +use relay_compiler::get_programs; use relay_lsp::find_field_usages::get_usages; use schema::Type; use schema::Schema; @@ -17,7 +16,14 @@ use serde::Serialize; use crate::errors::Error; use crate::{get_config, set_project_flag}; -use super::utils::{ensure_single_project_config, print_json_report}; +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, + source_line_for_reference, + source_location_to_analyze_location, + AnalyzeLocation, +}; #[derive(Parser)] #[clap( @@ -69,15 +75,7 @@ struct AnalyzeFindReferencesMatch { snippet: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFindReferencesLocation { - filename: String, - start_line: u32, - start_column: u32, - end_line: u32, - end_column: u32, -} +type AnalyzeFindReferencesLocation = AnalyzeLocation; #[derive(Debug)] struct AnalyzeFindReferenceItem { @@ -206,9 +204,13 @@ fn analyze_project_find_references( let mut matches = items .into_iter() .map(|item| { - let location = location_from_reference(root_dir, &item.location)?; + let location = source_location_to_analyze_location( + root_dir, + &item.location, + "find reference location", + )?; let snippet = if with_snippet { - Some(reference_line_snippet(root_dir, &item.location)?) + Some(source_line_for_reference(root_dir, &item.location, "find reference location")?) } else { None }; @@ -231,20 +233,18 @@ fn analyze_project_find_references( .then(a.location.end_column.cmp(&b.location.end_column)) .then(a.containing_definition.cmp(&b.containing_definition)) }); - let total_count = matches.len(); - let truncated = total_count > limit; - matches.truncate(limit); + let limited_matches = apply_limit(matches, limit); let report = AnalyzeFindReferencesReport { project: project_name.to_string(), target_type: payload.type_name, target_field: payload.field_name, with_snippet, - match_count: matches.len(), - total_count, + match_count: limited_matches.match_count, + total_count: limited_matches.total_count, limit, - truncated, - matches, + truncated: limited_matches.truncated, + matches: limited_matches.entries, }; if json { @@ -268,62 +268,6 @@ fn collect_type_condition_references( visitor.items } -fn location_from_reference( - root_dir: &Path, - location: &Location, -) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for reference.", - source_location.path() - ), - })?; - let source_text = source.text_source(); - let range = source_text.to_span_range(location.span()); - - Ok(AnalyzeFindReferencesLocation { - filename: source_location.path().to_string(), - start_line: range.start.line + 1, - start_column: range.start.character + 1, - end_line: range.end.line + 1, - end_column: range.end.character + 1, - }) -} - -fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for snippet lookup.", - source_location.path() - ), - })?; - let text_source = source.text_source(); - let range = text_source.to_span_range(location.span()); - let local_line = range - .start - .line - .checked_sub(text_source.line_index as u32) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Unable to resolve snippet line for {}.", source_location.path()), - })?; - text_source - .text - .lines() - .nth(local_line as usize) - .map(|line| line.to_string()) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to resolve snippet line for {}:{}.", - source_location.path(), - range.start.line + 1 - ), - }) -} - fn normalize_containing_definition(label: &str) -> String { label.split(" - ").next().unwrap_or(label).to_string() } diff --git a/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs index 1c0874d212b5e..a40ccb64eb011 100644 --- a/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs +++ b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs @@ -8,13 +8,20 @@ use graphql_ir::ExecutableDefinitionName; use graphql_ir::FragmentDefinitionName; use graphql_ir::Selection; use intern::string_key::Intern; -use relay_compiler::{source_for_location, get_programs, FsSourceReader, ProjectName}; +use relay_compiler::{get_programs, ProjectName}; use serde::Serialize; use crate::errors::Error; use crate::{get_config, set_project_flag}; -use super::utils::{ensure_single_project_config, print_json_report}; +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, + source_line_for_reference, + source_location_to_analyze_location, + AnalyzeLocation, +}; #[derive(Parser)] #[clap( @@ -75,15 +82,7 @@ struct AnalyzeFragmentDependentMatch { snippet: Option, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFragmentDependentLocation { - filename: String, - start_line: u32, - start_column: u32, - end_line: u32, - end_column: u32, -} +type AnalyzeFragmentDependentLocation = AnalyzeLocation; #[derive(Debug, Clone)] struct FragmentDependentEdge { @@ -194,9 +193,13 @@ fn analyze_project_fragment_dependents( distance_by_definition.insert(edge.parent, distance); - let location = location_from_reference(root_dir, &edge.location)?; + let location = source_location_to_analyze_location( + root_dir, + &edge.location, + "dependent reference", + )?; let snippet = if with_snippet { - Some(reference_line_snippet(root_dir, &edge.location)?) + Some(source_line_for_reference(root_dir, &edge.location, "dependent reference")?) } else { None }; @@ -223,20 +226,18 @@ fn analyze_project_fragment_dependents( .then(a.containing_definition.cmp(&b.containing_definition)) .then(a.distance.cmp(&b.distance)) }); - let total_count = dependents.len(); - let truncated = total_count > limit; - dependents.truncate(limit); + let limited_dependents = apply_limit(dependents, limit); let report = AnalyzeFragmentDependentsReport { project: project_name.to_string(), fragment: root_fragment.to_string(), with_snippet, include_transitive, - match_count: dependents.len(), - total_count, + match_count: limited_dependents.match_count, + total_count: limited_dependents.total_count, limit, - truncated, - matches: dependents, + truncated: limited_dependents.truncated, + matches: limited_dependents.entries, }; if json { @@ -302,64 +303,6 @@ fn collect_fragment_spreads_from_selections( } } -fn location_from_reference( - root_dir: &Path, - location: &Location, -) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for dependent reference.", - source_location.path() - ), - })?; - let source_text = source.text_source(); - let range = source_text.to_span_range(location.span()); - - Ok(AnalyzeFragmentDependentLocation { - filename: source_location.path().to_string(), - start_line: range.start.line + 1, - start_column: range.start.character + 1, - end_line: range.end.line + 1, - end_column: range.end.character + 1, - }) -} - -fn reference_line_snippet(root_dir: &Path, location: &Location) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for snippet lookup.", - source_location.path() - ), - })?; - let source_text = source.text_source(); - - let range = source_text.to_span_range(location.span()); - let local_line = range - .start - .line - .checked_sub(source_text.line_index as u32) - .ok_or_else(|| Error::AnalyzeError { - details: format!("Unable to resolve snippet line for {}.", source_location.path()), - })?; - - source_text - .text - .lines() - .nth(local_line as usize) - .map(|line| line.to_string()) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to resolve snippet line for {}:{}.", - source_location.path(), - range.start.line + 1 - ), - }) -} - fn print_analyze_fragment_dependents_text_report(report: &AnalyzeFragmentDependentsReport) { if report.matches.is_empty() { println!( diff --git a/compiler/crates/relay-bin/src/analyze/fragment_usage.rs b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs index c52dc3405e687..0467912d32b64 100644 --- a/compiler/crates/relay-bin/src/analyze/fragment_usage.rs +++ b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs @@ -2,18 +2,23 @@ use std::collections::HashMap; use std::path::Path; use clap::Parser; -use common::{ConsoleLogger, Location}; +use common::ConsoleLogger; use graphql_ir::Selection; use graphql_ir::FragmentDefinitionName; -use relay_compiler::source_for_location; use relay_compiler::ProjectName; -use relay_compiler::{FsSourceReader, get_programs}; +use relay_compiler::{get_programs}; use serde::Serialize; use crate::errors::Error; use crate::{get_config, set_project_flag}; -use super::utils::{ensure_single_project_config, print_json_report}; +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, + source_location_to_analyze_location, + AnalyzeLocation, +}; #[derive(Parser)] #[clap( @@ -72,15 +77,7 @@ struct AnalyzeFragmentUsageMatch { location: AnalyzeFragmentUsageLocation, } -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AnalyzeFragmentUsageLocation { - filename: String, - start_line: u32, - start_column: u32, - end_line: u32, - end_column: u32, -} +type AnalyzeFragmentUsageLocation = AnalyzeLocation; pub(crate) async fn handle_analyze_fragment_usage_command( command: AnalyzeFragmentUsageCommand, @@ -160,7 +157,11 @@ fn analyze_project_fragment_usage( .get(&fragment.name.item) .copied() .unwrap_or_default(); - let location = location_from_reference(root_dir, &fragment.name.location)?; + let location = source_location_to_analyze_location( + root_dir, + &fragment.name.location, + "fragment usage", + )?; fragments.push(AnalyzeFragmentUsageMatch { fragment_name: fragment.name.item.to_string(), usage_count, @@ -179,17 +180,15 @@ fn analyze_project_fragment_usage( .sort_by(|a, b| a.usage_count.cmp(&b.usage_count).then(a.fragment_name.cmp(&b.fragment_name))), } - let total_count = fragments.len(); - let truncated = total_count > limit; - fragments.truncate(limit); + let limited_fragments = apply_limit(fragments, limit); let report = AnalyzeFragmentUsageReport { project: project_name.to_string(), - match_count: fragments.len(), - total_count, + match_count: limited_fragments.match_count, + total_count: limited_fragments.total_count, limit, - truncated, - fragments, + truncated: limited_fragments.truncated, + fragments: limited_fragments.entries, }; if json { @@ -225,30 +224,6 @@ fn collect_fragment_spread_usages( } } -fn location_from_reference( - root_dir: &Path, - location: &Location, -) -> Result { - let source_location = location.source_location(); - let source = source_for_location(root_dir, source_location, &FsSourceReader) - .ok_or_else(|| Error::AnalyzeError { - details: format!( - "Unable to load source location '{}' for fragment definition.", - source_location.path() - ), - })?; - let source_text = source.text_source(); - let range = source_text.to_span_range(location.span()); - - Ok(AnalyzeFragmentUsageLocation { - filename: source_location.path().to_string(), - start_line: range.start.line + 1, - start_column: range.start.character + 1, - end_line: range.end.line + 1, - end_column: range.end.character + 1, - }) -} - fn print_analyze_fragment_usage_text_report(report: &AnalyzeFragmentUsageReport) { if report.match_count == 0 { println!("Project {}: no fragments found.", report.project); diff --git a/compiler/crates/relay-bin/src/analyze/mod.rs b/compiler/crates/relay-bin/src/analyze/mod.rs index c58b3e31c6189..5360686cf6dc1 100644 --- a/compiler/crates/relay-bin/src/analyze/mod.rs +++ b/compiler/crates/relay-bin/src/analyze/mod.rs @@ -4,7 +4,10 @@ mod executable_definitions; mod fragment_dependents; mod fragment_usage; mod find_references; +mod deprecated_usage; mod print_operation; +mod rename_fragment; +mod unused_fragments; mod schema_dce; mod utils; @@ -12,10 +15,13 @@ use crate::errors::Error; use executable_definitions::AnalyzeExecutableDefinitionsCommand; use find_references::AnalyzeFindReferencesCommand; +use deprecated_usage::AnalyzeDeprecatedUsageCommand; use fragment_dependents::AnalyzeFragmentDependentsCommand; use fragment_usage::AnalyzeFragmentUsageCommand; +use rename_fragment::AnalyzeRenameFragmentCommand; use print_operation::AnalyzePrintOperationCommand; use schema_dce::AnalyzeSchemaDceCommand; +use unused_fragments::AnalyzeUnusedFragmentsCommand; #[derive(Parser)] #[clap(rename_all = "snake_case", about = "Schema analysis helpers.")] @@ -39,11 +45,23 @@ enum AnalyzeSubcommand { #[clap(name = "fragment-dependents")] FragmentDependents(AnalyzeFragmentDependentsCommand), + /// Find deprecated fields, arguments, and directives in executable documents. + #[clap(name = "deprecated-usage")] + DeprecatedUsage(AnalyzeDeprecatedUsageCommand), + + /// Find fragments that are not referenced from any operation. + #[clap(name = "unused-fragments")] + UnusedFragments(AnalyzeUnusedFragmentsCommand), + /// List fragments by spread usage count (most used first). #[clap(name = "fragment-usage")] FragmentUsage(AnalyzeFragmentUsageCommand), - /// Find unused schema fields in Relay operations. + /// Rename a fragment and update all of its spread sites. + #[clap(name = "rename-fragment")] + RenameFragment(AnalyzeRenameFragmentCommand), + + /// Find schema fields never referenced in any Relay operations or fragments. #[clap(name = "schema-dce")] SchemaDce(AnalyzeSchemaDceCommand), @@ -66,9 +84,18 @@ pub async fn handle_analyze_command(command: AnalyzeCommand) -> Result<(), Error AnalyzeSubcommand::FragmentDependents(command) => { fragment_dependents::handle_analyze_fragment_dependents_command(command).await } + AnalyzeSubcommand::DeprecatedUsage(command) => { + deprecated_usage::handle_analyze_deprecated_usage_command(command).await + } + AnalyzeSubcommand::UnusedFragments(command) => { + unused_fragments::handle_analyze_unused_fragments_command(command).await + } AnalyzeSubcommand::FragmentUsage(command) => { fragment_usage::handle_analyze_fragment_usage_command(command).await } + AnalyzeSubcommand::RenameFragment(command) => { + rename_fragment::handle_analyze_rename_fragment_command(command).await + } AnalyzeSubcommand::ExecutableDefinitions(command) => { executable_definitions::handle_analyze_executable_definitions_command(command).await } diff --git a/compiler/crates/relay-bin/src/analyze/rename_fragment.rs b/compiler/crates/relay-bin/src/analyze/rename_fragment.rs new file mode 100644 index 0000000000000..9eeb72ef47b1c --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/rename_fragment.rs @@ -0,0 +1,371 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use clap::Parser; +use common::{ConsoleLogger, Location}; +use graphql_ir::FragmentDefinitionName; +use graphql_syntax::parse_executable_with_error_recovery; +use intern::string_key::Intern; +use lsp_types::Range; +use relay_compiler::{get_programs, FsSourceReader, ProjectName, source_for_location}; +use relay_lsp::rename::{create_rename_request, get_locations_for_rename}; +use relay_lsp::{position_to_offset, Feature}; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ + AnalyzeRange, + ensure_single_project_config, + normalize_range, + print_json_report, +}; + +type AnalyzeRenameFragmentLocation = AnalyzeRange; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Rename a fragment definition and all of its spread sites." +)] +pub(crate) struct AnalyzeRenameFragmentCommand { + /// The current fragment name. + old_fragment: String, + + /// The new fragment name. + new_fragment: String, + + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Show what would change without modifying files. + #[clap(long = "dry-run", alias = "dryRun")] + dry_run: bool, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeRenameFragmentReport { + project: String, + old_fragment: String, + new_fragment: String, + dry_run: bool, + file_count: usize, + match_count: usize, + files: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeRenameFragmentFile { + filename: String, + replacement_count: usize, + locations: Vec, +} + +pub(crate) async fn handle_analyze_rename_fragment_command( + command: AnalyzeRenameFragmentCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let old_fragment = parse_fragment_name(&command.old_fragment)?; + let new_fragment = parse_fragment_name(&command.new_fragment)?; + let json = command.json; + let dry_run = command.dry_run; + + if old_fragment == new_fragment { + return Err(Error::AnalyzeError { + details: "old-fragment and new-fragment must be different.".to_string(), + }); + } + + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + + analyze_project_rename_fragment( + project_name, + program.as_ref(), + &config.root_dir, + old_fragment, + new_fragment, + dry_run, + json, + )?; + + Ok(()) +} + +fn parse_fragment_name(fragment_name: &str) -> Result { + let fragment_name = fragment_name.trim(); + if fragment_name.is_empty() { + return Err(Error::AnalyzeError { + details: "A fragment name is required.".to_string(), + }); + } + + Ok(FragmentDefinitionName(fragment_name.intern())) +} + +fn analyze_project_rename_fragment( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + old_fragment: FragmentDefinitionName, + new_fragment: FragmentDefinitionName, + dry_run: bool, + json: bool, +) -> Result<(), Error> { + let old_fragment_name = old_fragment.to_string(); + let new_fragment_name = new_fragment.to_string(); + + let source_fragment = programs + .source + .fragment(old_fragment) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Fragment `{old_fragment_name}` was not found in source documents."), + })?; + + let definition_location = source_fragment.name.location; + let rename_request = create_fragment_rename_request(root_dir, definition_location)?; + + let mut rename_locations = get_locations_for_rename(rename_request, &programs.source).map_err(|err| { + Error::AnalyzeError { + details: format!("Unable to resolve rename locations for `{old_fragment_name}`: {err:?}"), + } + })?; + + if rename_locations.is_empty() { + return Err(Error::AnalyzeError { + details: format!("Fragment `{old_fragment_name}` has no rename targets."), + }); + } + + // Stable output order. + rename_locations.sort_by(|left, right| { + left.source_location() + .path() + .cmp(right.source_location().path()) + .then(left.span().start.cmp(&right.span().start)) + }); + + let mut locations_by_file: HashMap> = HashMap::default(); + for location in rename_locations { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader).ok_or_else(|| { + Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for rename target.", + source_location.path() + ), + } + })?; + let range = source.text_source().to_span_range(location.span()); + locations_by_file + .entry(source_location.path().to_string()) + .or_default() + .push(range); + } + + let mut file_changes = Vec::new(); + for (filename, mut ranges) in locations_by_file { + if dry_run { + ranges.sort_by(|left, right| { + (left.start.line, left.start.character).cmp(&(right.start.line, right.start.character)) + }); + + file_changes.push(AnalyzeRenameFragmentFile { + filename, + replacement_count: ranges.len(), + locations: ranges + .into_iter() + .map(|range| normalize_range(&range)) + .collect(), + }); + + continue; + } + + let absolute_path = root_dir.join(&filename); + let mut source_text = fs::read_to_string(&absolute_path).map_err(|err| Error::AnalyzeError { + details: format!("Unable to read file '{}': {err}", absolute_path.display()), + })?; + + let mut replacements = Vec::with_capacity(ranges.len()); + for range in ranges.drain(..) { + let start = position_to_offset(&range.start, 0, 0, &source_text) + .and_then(|offset| usize::try_from(offset).ok()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to map rename target line/column in '{}': {}:{}-{}:{}", + filename, + range.start.line + 1, + range.start.character + 1, + range.end.line + 1, + range.end.character + 1 + ), + })?; + + let end = position_to_offset(&range.end, 0, 0, &source_text) + .and_then(|offset| usize::try_from(offset).ok()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to map rename target line/column in '{}': {}:{}-{}:{}", + filename, + range.start.line + 1, + range.start.character + 1, + range.end.line + 1, + range.end.character + 1 + ), + })?; + + if start > end { + return Err(Error::AnalyzeError { + details: format!( + "Unable to apply rename in '{}': invalid range {}:{}-{}:{}", + filename, + range.start.line + 1, + range.start.character + 1, + range.end.line + 1, + range.end.character + 1 + ), + }); + } + + replacements.push((start, end, range)); + } + + replacements.sort_by(|left, right| right.0.cmp(&left.0)); + for (start, end, _) in replacements.iter() { + source_text.replace_range(*start..*end, &new_fragment_name); + } + + fs::write(&absolute_path, source_text).map_err(|err| Error::AnalyzeError { + details: format!("Unable to write file '{}': {err}", absolute_path.display()), + })?; + + replacements.sort_by(|left, right| { + (left.2.start.line, left.2.start.character).cmp(&(right.2.start.line, right.2.start.character)) + }); + + file_changes.push(AnalyzeRenameFragmentFile { + filename, + replacement_count: replacements.len(), + locations: replacements + .into_iter() + .map(|(_, _, range)| normalize_range(&range)) + .collect(), + }); + } + + file_changes.sort_by(|left, right| left.filename.cmp(&right.filename)); + let match_count: usize = file_changes.iter().map(|entry| entry.replacement_count).sum(); + + let report = AnalyzeRenameFragmentReport { + project: project_name.to_string(), + old_fragment: old_fragment_name, + new_fragment: new_fragment_name, + dry_run, + file_count: file_changes.len(), + match_count, + files: file_changes, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_rename_fragment_text_report(&report); + } + + Ok(()) +} + +fn create_fragment_rename_request( + root_dir: &Path, + definition_location: Location, +) -> Result { + let source_location = definition_location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader).ok_or_else(|| { + Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for fragment definition.", + source_location.path() + ), + } + })?; + + let source_text = source.text_source().text.to_string(); + let feature = Feature::ExecutableDocument(parse_executable_with_error_recovery( + &source_text, + source_location, + ) + .item); + + create_rename_request(feature, definition_location).map_err(|err| Error::AnalyzeError { + details: format!( + "Unable to prepare rename request for source '{}': {err:?}", + source_location.path() + ), + }) +} + +fn print_analyze_rename_fragment_text_report(report: &AnalyzeRenameFragmentReport) { + if report.match_count == 0 { + println!( + "Project {}: no changes made ({} not found).", + report.project, report.old_fragment + ); + return; + } + + let action = if report.dry_run { + "would rename" + } else { + "renamed" + }; + let writes = if report.dry_run { "(no files written)" } else { "" }; + + println!( + "Project {}: {} {} -> {} in {} location(s) across {} file(s) {}", + report.project, + action, + report.old_fragment, + report.new_fragment, + report.match_count, + report.file_count, + writes + ); + + for file in &report.files { + println!(" {} ({} location(s))", file.filename, file.replacement_count); + for location in &file.locations { + println!( + " {}:{}-{}:{}", + location.start_line, + location.start_column, + location.end_line, + location.end_column + ); + } + } +} diff --git a/compiler/crates/relay-bin/src/analyze/schema_dce.rs b/compiler/crates/relay-bin/src/analyze/schema_dce.rs index 35885877b91c3..61dc003c97526 100644 --- a/compiler/crates/relay-bin/src/analyze/schema_dce.rs +++ b/compiler/crates/relay-bin/src/analyze/schema_dce.rs @@ -18,7 +18,7 @@ use schema::Type; use crate::errors::Error; use crate::{get_config, set_project_flag}; -use super::utils::{ensure_single_project_config, print_json_report}; +use super::utils::{apply_limit, ensure_single_project_config, print_json_report}; #[derive(Parser)] #[clap( @@ -426,7 +426,7 @@ fn analyze_project_dead_fields( } } - let mut dead_fields = dead_fields_by_type + let dead_fields = dead_fields_by_type .into_values() .map(|mut entry| { entry.dead_fields.sort_unstable(); @@ -442,20 +442,18 @@ fn analyze_project_dead_fields( .iter() .map(|entry| entry.dead_union_members.len()) .sum(); - let total_count = dead_fields.len(); - let truncated = total_count > limit; - dead_fields.truncate(limit); + let limited_dead_fields = apply_limit(dead_fields, limit); let mut report = AnalyzeSchemaDceReport { project: project_name.to_string(), - dead_fields, + dead_fields: limited_dead_fields.entries, dead_field_count: 0, dead_union_member_count: 0, - total_count, + total_count: limited_dead_fields.total_count, total_dead_field_count, total_dead_union_member_count, limit, - truncated, + truncated: limited_dead_fields.truncated, }; report.dead_field_count = report diff --git a/compiler/crates/relay-bin/src/analyze/unused_fragments.rs b/compiler/crates/relay-bin/src/analyze/unused_fragments.rs new file mode 100644 index 0000000000000..4d2b756f7b575 --- /dev/null +++ b/compiler/crates/relay-bin/src/analyze/unused_fragments.rs @@ -0,0 +1,246 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::Path; + +use clap::Parser; +use common::ConsoleLogger; +use graphql_ir::FragmentDefinitionName; +use graphql_ir::Selection; +use relay_compiler::{ProjectName, get_programs}; +use serde::Serialize; + +use crate::errors::Error; +use crate::{get_config, set_project_flag}; + +use super::utils::{ + apply_limit, + ensure_single_project_config, + print_json_report, + source_location_to_analyze_location, + AnalyzeLocation, +}; + +#[derive(Parser)] +#[clap( + rename_all = "camel_case", + about = "Find fragment definitions that are not referenced by any operation." +)] +pub(crate) struct AnalyzeUnusedFragmentsCommand { + /// Analyze only this project. You can pass this argument multiple times. + /// Currently, only single-project configs are supported. + #[clap(name = "project", long, short)] + projects: Vec, + + /// Limit the number of fragments returned. + #[clap(long, default_value_t = 100)] + limit: usize, + + /// Emit JSON output. + #[clap(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeUnusedFragmentsReport { + project: String, + match_count: usize, + total_count: usize, + limit: usize, + truncated: bool, + fragments: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AnalyzeUnusedFragmentMatch { + fragment_name: String, + reason: String, + location: AnalyzeUnusedFragmentLocation, +} + +type AnalyzeUnusedFragmentLocation = AnalyzeLocation; + +pub(crate) async fn handle_analyze_unused_fragments_command( + command: AnalyzeUnusedFragmentsCommand, +) -> Result<(), Error> { + let mut config = get_config(None)?; + let project_name = ensure_single_project_config(&config)?; + let limit = command.limit; + let json = command.json; + set_project_flag(&mut config, command.projects)?; + + let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; + if programs_by_project.is_empty() { + return Err(Error::AnalyzeError { + details: "No programs were produced by analyze.".to_string(), + }); + } + + let program = programs_by_project + .get(&project_name) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Project {project_name} was not built for analyze."), + })?; + analyze_project_unused_fragments( + project_name, + program.as_ref(), + &config.root_dir, + limit, + json, + )?; + Ok(()) +} + +fn analyze_project_unused_fragments( + project_name: ProjectName, + programs: &relay_transforms::Programs, + root_dir: &Path, + limit: usize, + json: bool, +) -> Result<(), Error> { + let mut outgoing: HashMap> = HashMap::default(); + let mut inbound: HashMap> = HashMap::default(); + let mut used_fragments: HashSet = HashSet::default(); + let mut operation_level_spreads = Vec::new(); + for operation in programs.source.operations() { + let mut spreads = Vec::new(); + collect_fragment_spread_references(&operation.selections, &mut spreads); + for fragment_name in spreads { + if used_fragments.insert(fragment_name) { + operation_level_spreads.push(fragment_name); + } + } + } + + for fragment in programs.source.fragments() { + let mut spreads = Vec::new(); + collect_fragment_spread_references(&fragment.selections, &mut spreads); + let current_fragment_name = fragment.name.item; + outgoing.insert(current_fragment_name, spreads.clone()); + + for target_fragment_name in spreads { + inbound + .entry(target_fragment_name) + .or_default() + .push(current_fragment_name); + } + } + + let mut queue: VecDeque = VecDeque::from_iter(operation_level_spreads); + + while let Some(current_fragment_name) = queue.pop_front() { + for dependent_fragment_name in outgoing + .get(¤t_fragment_name) + .into_iter() + .flatten() + { + if used_fragments.insert(*dependent_fragment_name) { + queue.push_back(*dependent_fragment_name); + } + } + } + + let mut fragments = Vec::new(); + for fragment in programs.source.fragments() { + if used_fragments.contains(&fragment.name.item) { + continue; + } + + let reason = if inbound.get(&fragment.name.item).is_some() { + "only-deeply-referenced" + } else { + "unused" + }; + + let location = source_location_to_analyze_location( + root_dir, + &fragment.name.location, + "fragment definition", + )?; + fragments.push(AnalyzeUnusedFragmentMatch { + fragment_name: fragment.name.item.to_string(), + reason: reason.to_string(), + location, + }); + } + + fragments.sort_by(|a, b| { + a.reason + .cmp(&b.reason) + .then(a.fragment_name.cmp(&b.fragment_name)) + }); + + let limited_fragments = apply_limit(fragments, limit); + + let report = AnalyzeUnusedFragmentsReport { + project: project_name.to_string(), + match_count: limited_fragments.match_count, + total_count: limited_fragments.total_count, + limit, + truncated: limited_fragments.truncated, + fragments: limited_fragments.entries, + }; + + if json { + print_json_report(&report)?; + } else { + print_analyze_unused_fragments_text_report(&report); + } + + Ok(()) +} + +fn collect_fragment_spread_references( + selections: &[Selection], + spreads: &mut Vec, +) { + for selection in selections { + match selection { + Selection::FragmentSpread(spread) => { + spreads.push(spread.fragment.item); + } + Selection::Condition(condition) => { + collect_fragment_spread_references(&condition.selections, spreads); + } + Selection::InlineFragment(inline_fragment) => { + collect_fragment_spread_references(&inline_fragment.selections, spreads); + } + Selection::LinkedField(linked_field) => { + collect_fragment_spread_references(&linked_field.selections, spreads); + } + Selection::ScalarField(_) => {} + } + } +} + +fn print_analyze_unused_fragments_text_report(report: &AnalyzeUnusedFragmentsReport) { + if report.match_count == 0 { + println!("Project {}: no unused fragments found.", report.project); + return; + } + + println!( + "Project {}: {} unused fragment(s).", + report.project, report.match_count + ); + + if report.truncated { + println!( + " showing {} of {} fragment(s) (use --limit to see more).", + report.match_count, report.total_count + ); + } + + for entry in &report.fragments { + println!( + " [{}] {} @ {}:{}:{}-{}:{}", + entry.reason, + entry.fragment_name, + entry.location.filename, + entry.location.start_line, + entry.location.start_column, + entry.location.end_line, + entry.location.end_column + ); + } +} diff --git a/compiler/crates/relay-bin/src/analyze/utils.rs b/compiler/crates/relay-bin/src/analyze/utils.rs index 036bec91d52aa..0dcfda83784f8 100644 --- a/compiler/crates/relay-bin/src/analyze/utils.rs +++ b/compiler/crates/relay-bin/src/analyze/utils.rs @@ -1,5 +1,10 @@ +use std::path::Path; + +use common::Location; +use lsp_types::Range; use relay_compiler::config::Config; use relay_compiler::ProjectName; +use relay_compiler::{source_for_location, FsSourceReader}; use serde::Serialize; use crate::errors::Error; @@ -30,3 +35,117 @@ pub(crate) fn print_json_report(report: &T) -> Result<(), Error> { println!("{}", json_output); Ok(()) } + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AnalyzeLocation { + pub filename: String, + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AnalyzeRange { + pub start_line: u32, + pub start_column: u32, + pub end_line: u32, + pub end_column: u32, +} + +pub(crate) fn source_location_to_analyze_location( + root_dir: &Path, + location: &Location, + context: &str, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader).ok_or_else(|| { + Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for {context}.", + source_location.path() + ), + } + })?; + let source_text = source.text_source(); + let range = source_text.to_span_range(location.span()); + + Ok(AnalyzeLocation { + filename: source_location.path().to_string(), + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + }) +} + +pub(crate) fn source_line_for_reference( + root_dir: &Path, + location: &Location, + context: &str, +) -> Result { + let source_location = location.source_location(); + let source = source_for_location(root_dir, source_location, &FsSourceReader).ok_or_else(|| { + Error::AnalyzeError { + details: format!( + "Unable to load source location '{}' for snippet lookup ({context}).", + source_location.path() + ), + } + })?; + let text_source = source.text_source(); + let range = text_source.to_span_range(location.span()); + let local_line = range + .start + .line + .checked_sub(text_source.line_index as u32) + .ok_or_else(|| Error::AnalyzeError { + details: format!("Unable to resolve snippet line for {}.", source_location.path()), + })?; + text_source + .text + .lines() + .nth(local_line as usize) + .map(|line| line.to_string()) + .ok_or_else(|| Error::AnalyzeError { + details: format!( + "Unable to resolve snippet line for {}:{}.", + source_location.path(), + range.start.line + 1 + ), + }) +} + +pub(crate) fn normalize_range(range: &Range) -> AnalyzeRange { + AnalyzeRange { + start_line: range.start.line + 1, + start_column: range.start.character + 1, + end_line: range.end.line + 1, + end_column: range.end.character + 1, + } +} + +#[derive(Debug)] +pub(crate) struct AnalyzeLimitResult { + pub entries: Vec, + pub match_count: usize, + pub total_count: usize, + pub truncated: bool, +} + +pub(crate) fn apply_limit(entries: Vec, limit: usize) -> AnalyzeLimitResult { + let total_count = entries.len(); + let mut entries = entries; + let truncated = total_count > limit; + entries.truncate(limit); + let match_count = entries.len(); + + AnalyzeLimitResult { + entries, + match_count, + total_count, + truncated, + } +} From 4c6503f244aaa45beb53f75016f3b243b17efb5a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Wed, 18 Mar 2026 10:21:14 +0100 Subject: [PATCH 7/7] refactor commands --- .../relay-bin/src/analyze/deprecated_usage.rs | 11 +- .../src/analyze/executable_definitions.rs | 13 +- .../relay-bin/src/analyze/find_references.rs | 11 +- .../src/analyze/fragment_dependents.rs | 11 +- .../relay-bin/src/analyze/fragment_usage.rs | 11 +- compiler/crates/relay-bin/src/analyze/mod.rs | 171 ++++++++++++++++-- .../relay-bin/src/analyze/print_operation.rs | 11 +- .../relay-bin/src/analyze/rename_fragment.rs | 12 +- .../relay-bin/src/analyze/schema_dce.rs | 11 +- .../relay-bin/src/analyze/unused_fragments.rs | 11 +- compiler/crates/relay-bin/src/main.rs | 1 + 11 files changed, 189 insertions(+), 85 deletions(-) diff --git a/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs b/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs index 828e1ab7da69d..77c7129ba5938 100644 --- a/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs +++ b/compiler/crates/relay-bin/src/analyze/deprecated_usage.rs @@ -17,13 +17,10 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find deprecated fields, arguments, and directives in executable definitions." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeDeprecatedUsageCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -64,10 +61,10 @@ pub(crate) async fn handle_analyze_deprecated_usage_command( command: AnalyzeDeprecatedUsageCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let json = command.json; let limit = command.limit; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs index 482ba71c786b6..725ee1df4ea97 100644 --- a/compiler/crates/relay-bin/src/analyze/executable_definitions.rs +++ b/compiler/crates/relay-bin/src/analyze/executable_definitions.rs @@ -19,13 +19,10 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find operations and fragments by selection size/depth." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeExecutableDefinitionsCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -86,12 +83,12 @@ pub(crate) async fn handle_analyze_executable_definitions_command( command: AnalyzeExecutableDefinitionsCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; - let project_name = ensure_single_project_config(&config)?; set_project_flag(&mut config, command.projects)?; + let project_name = ensure_single_project_config(&config)?; if command.min_selection_lines.is_none() && command.min_selection_depth.is_none() { return Err(Error::AnalyzeError { - details: "At least one executable-definitions criterion must be provided." + details: "At least one definition-audit criterion must be provided." .into(), }); } diff --git a/compiler/crates/relay-bin/src/analyze/find_references.rs b/compiler/crates/relay-bin/src/analyze/find_references.rs index e74822f8bb9e1..ee0835271b9cf 100644 --- a/compiler/crates/relay-bin/src/analyze/find_references.rs +++ b/compiler/crates/relay-bin/src/analyze/find_references.rs @@ -26,10 +26,7 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find references for schema type/field paths." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeFindReferencesCommand { /// A schema path: either `Type` or `Type.field`. payload: String, @@ -38,8 +35,8 @@ pub(crate) struct AnalyzeFindReferencesCommand { #[clap(long = "with-snippet")] with_snippet: bool, - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -93,12 +90,12 @@ pub(crate) async fn handle_analyze_find_references_command( command: AnalyzeFindReferencesCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let payload = parse_find_references_payload(&command.payload)?; let with_snippet = command.with_snippet; let limit = command.limit; let json = command.json; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs index a40ccb64eb011..8e9fc0b00e625 100644 --- a/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs +++ b/compiler/crates/relay-bin/src/analyze/fragment_dependents.rs @@ -24,10 +24,7 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find direct dependents of a fragment (operations/fragments that spread it)." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeFragmentDependentsCommand { /// The name of the fragment to find dependents for. fragment: String, @@ -36,8 +33,8 @@ pub(crate) struct AnalyzeFragmentDependentsCommand { #[clap(long = "with-snippet")] with_snippet: bool, - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -94,13 +91,13 @@ pub(crate) async fn handle_analyze_fragment_dependents_command( command: AnalyzeFragmentDependentsCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let root_fragment = parse_fragment_name(&command.fragment)?; let with_snippet = command.with_snippet; let include_transitive = command.transitive; let limit = command.limit; let json = command.json; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/fragment_usage.rs b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs index 0467912d32b64..ead67c21beb3f 100644 --- a/compiler/crates/relay-bin/src/analyze/fragment_usage.rs +++ b/compiler/crates/relay-bin/src/analyze/fragment_usage.rs @@ -21,13 +21,10 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "List fragments sorted by spread usage count." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeFragmentUsageCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -83,6 +80,7 @@ pub(crate) async fn handle_analyze_fragment_usage_command( command: AnalyzeFragmentUsageCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let sort = match command.sort.as_str() { "usage-desc" => AnalyzeFragmentUsageSort::UsageDesc, @@ -101,7 +99,6 @@ pub(crate) async fn handle_analyze_fragment_usage_command( details: "min-usage must be greater than zero.".into(), }); } - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/mod.rs b/compiler/crates/relay-bin/src/analyze/mod.rs index 5360686cf6dc1..42dd7bd025f73 100644 --- a/compiler/crates/relay-bin/src/analyze/mod.rs +++ b/compiler/crates/relay-bin/src/analyze/mod.rs @@ -24,49 +24,180 @@ use schema_dce::AnalyzeSchemaDceCommand; use unused_fragments::AnalyzeUnusedFragmentsCommand; #[derive(Parser)] -#[clap(rename_all = "snake_case", about = "Schema analysis helpers.")] +#[clap( + rename_all = "snake_case", + about = "Relay project analysis helpers for audits, impact analysis, and targeted refactors.", + after_help = "Use `relay tools --help` for detailed guidance and examples.\nYou can also run `relay tools help `.", + after_long_help = "Use `relay tools --help` for detailed guidance and examples.\nYou can also run `relay tools help `." +)] pub struct AnalyzeCommand { - /// Schema analysis commands. + /// GraphQL/Relay tooling commands. #[clap(subcommand)] command: AnalyzeSubcommand, } #[derive(clap::Subcommand)] enum AnalyzeSubcommand { - /// Find references for a schema path. - #[clap(name = "find-references")] + /// Find schema type/field references before renames, deprecations, or removals. + #[clap( + name = "find-schema-references", + about = "Find schema type/field references before renames, deprecations, or removals.", + long_about = "\ +Find references for a schema type or field path. + +Use this when you need impact analysis before renaming, deprecating, or removing part of the schema. +Good fit: trace every use of `User.name`, or find all type conditions that mention `User`. +Not a good fit: use `fragment-dependents` for fragment spread impact, or `deprecated-usage` for a broad deprecation sweep. + +Examples: + relay tools find-schema-references User.name + relay tools find-schema-references User --with-snippet + relay tools find-schema-references User.name --json" + )] FindReferences(AnalyzeFindReferencesCommand), - /// Print the full text for a named GraphQL operation. - #[clap(name = "print-operation")] + /// Print one transformed operation with all reachable fragments inlined into the output. + #[clap( + name = "print-operation", + about = "Print the executable GraphQL text for one named Relay operation.", + long_about = "\ +Print the executable GraphQL text for one named Relay operation. + +Use this when you want to inspect the exact executable operation Relay produces after pulling in reachable fragments and inlining the Relay-managed pieces into the final GraphQL text. +Good fit: debugging the final query shape, sharing the full runnable GraphQL text in a review, or comparing two operations with similar names. +Not a good fit: use `find-schema-references` for impact analysis, or `definition-audit` to scan many operations/fragments at once. + +Examples: + relay tools print-operation UserProfileQuery + relay tools print-operation UserProfileQuery --json" + )] PrintOperation(AnalyzePrintOperationCommand), - /// Find all dependent operations and fragments for a fragment. - #[clap(name = "fragment-dependents")] + /// Find which operations and fragments depend on a fragment before changing it. + #[clap( + name = "fragment-dependents", + about = "Find direct or transitive dependents of a fragment.", + long_about = "\ +Find direct or transitive dependents of a fragment. + +Use this when you are changing a fragment and need to understand its blast radius before editing or deleting it. +Good fit: see which operations will change if `UserCard_user` changes, or walk the full dependency chain with `--transitive`. +Not a good fit: use `find-schema-references` for schema field usage, or `rename-fragment` when you already know you want a mechanical rename. + +Examples: + relay tools fragment-dependents UserCard_user + relay tools fragment-dependents UserCard_user --transitive + relay tools fragment-dependents UserCard_user --with-snippet --json" + )] FragmentDependents(AnalyzeFragmentDependentsCommand), - /// Find deprecated fields, arguments, and directives in executable documents. - #[clap(name = "deprecated-usage")] + /// Find deprecated schema usage to plan or verify migrations. + #[clap( + name = "deprecated-usage", + about = "Find deprecated schema usage to plan or verify migrations.", + long_about = "\ +Find deprecated fields, arguments, and directives in executable definitions. + +Use this when you are preparing a schema cleanup, auditing migration work, or verifying that deprecations are gone from Relay documents. +Good fit: enumerate every deprecated field still in use before deleting it from the schema. +Not a good fit: use `find-schema-references` for one specific field, or `unused-schema-members` for schema members that are never used at all. + +Examples: + relay tools deprecated-usage + relay tools deprecated-usage --limit 200 + relay tools deprecated-usage --json" + )] DeprecatedUsage(AnalyzeDeprecatedUsageCommand), - /// Find fragments that are not referenced from any operation. - #[clap(name = "unused-fragments")] + /// Find fragments that are not reachable from any operation. + #[clap( + name = "unused-fragments", + about = "Find fragments that are not reachable from any operation.", + long_about = "\ +Find fragment definitions that are not reachable from any operation. + +Use this when you want to clean up dead Relay code after feature removal or confirm that a fragment is no longer part of any shipped query path. +Good fit: identify fragments that can likely be deleted or reviewed for removal. +Not a good fit: use `fragment-spread-usage` to rank fragments by spread count, because low usage and unused are not the same thing. + +Examples: + relay tools unused-fragments + relay tools unused-fragments --limit 50 + relay tools unused-fragments --json" + )] UnusedFragments(AnalyzeUnusedFragmentsCommand), - /// List fragments by spread usage count (most used first). - #[clap(name = "fragment-usage")] + /// Rank fragments by spread count to spot shared hot spots or low-value fragments. + #[clap( + name = "fragment-spread-usage", + about = "List fragments sorted by fragment spread usage count.", + long_about = "\ +List fragments sorted by spread usage count. + +Use this when you want to find heavily shared fragments that may be doing too much, or lightly used fragments that may not justify their abstraction. +Good fit: rank fragments to spot refactor hot spots, shared leaf fragments, or candidates to inline. +Not a good fit: use `unused-fragments` for deletion safety, because a fragment can have usages and still be unreachable from any operation. + +Examples: + relay tools fragment-spread-usage + relay tools fragment-spread-usage --sort usage-asc --min-usage 1 + relay tools fragment-spread-usage --limit 25 --json" + )] FragmentUsage(AnalyzeFragmentUsageCommand), - /// Rename a fragment and update all of its spread sites. - #[clap(name = "rename-fragment")] + /// Rename a fragment definition and all spread sites when the change is mechanical. + #[clap( + name = "rename-fragment", + about = "Rename a fragment definition and all of its spread sites.", + long_about = "\ +Rename a fragment definition and all of its spread sites. + +Use this when the change is a mechanical rename and you want Relay-aware updates across the codebase. +Good fit: renaming `UserInfo_user` to `UserProfile_user`, especially with `--dry-run` first to inspect the edit set. +Important: this renames the fragment definition and spread sites, but it does not rename the source file. If your project expects fragment names and filenames to stay aligned, you must rename the file too or the result will likely be invalid. +Not a good fit: do not use this for semantic refactors such as splitting a fragment, changing its type condition, or redesigning ownership boundaries. + +Examples: + relay tools rename-fragment UserInfo_user UserProfile_user --dry-run + relay tools rename-fragment UserInfo_user UserProfile_user + relay tools rename-fragment UserInfo_user UserProfile_user --json" + )] RenameFragment(AnalyzeRenameFragmentCommand), - /// Find schema fields never referenced in any Relay operations or fragments. - #[clap(name = "schema-dce")] + /// Find schema fields and union members unused by Relay documents in this project. + #[clap( + name = "unused-schema-members", + about = "Find schema fields and union members unused by Relay documents.", + long_about = "\ +Find schema fields and union members unused by Relay documents. + +Use this when you are auditing schema surface area from the Relay client's point of view and want cleanup candidates. +Good fit: find fields that no Relay operation or fragment in this project references before proposing schema deletions. +Not a good fit: this is not proof that other clients, servers, or future work do not need a field, so treat it as Relay-specific evidence. + +Examples: + relay tools unused-schema-members + relay tools unused-schema-members --limit 25 + relay tools unused-schema-members --json" + )] SchemaDce(AnalyzeSchemaDceCommand), - /// Find operations/fragments by selection size/depth. - #[clap(name = "executable-definitions")] + /// Find large or deeply nested operations/fragments that are refactor candidates. + #[clap( + name = "definition-audit", + about = "Audit operations and fragments by selection size or nesting depth.", + long_about = "\ +Find operations and fragments by selection size or nesting depth. + +Use this when you want to scan a codebase for oversized executable definitions that may need refactoring. +Good fit: go through all fragments and flag ones that are too large or too deeply nested; large matches are often opportunities to split work into smaller, more focused components. +Not a good fit: this is a structural heuristic, not a runtime cost model, so do not treat it as an exact performance measurement. + +Examples: + relay tools definition-audit --min-selection-lines 40 + relay tools definition-audit --min-selection-depth 5 + relay tools definition-audit --min-selection-lines 30 --min-selection-depth 4 --json" + )] ExecutableDefinitions(AnalyzeExecutableDefinitionsCommand), } diff --git a/compiler/crates/relay-bin/src/analyze/print_operation.rs b/compiler/crates/relay-bin/src/analyze/print_operation.rs index ccb87388eb8c0..05ceb8b599dbe 100644 --- a/compiler/crates/relay-bin/src/analyze/print_operation.rs +++ b/compiler/crates/relay-bin/src/analyze/print_operation.rs @@ -17,16 +17,13 @@ use crate::{get_config, set_project_flag}; use super::utils::{ensure_single_project_config, print_json_report}; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Print the full text for a named GraphQL operation." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzePrintOperationCommand { /// The name of the operation to print. operation: String, - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -47,10 +44,10 @@ pub(crate) async fn handle_analyze_print_operation_command( command: AnalyzePrintOperationCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let operation_name = command.operation; let json = command.json; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/rename_fragment.rs b/compiler/crates/relay-bin/src/analyze/rename_fragment.rs index 9eeb72ef47b1c..750247dccacde 100644 --- a/compiler/crates/relay-bin/src/analyze/rename_fragment.rs +++ b/compiler/crates/relay-bin/src/analyze/rename_fragment.rs @@ -27,10 +27,7 @@ use super::utils::{ type AnalyzeRenameFragmentLocation = AnalyzeRange; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Rename a fragment definition and all of its spread sites." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeRenameFragmentCommand { /// The current fragment name. old_fragment: String, @@ -38,8 +35,8 @@ pub(crate) struct AnalyzeRenameFragmentCommand { /// The new fragment name. new_fragment: String, - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -76,6 +73,7 @@ pub(crate) async fn handle_analyze_rename_fragment_command( command: AnalyzeRenameFragmentCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let old_fragment = parse_fragment_name(&command.old_fragment)?; let new_fragment = parse_fragment_name(&command.new_fragment)?; @@ -88,8 +86,6 @@ pub(crate) async fn handle_analyze_rename_fragment_command( }); } - set_project_flag(&mut config, command.projects)?; - let (programs_by_project, _, config) = get_programs(config, Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { return Err(Error::AnalyzeError { diff --git a/compiler/crates/relay-bin/src/analyze/schema_dce.rs b/compiler/crates/relay-bin/src/analyze/schema_dce.rs index 61dc003c97526..7c43ba9f3f227 100644 --- a/compiler/crates/relay-bin/src/analyze/schema_dce.rs +++ b/compiler/crates/relay-bin/src/analyze/schema_dce.rs @@ -21,13 +21,10 @@ use crate::{get_config, set_project_flag}; use super::utils::{apply_limit, ensure_single_project_config, print_json_report}; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find unused schema fields in Relay operations." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeSchemaDceCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -70,10 +67,10 @@ pub(crate) async fn handle_analyze_schema_dce_command( command: AnalyzeSchemaDceCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let limit = command.limit; let json = command.json; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, _config) = get_programs(config, Arc::new(common::ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/analyze/unused_fragments.rs b/compiler/crates/relay-bin/src/analyze/unused_fragments.rs index 4d2b756f7b575..1cf6777aafea0 100644 --- a/compiler/crates/relay-bin/src/analyze/unused_fragments.rs +++ b/compiler/crates/relay-bin/src/analyze/unused_fragments.rs @@ -20,13 +20,10 @@ use super::utils::{ }; #[derive(Parser)] -#[clap( - rename_all = "camel_case", - about = "Find fragment definitions that are not referenced by any operation." -)] +#[clap(rename_all = "camel_case")] pub(crate) struct AnalyzeUnusedFragmentsCommand { - /// Analyze only this project. You can pass this argument multiple times. - /// Currently, only single-project configs are supported. + /// Analyze only this project. + /// This exists for compatibility with multi-project Relay configs. #[clap(name = "project", long, short)] projects: Vec, @@ -64,10 +61,10 @@ pub(crate) async fn handle_analyze_unused_fragments_command( command: AnalyzeUnusedFragmentsCommand, ) -> Result<(), Error> { let mut config = get_config(None)?; + set_project_flag(&mut config, command.projects)?; let project_name = ensure_single_project_config(&config)?; let limit = command.limit; let json = command.json; - set_project_flag(&mut config, command.projects)?; let (programs_by_project, _, config) = get_programs(config, std::sync::Arc::new(ConsoleLogger)).await; if programs_by_project.is_empty() { diff --git a/compiler/crates/relay-bin/src/main.rs b/compiler/crates/relay-bin/src/main.rs index e150e587b2b96..08bf934195faa 100644 --- a/compiler/crates/relay-bin/src/main.rs +++ b/compiler/crates/relay-bin/src/main.rs @@ -154,6 +154,7 @@ enum Commands { Lsp(LspCommand), ConfigJsonSchema(ConfigJsonSchemaCommand), Codemod(CodemodCommand), + #[clap(name = "tools", alias = "analyze")] Analyze(analyze::AnalyzeCommand), }