use super::types::ShellStatus;
use crate::tools::spec::ToolResult;
use serde_json::json;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ShellFailureHint {
pub id: &'static str,
pub message: &'static str,
}
fn combined_output(stdout: &str, stderr: &str) -> String {
format!("{stderr}\n{stdout}")
}
fn mentions_npm(command: &str, output: &str) -> bool {
let cmd = command.to_ascii_lowercase();
let out = output.to_ascii_lowercase();
cmd.contains("npm") || cmd.contains("npx") || out.contains("npm")
}
fn mentions_jest(command: &str, output: &str) -> bool {
let cmd = command.to_ascii_lowercase();
let out = output.to_ascii_lowercase();
cmd.contains("jest") || out.contains("jest")
}
fn has_eperm_or_eacces(output: &str) -> bool {
let upper = output.to_ascii_uppercase();
upper.contains("EPERM") || upper.contains("EACCES")
}
fn has_spawn_failure(output: &str) -> bool {
let lower = output.to_ascii_lowercase();
lower.contains("spawn eperm")
|| lower.contains("error: spawn")
|| lower.contains("errno: -4048")
|| lower.contains("errno -4048")
}
#[must_use]
pub(crate) fn detect_shell_failure_hints(
command: &str,
stdout: &str,
stderr: &str,
exit_code: Option<i32>,
status: &ShellStatus,
) -> Vec<ShellFailureHint> {
if matches!(status, ShellStatus::Completed | ShellStatus::Running)
&& exit_code.unwrap_or(0) == 0
{
return Vec::new();
}
let output = combined_output(stdout, stderr);
let mut hints = Vec::new();
if mentions_npm(command, &output) && has_eperm_or_eacces(&output) {
hints.push(ShellFailureHint {
id: "npm_cache_eperm",
message: "npm hit EPERM/EACCES on the cache directory (common on Windows or \
sandboxed runs). Add a project `.npmrc` with `cache=./.npm-cache`, \
retry `npm install`, and avoid writing to the global `%AppData%\\npm-cache`. \
Template: `fixtures/harness/workspace-templates/nodejs-windows/.npmrc`.",
});
}
if (mentions_jest(command, &output) || command.to_ascii_lowercase().contains("npm test"))
&& (has_eperm_or_eacces(&output) || has_spawn_failure(&output))
{
hints.push(ShellFailureHint {
id: "jest_run_in_band",
message: "Jest worker spawn failed (parallel test runners on Windows). Retry with \
`npm test -- --runInBand` or `npx jest --runInBand` to run tests in-band \
(single process, no worker pool).",
});
}
let cmd_lower = command.to_ascii_lowercase();
let out_lower = output.to_ascii_lowercase();
let npm_install = cmd_lower.contains("npm install") || cmd_lower.contains("npm ci");
if npm_install
&& (out_lower.contains("devdependencies")
|| out_lower.contains("omit=dev")
|| out_lower.contains("omit dev")
|| out_lower.contains("production=true")
|| (out_lower.contains("idealtree") && out_lower.contains("dev")))
{
hints.push(ShellFailureHint {
id: "npm_dev_dependencies",
message: "npm may have skipped devDependencies (npm v11+ defaults or `--omit=dev`). \
Retry with `npm install --include=dev`, or set `omit=` empty in `.npmrc`. \
For peer-resolution failures also try `npm install --legacy-peer-deps`.",
});
}
if (cmd_lower.contains("tsc") || out_lower.contains("error ts"))
&& (out_lower.contains("rootdir")
|| out_lower.contains("root dir")
|| out_lower.contains("cannot find module")
|| out_lower.contains("module resolution")
|| out_lower.contains("file is not under")
|| out_lower.contains("ts6307")
|| out_lower.contains("ts6059"))
{
hints.push(ShellFailureHint {
id: "tsconfig_paths",
message: "TypeScript project layout issue: check `rootDir` vs `include`/`files`, \
`composite` project references, and whether imports need `.js` extensions \
under `moduleResolution: node16/nodenext`. For cross-folder relative imports \
at different depths, prefer `refactor_imports` (per-file `../` depth) over \
many sequential `edit_file` calls.",
});
}
hints
}
fn format_hints_block(hints: &[ShellFailureHint]) -> String {
hints
.iter()
.map(|h| format!("[HINT:{}] {}", h.id, h.message))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn apply_shell_failure_hints(
result: &mut ToolResult,
command: &str,
stdout: &str,
stderr: &str,
exit_code: Option<i32>,
status: &ShellStatus,
) {
let hints = detect_shell_failure_hints(command, stdout, stderr, exit_code, status);
if hints.is_empty() {
return;
}
let block = format_hints_block(&hints);
if result.content.is_empty() {
result.content = block.clone();
} else {
result.content = format!("{}\n\n{block}", result.content);
}
let hint_ids: Vec<&str> = hints.iter().map(|h| h.id).collect();
let hint_messages: Vec<&str> = hints.iter().map(|h| h.message).collect();
match &mut result.metadata {
Some(meta) => {
if let Some(obj) = meta.as_object_mut() {
obj.insert("failure_hints".to_string(), json!(hint_ids));
obj.insert("failure_hint_messages".to_string(), json!(hint_messages));
}
}
None => {
result.metadata = Some(json!({
"failure_hints": hint_ids,
"failure_hint_messages": hint_messages,
}));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn npm_eperm_emits_cache_hint() {
let hints = detect_shell_failure_hints(
"npm install",
"",
"npm ERR! code EPERM\nnpm ERR! syscall mkdir",
Some(1),
&ShellStatus::Failed,
);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].id, "npm_cache_eperm");
assert!(hints[0].message.contains(".npm-cache"));
}
#[test]
fn jest_spawn_emits_run_in_band_hint() {
let hints = detect_shell_failure_hints(
"npm test",
"",
"Error: spawn EPERM\n at ChildProcess.spawn",
Some(1),
&ShellStatus::Failed,
);
assert!(hints.iter().any(|h| h.id == "jest_run_in_band"));
}
#[test]
fn tsc_rootdir_emits_tsconfig_hint() {
let hints = detect_shell_failure_hints(
"npx tsc -p tsconfig.json",
"",
"error TS6059: File 'src/foo.ts' is not under 'rootDir'",
Some(2),
&ShellStatus::Failed,
);
assert!(hints.iter().any(|h| h.id == "tsconfig_paths"));
}
#[test]
fn npm_dev_dependencies_hint_on_omit_dev_output() {
let hints = detect_shell_failure_hints(
"npm ci",
"npm WARN config production Use `--omit=dev` instead.",
"",
Some(1),
&ShellStatus::Failed,
);
assert!(hints.iter().any(|h| h.id == "npm_dev_dependencies"));
}
#[test]
fn success_exit_skips_hints() {
let hints = detect_shell_failure_hints(
"npm install",
"added 42 packages",
"",
Some(0),
&ShellStatus::Completed,
);
assert!(hints.is_empty());
}
#[test]
fn apply_shell_failure_hints_appends_to_content_and_metadata() {
let mut result = ToolResult {
content: "Command failed\n\nSTDERR:\nnpm ERR! EPERM".to_string(),
success: false,
metadata: Some(json!({"exit_code": 1})),
};
apply_shell_failure_hints(
&mut result,
"npm install",
"",
"npm ERR! EPERM",
Some(1),
&ShellStatus::Failed,
);
assert!(result.content.contains("[HINT:npm_cache_eperm]"));
let meta = result.metadata.expect("metadata");
assert_eq!(meta["failure_hints"].as_array().map(|a| a.len()), Some(1));
}
}