Skip to content
Open
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
2 changes: 2 additions & 0 deletions agents/ralphx-project-analyzer/shared/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ You are the RalphX Project Analyzer Agent. Your job is to scan a project's worki
3. For `package.json`: inspect it to check available scripts (typecheck, lint, build, test)
4. For `Cargo.toml`: check if it's a workspace root (`[workspace]`) vs member
5. Determine the relative `path` from project root (use `.` for root-level)
6. If the only Node package is under `frontend/package.json`, emit `path: "frontend"` so validation runs from `frontend/`, not from the repository root

## Repo-Specific Validation Overrides

Expand Down Expand Up @@ -104,6 +105,7 @@ Use these placeholders in commands — they are resolved at runtime:
- Only detect what actually exists — don't guess or assume
- If a monorepo has multiple workspaces, produce entries for each build context
- For `package.json`, only include scripts that actually exist (check the `scripts` object)
- Never pass `vitest.config.ts` as a test target. For Vitest, use the package test script with no config-file argument, or pass real `*.test.*` / `*.spec.*` files only.
- Focus on commands useful for validation during task execution and review
- When repo-local docs define validation policy, prefer those commands over generic defaults

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
use std::path::{Path, PathBuf};

use tokio::io::AsyncReadExt;
use tokio_util::sync::CancellationToken;

use crate::infrastructure::tool_paths::resolve_node_cli_path;
use crate::utils::path_safety::{checked_read_to_string, validate_absolute_non_root_path};

#[derive(Debug, Clone)]
pub(super) struct FrontendReadinessFailure {
issues: Vec<String>,
}

impl FrontendReadinessFailure {
pub(super) fn message(&self) -> String {
self.issues.join("; ")
}
}

pub(super) fn command_cwd(
base_cwd: &Path,
resolved_path: &str,
command: &str,
) -> (PathBuf, String) {
let default_cwd = if resolved_path == "." {
base_cwd.to_path_buf()
} else {
base_cwd.join(resolved_path)
};

if resolved_path == "." && is_node_package_command(command) {
let nested_frontend = default_cwd.join("frontend");
if nested_frontend.join("package.json").exists()
&& !default_cwd.join("package.json").exists()
{
return (nested_frontend, "frontend".to_string());
}
}

(default_cwd, resolved_path.to_string())
}

pub(super) fn sanitize_frontend_validate_command(command: &str) -> String {
let mut parts: Vec<&str> = command
.split_whitespace()
.filter(|part| {
let trimmed = part.trim_matches(|ch| ch == '\'' || ch == '"');
trimmed != "vitest.config.ts" && !trimmed.ends_with("/vitest.config.ts")
})
.collect();

while parts.last().copied() == Some("--") {
parts.pop();
}

let mut sanitized = if parts.is_empty() {
command.to_string()
} else {
parts.join(" ")
};

if let Some(rest) = sanitized.strip_prefix("vitest ") {
sanitized = format!("./node_modules/.bin/vitest {rest}");
} else if sanitized == "vitest" {
sanitized = "./node_modules/.bin/vitest".to_string();
}

sanitized
}

pub(super) fn requires_frontend_readiness(command: &str, cwd: &Path) -> bool {
if !is_frontend_validation_command(command) {
return false;
}

is_frontend_package_context(cwd)
}

pub(super) fn is_frontend_package_context(cwd: &Path) -> bool {
cwd.join("package.json").exists()
&& (cwd.ends_with("frontend") || package_json_mentions_frontend_stack(cwd))
}

pub(super) async fn check_frontend_dependency_readiness(
cwd: &Path,
cancel: &CancellationToken,
) -> Result<(), FrontendReadinessFailure> {
let mut issues = Vec::new();
let vitest_bin = cwd.join("node_modules").join(".bin").join("vitest");
if !is_executable_file(&vitest_bin) {
issues.push(format!(
"{} is missing or not executable",
vitest_bin.display()
));
}

for specifier in ["vitest/config", "react", "zod", "@tauri-apps/api"] {
if let Err(error) = run_node_import_probe(cwd, specifier, cancel).await {
issues.push(error);
}
}

if issues.is_empty() {
Ok(())
} else {
Err(FrontendReadinessFailure { issues })
}
}

