From 5e7440cef3dd045a905e6156bf91dcc367d31385 Mon Sep 17 00:00:00 2001 From: "Vitaly D." Date: Mon, 8 Jun 2026 14:46:52 +0300 Subject: [PATCH] refactor(repo-graph): move node detection into module --- src/core/repo_graph.rs | 288 +---------------------------------- src/core/repo_graph/node.rs | 290 ++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 286 deletions(-) create mode 100644 src/core/repo_graph/node.rs diff --git a/src/core/repo_graph.rs b/src/core/repo_graph.rs index 67a1fe3..16aba80 100644 --- a/src/core/repo_graph.rs +++ b/src/core/repo_graph.rs @@ -1,4 +1,3 @@ -use serde_json::Value as JsonValue; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -7,6 +6,7 @@ use toml::Value as TomlValue; mod generic; mod go; mod impact; +mod node; mod types; pub use impact::analyze_impact; @@ -18,7 +18,7 @@ pub fn inspect_repo(repo_path: impl AsRef) -> RepoInspection { let mut builder = RepoGraphBuilder::new(display_path(&root)); detect_rust(&root, &mut builder); - detect_node(&root, &mut builder); + node::detect_node(&root, &mut builder); detect_python(&root, &mut builder); go::detect_go(&root, &mut builder); generic::detect_generic(&root, &mut builder); @@ -730,168 +730,6 @@ fn detect_cargo_targets( } } -fn detect_node(root: &Path, builder: &mut RepoGraphBuilder) { - let package_json = root.join("package.json"); - if package_json.exists() { - let manifest_evidence = builder.add_detected_file( - Path::new("package.json"), - DetectedFileKind::Manifest, - "manifest", - None, - "Node package manifest detected.", - ); - builder.add_package_manager(PackageManagerKind::Npm, "npm", manifest_evidence.clone()); - if !root.join("package-lock.json").exists() - && !root.join("pnpm-lock.yaml").exists() - && !root.join("yarn.lock").exists() - { - builder.add_warning( - DetectionSeverity::Info, - DetectionCategory::AmbiguousDetection, - "package.json was found without a lockfile; npm is treated as the default package manager hint.", - Some(Path::new("package.json")), - Some(manifest_evidence.clone()), - ); - } - - match read_json(&package_json) { - Ok(manifest) => { - if let Some(name) = manifest.get("name").and_then(JsonValue::as_str) { - let evidence_id = builder.add_evidence( - Path::new("package.json"), - "manifest", - Some("name"), - "Node package name.", - ); - builder.add_component( - "component-node-package", - name, - "node_package", - ".", - vec![ - "package.json".to_string(), - "src/**".to_string(), - "test/**".to_string(), - "tests/**".to_string(), - ], - evidence_id, - ); - builder.add_relationship( - RelationshipKind::UsesPackageManager, - "component-node-package", - "package-manager-npm", - manifest_evidence.clone(), - ); - } - - if manifest.get("workspaces").is_some() { - if let Some(workspaces) = extract_package_json_workspaces(&manifest) { - let evidence_id = builder.add_evidence( - Path::new("package.json"), - "manifest", - Some("workspaces"), - "Node workspace members.", - ); - builder.add_workspace( - "workspace-node", - "node-workspace", - workspaces, - evidence_id, - ); - } else { - builder.add_warning( - DetectionSeverity::Warning, - DetectionCategory::UnsupportedPattern, - "package.json workspaces field is present but not in a supported array/packages shape.", - Some(Path::new("package.json")), - Some(manifest_evidence.clone()), - ); - } - } - - if let Some(scripts) = manifest.get("scripts").and_then(JsonValue::as_object) { - let has_test_script = - add_node_script(builder, scripts, "test", RepoCommandKind::Test, 0.9); - add_node_script(builder, scripts, "build", RepoCommandKind::Build, 0.85); - add_node_script(builder, scripts, "lint", RepoCommandKind::Lint, 0.85); - add_node_script(builder, scripts, "check", RepoCommandKind::Check, 0.8); - add_node_script( - builder, - scripts, - "typecheck", - RepoCommandKind::Typecheck, - 0.8, - ); - if !has_test_script { - builder.add_warning( - DetectionSeverity::Info, - DetectionCategory::MissingCommand, - "package.json does not define scripts.test; no Node test target was inferred.", - Some(Path::new("package.json")), - Some(manifest_evidence.clone()), - ); - } - } else { - builder.add_warning( - DetectionSeverity::Info, - DetectionCategory::MissingCommand, - "package.json does not define scripts; no Node commands were inferred.", - Some(Path::new("package.json")), - Some(manifest_evidence.clone()), - ); - } - } - Err(message) => builder.add_warning( - DetectionSeverity::Error, - manifest_warning_category(&message), - &message, - Some(Path::new("package.json")), - Some(manifest_evidence), - ), - } - } - - detect_lockfile( - builder, - root, - "package-lock.json", - PackageManagerKind::Npm, - "npm", - ); - detect_lockfile( - builder, - root, - "pnpm-lock.yaml", - PackageManagerKind::Pnpm, - "pnpm", - ); - detect_lockfile(builder, root, "yarn.lock", PackageManagerKind::Yarn, "yarn"); - - let pnpm_workspace = root.join("pnpm-workspace.yaml"); - if pnpm_workspace.exists() { - let evidence_id = builder.add_detected_file( - Path::new("pnpm-workspace.yaml"), - DetectedFileKind::WorkspaceConfig, - "workspace_config", - None, - "pnpm workspace config detected.", - ); - builder.add_package_manager(PackageManagerKind::Pnpm, "pnpm", evidence_id.clone()); - let members = parse_simple_yaml_packages(&pnpm_workspace); - if !members.is_empty() { - builder.add_workspace("workspace-pnpm", "pnpm-workspace", members, evidence_id); - } else { - builder.add_warning( - DetectionSeverity::Warning, - DetectionCategory::UnsupportedPattern, - "pnpm-workspace.yaml was detected but no supported packages list was parsed.", - Some(Path::new("pnpm-workspace.yaml")), - Some(evidence_id), - ); - } - } -} - fn detect_python(root: &Path, builder: &mut RepoGraphBuilder) { let mut python_project_detected = false; let mut python_project_evidence = None; @@ -1063,68 +901,6 @@ fn detect_python(root: &Path, builder: &mut RepoGraphBuilder) { } } -fn add_node_script( - builder: &mut RepoGraphBuilder, - scripts: &serde_json::Map, - script_name: &str, - kind: RepoCommandKind, - confidence: f32, -) -> bool { - if scripts - .get(script_name) - .and_then(JsonValue::as_str) - .is_some() - { - let evidence_id = builder.add_evidence( - Path::new("package.json"), - "manifest", - Some(&format!("scripts.{script_name}")), - "Node package script detected.", - ); - let command = format!("npm run {script_name}"); - builder.add_command( - &format!("cmd-npm-{script_name}"), - kind.clone(), - &command, - Some("component-node-package"), - confidence, - evidence_id.clone(), - ); - if matches!(kind, RepoCommandKind::Test) { - builder.add_test( - &format!("test-npm-{script_name}"), - script_name, - &command, - Some("component-node-package"), - confidence, - evidence_id, - ); - } - true - } else { - false - } -} - -fn detect_lockfile( - builder: &mut RepoGraphBuilder, - root: &Path, - file_name: &str, - kind: PackageManagerKind, - name: &str, -) { - if root.join(file_name).exists() { - let evidence_id = builder.add_detected_file( - Path::new(file_name), - DetectedFileKind::Lockfile, - "lockfile", - None, - &format!("{name} lockfile detected."), - ); - builder.add_package_manager(kind, name, evidence_id); - } -} - fn detect_python_lockfile( builder: &mut RepoGraphBuilder, root: &Path, @@ -1255,59 +1031,6 @@ fn dependency_name_matches(dependency: &str, package_name: &str) -> bool { .is_some_and(|character| matches!(character, '=' | '<' | '>' | '~' | '[' | '!' | ' ')) } -fn extract_package_json_workspaces(manifest: &JsonValue) -> Option> { - let workspaces = manifest.get("workspaces")?; - - if let Some(items) = workspaces.as_array() { - let members = items - .iter() - .filter_map(JsonValue::as_str) - .map(str::to_string) - .collect::>(); - return (!members.is_empty()).then_some(members); - } - - let packages = workspaces.get("packages")?.as_array()?; - let members = packages - .iter() - .filter_map(JsonValue::as_str) - .map(str::to_string) - .collect::>(); - (!members.is_empty()).then_some(members) -} - -fn parse_simple_yaml_packages(path: &Path) -> Vec { - let Ok(contents) = fs::read_to_string(path) else { - return Vec::new(); - }; - - let mut in_packages = false; - let mut members = Vec::new(); - - for line in contents.lines() { - let trimmed = line.trim(); - if trimmed == "packages:" { - in_packages = true; - continue; - } - - if in_packages && trimmed.starts_with('-') { - let member = trimmed - .trim_start_matches('-') - .trim() - .trim_matches('"') - .trim_matches('\''); - if !member.is_empty() { - members.push(member.to_string()); - } - } else if in_packages && !trimmed.is_empty() && !line.starts_with(' ') { - break; - } - } - - members -} - fn detect_ignored_paths(root: &Path, builder: &mut RepoGraphBuilder) { for (ignored_path, emit_warning) in [ (".git", false), @@ -1427,13 +1150,6 @@ fn read_toml(path: &Path) -> Result { .map_err(|error| format!("Failed to parse {}: {error}", normalize_path(path))) } -fn read_json(path: &Path) -> Result { - let contents = fs::read_to_string(path) - .map_err(|error| format!("Failed to read {}: {error}", normalize_path(path)))?; - serde_json::from_str(&contents) - .map_err(|error| format!("Failed to parse {}: {error}", normalize_path(path))) -} - fn normalize_path(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } diff --git a/src/core/repo_graph/node.rs b/src/core/repo_graph/node.rs new file mode 100644 index 0000000..fddbe75 --- /dev/null +++ b/src/core/repo_graph/node.rs @@ -0,0 +1,290 @@ +use serde_json::Value as JsonValue; +use std::fs; +use std::path::Path; + +use super::types::*; +use super::{manifest_warning_category, normalize_path, RepoGraphBuilder}; + +pub(super) fn detect_node(root: &Path, builder: &mut RepoGraphBuilder) { + let package_json = root.join("package.json"); + if package_json.exists() { + let manifest_evidence = builder.add_detected_file( + Path::new("package.json"), + DetectedFileKind::Manifest, + "manifest", + None, + "Node package manifest detected.", + ); + builder.add_package_manager(PackageManagerKind::Npm, "npm", manifest_evidence.clone()); + if !root.join("package-lock.json").exists() + && !root.join("pnpm-lock.yaml").exists() + && !root.join("yarn.lock").exists() + { + builder.add_warning( + DetectionSeverity::Info, + DetectionCategory::AmbiguousDetection, + "package.json was found without a lockfile; npm is treated as the default package manager hint.", + Some(Path::new("package.json")), + Some(manifest_evidence.clone()), + ); + } + + match read_json(&package_json) { + Ok(manifest) => { + if let Some(name) = manifest.get("name").and_then(JsonValue::as_str) { + let evidence_id = builder.add_evidence( + Path::new("package.json"), + "manifest", + Some("name"), + "Node package name.", + ); + builder.add_component( + "component-node-package", + name, + "node_package", + ".", + vec![ + "package.json".to_string(), + "src/**".to_string(), + "test/**".to_string(), + "tests/**".to_string(), + ], + evidence_id, + ); + builder.add_relationship( + RelationshipKind::UsesPackageManager, + "component-node-package", + "package-manager-npm", + manifest_evidence.clone(), + ); + } + + if manifest.get("workspaces").is_some() { + if let Some(workspaces) = extract_package_json_workspaces(&manifest) { + let evidence_id = builder.add_evidence( + Path::new("package.json"), + "manifest", + Some("workspaces"), + "Node workspace members.", + ); + builder.add_workspace( + "workspace-node", + "node-workspace", + workspaces, + evidence_id, + ); + } else { + builder.add_warning( + DetectionSeverity::Warning, + DetectionCategory::UnsupportedPattern, + "package.json workspaces field is present but not in a supported array/packages shape.", + Some(Path::new("package.json")), + Some(manifest_evidence.clone()), + ); + } + } + + if let Some(scripts) = manifest.get("scripts").and_then(JsonValue::as_object) { + let has_test_script = + add_node_script(builder, scripts, "test", RepoCommandKind::Test, 0.9); + add_node_script(builder, scripts, "build", RepoCommandKind::Build, 0.85); + add_node_script(builder, scripts, "lint", RepoCommandKind::Lint, 0.85); + add_node_script(builder, scripts, "check", RepoCommandKind::Check, 0.8); + add_node_script( + builder, + scripts, + "typecheck", + RepoCommandKind::Typecheck, + 0.8, + ); + if !has_test_script { + builder.add_warning( + DetectionSeverity::Info, + DetectionCategory::MissingCommand, + "package.json does not define scripts.test; no Node test target was inferred.", + Some(Path::new("package.json")), + Some(manifest_evidence.clone()), + ); + } + } else { + builder.add_warning( + DetectionSeverity::Info, + DetectionCategory::MissingCommand, + "package.json does not define scripts; no Node commands were inferred.", + Some(Path::new("package.json")), + Some(manifest_evidence.clone()), + ); + } + } + Err(message) => builder.add_warning( + DetectionSeverity::Error, + manifest_warning_category(&message), + &message, + Some(Path::new("package.json")), + Some(manifest_evidence), + ), + } + } + + detect_lockfile( + builder, + root, + "package-lock.json", + PackageManagerKind::Npm, + "npm", + ); + detect_lockfile( + builder, + root, + "pnpm-lock.yaml", + PackageManagerKind::Pnpm, + "pnpm", + ); + detect_lockfile(builder, root, "yarn.lock", PackageManagerKind::Yarn, "yarn"); + + let pnpm_workspace = root.join("pnpm-workspace.yaml"); + if pnpm_workspace.exists() { + let evidence_id = builder.add_detected_file( + Path::new("pnpm-workspace.yaml"), + DetectedFileKind::WorkspaceConfig, + "workspace_config", + None, + "pnpm workspace config detected.", + ); + builder.add_package_manager(PackageManagerKind::Pnpm, "pnpm", evidence_id.clone()); + let members = parse_simple_yaml_packages(&pnpm_workspace); + if !members.is_empty() { + builder.add_workspace("workspace-pnpm", "pnpm-workspace", members, evidence_id); + } else { + builder.add_warning( + DetectionSeverity::Warning, + DetectionCategory::UnsupportedPattern, + "pnpm-workspace.yaml was detected but no supported packages list was parsed.", + Some(Path::new("pnpm-workspace.yaml")), + Some(evidence_id), + ); + } + } +} + +fn add_node_script( + builder: &mut RepoGraphBuilder, + scripts: &serde_json::Map, + script_name: &str, + kind: RepoCommandKind, + confidence: f32, +) -> bool { + if scripts + .get(script_name) + .and_then(JsonValue::as_str) + .is_some() + { + let evidence_id = builder.add_evidence( + Path::new("package.json"), + "manifest", + Some(&format!("scripts.{script_name}")), + "Node package script detected.", + ); + let command = format!("npm run {script_name}"); + builder.add_command( + &format!("cmd-npm-{script_name}"), + kind.clone(), + &command, + Some("component-node-package"), + confidence, + evidence_id.clone(), + ); + if matches!(kind, RepoCommandKind::Test) { + builder.add_test( + &format!("test-npm-{script_name}"), + script_name, + &command, + Some("component-node-package"), + confidence, + evidence_id, + ); + } + true + } else { + false + } +} + +fn detect_lockfile( + builder: &mut RepoGraphBuilder, + root: &Path, + file_name: &str, + kind: PackageManagerKind, + name: &str, +) { + if root.join(file_name).exists() { + let evidence_id = builder.add_detected_file( + Path::new(file_name), + DetectedFileKind::Lockfile, + "lockfile", + None, + &format!("{name} lockfile detected."), + ); + builder.add_package_manager(kind, name, evidence_id); + } +} + +fn extract_package_json_workspaces(manifest: &JsonValue) -> Option> { + let workspaces = manifest.get("workspaces")?; + + if let Some(items) = workspaces.as_array() { + let members = items + .iter() + .filter_map(JsonValue::as_str) + .map(str::to_string) + .collect::>(); + return (!members.is_empty()).then_some(members); + } + + let packages = workspaces.get("packages")?.as_array()?; + let members = packages + .iter() + .filter_map(JsonValue::as_str) + .map(str::to_string) + .collect::>(); + (!members.is_empty()).then_some(members) +} + +fn parse_simple_yaml_packages(path: &Path) -> Vec { + let Ok(contents) = fs::read_to_string(path) else { + return Vec::new(); + }; + + let mut in_packages = false; + let mut members = Vec::new(); + + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed == "packages:" { + in_packages = true; + continue; + } + + if in_packages && trimmed.starts_with('-') { + let member = trimmed + .trim_start_matches('-') + .trim() + .trim_matches('"') + .trim_matches('\''); + if !member.is_empty() { + members.push(member.to_string()); + } + } else if in_packages && !trimmed.is_empty() && !line.starts_with(' ') { + break; + } + } + + members +} + +fn read_json(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .map_err(|error| format!("Failed to read {}: {error}", normalize_path(path)))?; + serde_json::from_str(&contents) + .map_err(|error| format!("Failed to parse {}: {error}", normalize_path(path))) +}