Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 2 additions & 272 deletions src/core/repo_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;

mod generic;
mod impact;
mod types;

Expand All @@ -19,7 +20,7 @@ pub fn inspect_repo(repo_path: impl AsRef<Path>) -> RepoInspection {
detect_node(&root, &mut builder);
detect_python(&root, &mut builder);
detect_go(&root, &mut builder);
detect_generic(&root, &mut builder);
generic::detect_generic(&root, &mut builder);
detect_ignored_paths(&root, &mut builder);

if builder.detected_files.is_empty() {
Expand Down Expand Up @@ -48,11 +49,6 @@ struct CargoDependency {
path_dependency: bool,
}

struct CommandFileTargets {
targets: Vec<String>,
ambiguous_lines: usize,
}

struct RepoGraphBuilder {
repo_root: String,
next_evidence: usize,
Expand Down Expand Up @@ -1171,165 +1167,6 @@ fn detect_go(root: &Path, builder: &mut RepoGraphBuilder) {
}
}

fn detect_generic(root: &Path, builder: &mut RepoGraphBuilder) {
let makefile = root.join("Makefile");
if makefile.exists() {
let evidence_id = builder.add_detected_file(
Path::new("Makefile"),
DetectedFileKind::BuildConfig,
"build_config",
None,
"Makefile detected.",
);
builder.add_package_manager(PackageManagerKind::Make, "make", evidence_id.clone());
builder.add_component(
"component-make-project",
"make-project",
"generic_make_project",
".",
vec!["Makefile".to_string()],
evidence_id.clone(),
);
let parsed_targets = read_command_file_targets(&makefile);
add_command_file_targets(
builder,
"Makefile",
"make",
"component-make-project",
&parsed_targets.targets,
);

if !parsed_targets.targets.iter().any(|target| target == "test") {
builder.add_warning(
DetectionSeverity::Info,
DetectionCategory::MissingCommand,
"Makefile detected but no test target was parsed.",
Some(Path::new("Makefile")),
Some(evidence_id.clone()),
);
}

if parsed_targets.ambiguous_lines > 0 {
builder.add_warning(
DetectionSeverity::Warning,
DetectionCategory::PartialSupport,
"Makefile contains target-like lines that were not parsed conservatively.",
Some(Path::new("Makefile")),
Some(evidence_id),
);
}
}

let justfile = root.join("justfile");
if justfile.exists() {
let evidence_id = builder.add_detected_file(
Path::new("justfile"),
DetectedFileKind::BuildConfig,
"build_config",
None,
"justfile detected.",
);
builder.add_package_manager(PackageManagerKind::Just, "just", evidence_id.clone());
builder.add_component(
"component-just-project",
"just-project",
"generic_just_project",
".",
vec!["justfile".to_string()],
evidence_id.clone(),
);
let parsed_targets = read_command_file_targets(&justfile);
add_command_file_targets(
builder,
"justfile",
"just",
"component-just-project",
&parsed_targets.targets,
);

if !parsed_targets.targets.iter().any(|target| target == "test") {
builder.add_warning(
DetectionSeverity::Info,
DetectionCategory::MissingCommand,
"justfile detected but no test recipe was parsed.",
Some(Path::new("justfile")),
Some(evidence_id.clone()),
);
}

if parsed_targets.ambiguous_lines > 0 {
builder.add_warning(
DetectionSeverity::Warning,
DetectionCategory::PartialSupport,
"justfile contains recipe-like lines that were not parsed conservatively.",
Some(Path::new("justfile")),
Some(evidence_id),
);
}
}

if root.join("Dockerfile").exists() {
let evidence_id = builder.add_detected_file(
Path::new("Dockerfile"),
DetectedFileKind::ContainerConfig,
"container_config",
None,
"Dockerfile detected.",
);
builder.add_package_manager(PackageManagerKind::Docker, "docker", evidence_id);
}

for compose_file in [
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
] {
let path = root.join(compose_file);
if path.exists() {
let evidence_id = builder.add_detected_file(
Path::new(compose_file),
DetectedFileKind::ContainerConfig,
"container_config",
None,
"Docker Compose file detected.",
);
builder.add_package_manager(PackageManagerKind::Docker, "docker", evidence_id);
}
}

let workflows_dir = root.join(".github").join("workflows");
if let Ok(entries) = fs::read_dir(workflows_dir) {
let mut entries = entries.flatten().collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());

for entry in entries {
let path = entry.path();
let Some(extension) = path.extension().and_then(|extension| extension.to_str()) else {
continue;
};

if matches!(extension, "yml" | "yaml") {
let relative = PathBuf::from(".github")
.join("workflows")
.join(entry.file_name());
let evidence_id = builder.add_detected_file(
&relative,
DetectedFileKind::Workflow,
"workflow",
None,
"GitHub Actions workflow detected.",
);
builder.add_package_manager(
PackageManagerKind::GitHubActions,
"github_actions",
evidence_id,
);
}
}
}
}

fn add_node_script(
builder: &mut RepoGraphBuilder,
scripts: &serde_json::Map<String, JsonValue>,
Expand Down Expand Up @@ -1522,58 +1359,6 @@ fn dependency_name_matches(dependency: &str, package_name: &str) -> bool {
.is_some_and(|character| matches!(character, '=' | '<' | '>' | '~' | '[' | '!' | ' '))
}

fn add_command_file_targets(
builder: &mut RepoGraphBuilder,
file_name: &str,
tool_name: &str,
scope_ref: &str,
targets: &[String],
) {
for target in targets {
let Some(kind) = command_kind_for_target(target) else {
continue;
};

let evidence_id = builder.add_evidence(
Path::new(file_name),
"build_target",
Some(&format!("target.{target}")),
"Build file target detected.",
);
let command = format!("{tool_name} {target}");
builder.add_command(
&stable_id(&format!("cmd-{tool_name}"), target),
kind.clone(),
&command,
Some(scope_ref),
0.75,
evidence_id.clone(),
);

if kind == RepoCommandKind::Test {
builder.add_test(
&stable_id(&format!("test-{tool_name}"), target),
&command,
&command,
Some(scope_ref),
0.75,
evidence_id,
);
}
}
}

fn command_kind_for_target(target: &str) -> Option<RepoCommandKind> {
match target {
"test" => Some(RepoCommandKind::Test),
"check" => Some(RepoCommandKind::Check),
"build" => Some(RepoCommandKind::Build),
"lint" => Some(RepoCommandKind::Lint),
"fmt" | "format" => Some(RepoCommandKind::Format),
_ => None,
}
}

fn extract_package_json_workspaces(manifest: &JsonValue) -> Option<Vec<String>> {
let workspaces = manifest.get("workspaces")?;

Expand Down Expand Up @@ -1651,61 +1436,6 @@ fn detect_ignored_paths(root: &Path, builder: &mut RepoGraphBuilder) {
}
}

fn read_command_file_targets(path: &Path) -> CommandFileTargets {
let Ok(contents) = fs::read_to_string(path) else {
return CommandFileTargets {
targets: Vec::new(),
ambiguous_lines: 0,
};
};

let mut targets = BTreeSet::new();
let mut ambiguous_lines = 0;

for line in contents.lines() {
let trimmed = line.trim_start();
if line.starts_with(char::is_whitespace)
|| trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(".PHONY:")
|| trimmed.contains(":=")
|| trimmed.contains("?=")
|| trimmed.contains("+=")
{
continue;
}

let Some((target, _)) = trimmed.split_once(':') else {
continue;
};
let target = target.trim();

if is_simple_command_target(target) {
targets.insert(target.to_string());
} else if is_ambiguous_target_syntax(target) {
ambiguous_lines += 1;
}
}

CommandFileTargets {
targets: targets.into_iter().collect(),
ambiguous_lines,
}
}

fn is_simple_command_target(target: &str) -> bool {
command_kind_for_target(target).is_some()
}

fn is_ambiguous_target_syntax(target: &str) -> bool {
!target.is_empty()
&& !target.starts_with('.')
&& (target.chars().any(char::is_whitespace)
|| target.contains('%')
|| target.contains('$')
|| target.contains('/'))
}

fn read_go_module_name(path: &Path) -> Option<String> {
let contents = fs::read_to_string(path).ok()?;
contents.lines().find_map(|line| {
Expand Down
Loading