fn is_node_package_command(command: &str) -> bool {
let trimmed = command.trim_start();
trimmed.starts_with("npm ")
|| trimmed == "npm"
|| trimmed.starts_with("npx ")
|| trimmed.starts_with("vitest")
|| trimmed.starts_with("./node_modules/.bin/vitest")
}

fn is_frontend_validation_command(command: &str) -> bool {
let command = command.to_ascii_lowercase();
command.contains("npm run lint")
|| command.contains("npm run typecheck")
|| command.contains("npm run test")
|| command == "npm test"
|| command.contains(" vitest")
|| command.starts_with("vitest")
|| command.starts_with("./node_modules/.bin/vitest")
}

fn package_json_mentions_frontend_stack(cwd: &Path) -> bool {
let Ok(contents) = checked_read_to_string(&cwd.join("package.json"), "frontend package.json")
else {
return false;
};

contents.contains("\"react\"")
|| contents.contains("\"vitest\"")
|| contents.contains("\"@tauri-apps/api\"")
|| contents.contains("\"zod\"")
}

fn is_executable_file(path: &Path) -> bool {
let Ok(safe_path) = validate_absolute_non_root_path(path, "frontend executable") else {
return false;
};
let Ok(metadata) = safe_path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}

#[cfg(not(unix))]
{
true
}
}

async fn run_node_import_probe(
cwd: &Path,
specifier: &str,
cancel: &CancellationToken,
) -> Result<(), String> {
let safe_cwd = validate_absolute_non_root_path(cwd, "frontend dependency probe cwd")
.map_err(|error| format!("invalid frontend dependency probe cwd: {error}"))?;
let mut child = tokio::process::Command::new(resolve_node_cli_path())
.arg("-e")
.arg(format!("import({specifier:?})"))
.current_dir(&safe_cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|error| format!("node import {specifier:?} could not start: {error}"))?;

let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_fut = async {
let mut bytes = Vec::new();
if let Some(mut stdout) = stdout_handle {
let _ = stdout.read_to_end(&mut bytes).await;
}
bytes
};
let stderr_fut = async {
let mut bytes = Vec::new();
if let Some(mut stderr) = stderr_handle {
let _ = stderr.read_to_end(&mut bytes).await;
}
bytes
};

tokio::select! {
_ = cancel.cancelled() => {
let _ = child.kill().await;
let _ = child.wait().await;
Err(format!("node import {specifier:?} cancelled"))
}
(status, stdout, stderr) = async { tokio::join!(child.wait(), stdout_fut, stderr_fut) } => {
match status {
Ok(status) if status.success() => Ok(()),
Ok(status) => {
let stderr = String::from_utf8_lossy(&stderr);
let stdout = String::from_utf8_lossy(&stdout);
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
Err(format!(
"node import {specifier:?} failed with exit {:?}: {}",
status.code(),
detail
))
}
Err(error) => Err(format!("node import {specifier:?} wait failed: {error}")),
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ use tokio_util::sync::CancellationToken;
use crate::domain::entities::{Project, Task};

use super::{
setup::run_setup_phase, spawn_cancellable_command, truncate_output, CancellableCommandResult,
MergeAnalysisEntry, PreExecAnalysisEntry, PreExecSetupResult, ValidationLogEntry,
INSTALL_RETRY_DELAY_MS, STATUS_FAILED,
frontend_readiness::{
check_frontend_dependency_readiness, command_cwd, is_frontend_package_context,
requires_frontend_readiness,
},
setup::run_setup_phase,
spawn_cancellable_command, truncate_output, CancellableCommandResult, MergeAnalysisEntry,
PreExecAnalysisEntry, PreExecSetupResult, ValidationLogEntry, INSTALL_RETRY_DELAY_MS,
STATUS_FAILED,
};

/// Run install commands for pre-execution setup.
Expand All @@ -31,21 +36,28 @@ pub(crate) async fn run_install_phase(
};

let resolved_cmd = resolve(cmd_str);
let resolved_path = resolve(&entry.path);
let cmd_cwd = if resolved_path == "." {
exec_cwd.to_path_buf()
} else {
exec_cwd.join(&resolved_path)
};
let entry_path = resolve(&entry.path);
let (cmd_cwd, resolved_path) = command_cwd(exec_cwd, &entry_path, &resolved_cmd);
let frontend_readiness_required = is_frontend_package_context(&cmd_cwd)
|| requires_frontend_readiness(&resolved_cmd, &cmd_cwd);

// Skip install if node_modules already exists (symlink from setup phase or prior install)
// Skip install only when frontend dependencies are actually ready. A partial
// node_modules directory is not enough: local Vitest and import probes must pass.
let nm_path = cmd_cwd.join("node_modules");
if nm_path.exists() || nm_path.is_symlink() {
let dependency_tree_ready = if frontend_readiness_required {
check_frontend_dependency_readiness(&cmd_cwd, cancel)
.await
.is_ok()
} else {
nm_path.exists() || nm_path.is_symlink()
};
if dependency_tree_ready {
tracing::info!(
command = %resolved_cmd,
cwd = %cmd_cwd.display(),
is_symlink = nm_path.is_symlink(),
"Skipping install: node_modules already exists"
frontend_readiness_required,
"Skipping install: dependency tree is ready"
);
log.push(ValidationLogEntry {
phase: "install".to_string(),
Expand All @@ -55,7 +67,11 @@ pub(crate) async fn run_install_phase(
status: "skipped".to_string(),
exit_code: None,
stdout: String::new(),
stderr: "node_modules already exists — install skipped".to_string(),
stderr: if frontend_readiness_required {
"frontend dependencies are ready — install skipped".to_string()
} else {
"node_modules already exists — install skipped".to_string()
},
duration_ms: 0,
..Default::default()
});
Expand Down Expand Up @@ -241,6 +257,21 @@ pub(crate) async fn run_install_phase(
install_had_failures = true;
}

if frontend_readiness_required && log_entry.status != STATUS_FAILED {
if let Err(readiness) = check_frontend_dependency_readiness(&cmd_cwd, cancel).await {
install_had_failures = true;
log_entry.status = STATUS_FAILED.to_string();
log_entry.exit_code = None;
log_entry.stderr = truncate_output(
&format!(
"Frontend dependency setup failed after install: {}",
readiness.message()
),
2000,
);
}
}

if let Some(handle) = app_handle {
let _ = handle.emit(
"merge:validation_step",
Expand Down Expand Up @@ -337,6 +368,7 @@ pub async fn run_pre_execution_setup(
.map(|e| MergeAnalysisEntry {
path: e.path.clone(),
label: e.label.clone(),
install: e.install.clone(),
validate: Vec::new(),
worktree_setup: e.worktree_setup.clone(),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
// Extracted from side_effects.rs — runs project analysis commands to verify merge correctness.
// Decomposed into setup phase, validate phase, and orchestrator.

mod logging;
mod frontend_readiness;
mod install;
mod logging;
mod metadata;
mod setup;
mod validate;
Expand Down Expand Up @@ -40,19 +41,19 @@ use crate::domain::entities::{
use crate::infrastructure::tool_paths::resolve_shell_cli_path;
use crate::utils::truncate_str;

#[cfg(test)]
pub(crate) use install::run_install_phase;
pub use install::run_pre_execution_setup;
#[cfg(test)]
pub(crate) use logging::validation_log_dir;
pub(crate) use logging::{cleanup_validation_logs, emit_merge_progress};
pub(crate) use metadata::format_validation_error_metadata;
pub(crate) use metadata::{
extract_cached_validation, format_validation_warn_metadata, take_skip_validation_flag,
};
pub(crate) use logging::{cleanup_validation_logs, emit_merge_progress};
#[cfg(test)]
pub(crate) use logging::validation_log_dir;
#[cfg(test)]
pub(crate) use install::run_install_phase;
pub use install::run_pre_execution_setup;
use setup::run_setup_phase;
#[cfg(test)]
pub(crate) use setup::{parse_symlink_command, try_handle_symlink_idempotent};
use setup::run_setup_phase;
use validate::run_validate_phase;

/// Outcome of a cancellable shell command execution.
Expand Down Expand Up @@ -227,6 +228,8 @@ pub(super) struct MergeAnalysisEntry {
#[allow(dead_code)]
pub(super) label: String,
#[serde(default)]
pub(super) install: Option<String>,
#[serde(default)]
pub(super) validate: Vec<String>,
#[serde(default)]
pub(super) worktree_setup: Vec<String>,
Expand Down
Loading
Loading