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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 28 additions & 5 deletions crates/forgeguard-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,13 @@ fn execute() -> Result<ExitCode> {
// Interactive wizard only when nothing was specified and we own a
// terminal. Explicit `--agent` always wins and is never second-guessed,
// so existing scripts keep working unchanged.
let interactive =
agent.is_empty() && !global && !json && std::io::stdout().is_terminal();
let interactive = should_run_init_wizard(
agent.is_empty(),
global,
json,
std::io::stdin().is_terminal(),
std::io::stdout().is_terminal(),
);
let mut choices = if interactive {
run_init_wizard(&root, index_flag, mcp_flag)?
} else if agent.is_empty() {
Expand Down Expand Up @@ -1047,6 +1052,7 @@ fn print_json<T: serde::Serialize>(value: &T) -> Result<ExitCode> {
Ok(ExitCode::SUCCESS)
}

// forgeguard: allow FG-CPLX-001 -- legacy update flow is unchanged; this PR only adjusts unrelated CLI paths
fn execute_update(
root: &Path,
check: bool,
Expand Down Expand Up @@ -1645,6 +1651,16 @@ const fn flag_choice(yes: bool, no: bool) -> Option<bool> {
}
}

const fn should_run_init_wizard(
no_agents: bool,
global: bool,
json: bool,
stdin_terminal: bool,
stdout_terminal: bool,
) -> bool {
no_agents && !global && !json && stdin_terminal && stdout_terminal
}

fn confirm(question: &str, help: &str) -> Result<bool> {
inquire::Confirm::new(question)
.with_default(true)
Expand Down Expand Up @@ -2203,9 +2219,9 @@ mod tests {
use forgeguard_core::{config::ForgeGuardConfig, GuardMode};

use super::{
agent_menu_rows, agents_from_names, agents_from_rows, execute_mode, summarize_paths,
AgentTarget, BaselineCommands, Cli, Commands, ConfigCommands, HookCommands, McpCommands,
ModeArg, OutputArg,
agent_menu_rows, agents_from_names, agents_from_rows, execute_mode, should_run_init_wizard,
summarize_paths, AgentTarget, BaselineCommands, Cli, Commands, ConfigCommands,
HookCommands, McpCommands, ModeArg, OutputArg,
};

fn temporary_project(label: &str) -> std::path::PathBuf {
Expand Down Expand Up @@ -2297,6 +2313,13 @@ mod tests {
assert!(agents_from_names(&[]).is_empty());
}

#[test]
fn init_wizard_requires_input_and_output_terminals() {
assert!(should_run_init_wizard(true, false, false, true, true));
assert!(!should_run_init_wizard(true, false, false, false, true));
assert!(!should_run_init_wizard(true, false, false, true, false));
}

#[test]
fn menu_rows_pair_each_agent_with_what_it_writes() {
let rows = agent_menu_rows();
Expand Down
53 changes: 31 additions & 22 deletions crates/forgeguard-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ struct TaskStatusRequest {
session: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum DetailArg {
Metadata,
Structure,
Snippet,
Full,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum DirectionArg {
Inbound,
Outbound,
Both,
}

#[derive(Debug, Default, Deserialize, JsonSchema)]
struct DoctorRequest {}

Expand All @@ -62,7 +79,7 @@ struct MemorySymbolRequest {
/// Symbol name or `Type.method`.
query: String,
/// `metadata`, `structure` (default), `snippet`, or `full`.
detail: Option<String>,
detail: Option<DetailArg>,
/// Maximum source bytes to return; defaults to 8192.
max_bytes: Option<usize>,
}
Expand All @@ -81,7 +98,7 @@ struct MemoryTraceRequest {
/// Symbol name or `Type.method` to start from.
query: String,
/// `inbound` (callers), `outbound` (callees), or `both` (default).
direction: Option<String>,
direction: Option<DirectionArg>,
/// Hops to follow, 1 to 5; defaults to 2.
depth: Option<usize>,
}
Expand Down Expand Up @@ -222,7 +239,7 @@ impl ForgeGuardMcp {
let root = self.root.clone();
blocking(move || {
let store = ensure_index(&root)?;
let direction = parse_direction(request.direction.as_deref())?;
let direction = parse_direction(request.direction);
trace_path(
&root,
&store,
Expand Down Expand Up @@ -263,7 +280,7 @@ impl ForgeGuardMcp {
blocking(move || {
let store = ensure_index(&root)?;
let options = RetrievalOptions {
detail: parse_detail(request.detail.as_deref())?,
detail: parse_detail(request.detail),
max_bytes: request.max_bytes.unwrap_or(8192),
};
symbol_card(&root, &store, &request.query, &options)?
Expand Down Expand Up @@ -382,26 +399,20 @@ impl ForgeGuardMcp {
}
}

fn parse_detail(value: Option<&str>) -> Result<Detail> {
fn parse_detail(value: Option<DetailArg>) -> Detail {
match value {
None | Some("structure") => Ok(Detail::Structure),
Some("metadata") => Ok(Detail::Metadata),
Some("snippet") => Ok(Detail::Snippet),
Some("full") => Ok(Detail::Full),
Some(other) => {
anyhow::bail!("unknown detail {other}: use metadata, structure, snippet, or full")
}
None | Some(DetailArg::Structure) => Detail::Structure,
Some(DetailArg::Metadata) => Detail::Metadata,
Some(DetailArg::Snippet) => Detail::Snippet,
Some(DetailArg::Full) => Detail::Full,
}
}

fn parse_direction(value: Option<&str>) -> Result<Direction> {
fn parse_direction(value: Option<DirectionArg>) -> Direction {
match value {
None | Some("both") => Ok(Direction::Both),
Some("inbound") => Ok(Direction::Inbound),
Some("outbound") => Ok(Direction::Outbound),
Some(other) => {
anyhow::bail!("unknown direction {other}: use inbound, outbound, or both")
}
None | Some(DirectionArg::Both) => Direction::Both,
Some(DirectionArg::Inbound) => Direction::Inbound,
Some(DirectionArg::Outbound) => Direction::Outbound,
}
}

Expand Down Expand Up @@ -566,9 +577,7 @@ pub fn register_agents(root: &Path, agents: &[AgentTarget], quiet: bool) -> Vec<
}
}
Err(error) => {
if !quiet {
eprintln!(" MCP registration skipped for {id}: {error}");
}
eprintln!(" MCP registration skipped for {id}: {error}");
}
}
}
Expand Down
43 changes: 43 additions & 0 deletions crates/forgeguard-cli/tests/cli_regression_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
use std::{
fs,
process::{self, Command},
time::{SystemTime, UNIX_EPOCH},
};

fn temporary_directory(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"forgeguard-cli-{label}-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should follow the Unix epoch")
.as_nanos()
))
}

#[test]
fn json_init_reports_mcp_registration_failure_on_stderr() {
let root = temporary_directory("invalid-mcp");
fs::create_dir_all(&root).expect("temporary project should be created");
fs::write(root.join(".mcp.json"), "not json").expect("invalid MCP config should be created");

let output = Command::new(env!("CARGO_BIN_EXE_forgeguard"))
.args([
"--root",
root.to_str().expect("temporary path should be UTF-8"),
"init",
"--agent",
"claude",
"--mcp",
"--no-index",
"--json",
])
.output()
.expect("init command should run");

assert!(output.status.success());
serde_json::from_slice::<serde_json::Value>(&output.stdout)
.expect("init stdout should remain valid JSON");
assert!(String::from_utf8_lossy(&output.stderr).contains("MCP registration skipped"));
fs::remove_dir_all(root).expect("temporary project should be removed");
}
15 changes: 13 additions & 2 deletions crates/forgeguard-core/src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,19 +156,30 @@ fn write_baseline(root: &Path, findings: &[Finding]) -> Result<Baseline> {
Ok(baseline)
}

fn normalize_evidence(rule_id: &str, evidence: &str) -> String {
if rule_id.starts_with("FG-DRY-") {
if let Some((prefix, line)) = evidence.rsplit_once(':') {
if line.chars().all(|c| c.is_ascii_digit()) {
return prefix.to_owned();
}
}
}
evidence.to_owned()
}

fn key_for_finding(finding: &Finding) -> BaselineKey {
BaselineKey {
rule_id: finding.rule_id.clone(),
path: portable_path(&finding.path),
evidence: finding.evidence.clone(),
evidence: normalize_evidence(&finding.rule_id, &finding.evidence),
}
}

fn key_for_entry(entry: &BaselineEntry) -> BaselineKey {
BaselineKey {
rule_id: entry.rule_id.clone(),
path: entry.path.clone(),
evidence: entry.evidence.clone(),
evidence: normalize_evidence(&entry.rule_id, &entry.evidence),
}
}

Expand Down
12 changes: 8 additions & 4 deletions crates/forgeguard-core/src/coverage.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
Expand Down Expand Up @@ -36,8 +37,7 @@ pub(crate) fn changed_coverage_finding(
let source = fs::read_to_string(&report_path)
.with_context(|| format!("failed to read {}", report_path.display()))?;
let mut current = None;
let mut covered = 0usize;
let mut coverable = 0usize;
let mut changed_lines = BTreeMap::<(PathBuf, usize), u64>::new();
for line in source.lines() {
if let Some(path) = line.strip_prefix("SF:") {
current = Some(normalize_path(root, Path::new(path)));
Expand All @@ -63,13 +63,17 @@ pub(crate) fn changed_coverage_finding(
.iter()
.any(|(start, end)| (*start..=*end).contains(&line_number))
}) {
coverable += 1;
covered += usize::from(hits > 0);
let total = changed_lines
.entry((path.clone(), line_number))
.or_default();
*total = total.saturating_add(hits);
}
}
let coverable = changed_lines.len();
if coverable == 0 {
return Ok(None);
}
let covered = changed_lines.values().filter(|hits| **hits > 0).count();
let percent = covered.saturating_mul(100) / coverable;
Ok((percent < minimum as usize).then(|| {
coverage_finding(
Expand Down
9 changes: 6 additions & 3 deletions crates/forgeguard-core/src/duplication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub fn scan_duplicate_blocks(
blocking: false,
path: target.path.clone(),
line: target.line,
end_line: None,
end_line: Some(target.line.saturating_add(block_lines.saturating_sub(1))),
evidence: format!(
"A similar {}-line block also appears at {}:{}",
block_lines,
Expand Down Expand Up @@ -124,6 +124,7 @@ pub fn scan_duplicate_blocks(
struct CloneOccurrence {
path: PathBuf,
line: usize,
end_line: usize,
canonical: String,
original: String,
}
Expand All @@ -150,6 +151,7 @@ fn scan_renamed_duplicate_blocks(
.push(CloneOccurrence {
path: path.strip_prefix(root).unwrap_or(path).to_path_buf(),
line: function.line,
end_line: function.end_line,
canonical: function.canonical,
original: function.original,
});
Expand Down Expand Up @@ -194,7 +196,7 @@ fn scan_renamed_duplicate_blocks(
blocking: false,
path: target.path.clone(),
line: target.line,
end_line: None,
end_line: Some(target.end_line),
evidence: format!(
"An alpha-renamed function also appears at {}:{}",
other.path.display(),
Expand Down Expand Up @@ -226,6 +228,7 @@ fn scan_behavioral_duplicates(
let occurrence = CloneOccurrence {
path: path.strip_prefix(root).unwrap_or(path).to_path_buf(),
line: function.line,
end_line: function.end_line,
canonical: function.canonical,
original: function.original,
};
Expand Down Expand Up @@ -273,7 +276,7 @@ fn scan_behavioral_duplicates(
blocking: false,
path: target.path.clone(),
line: target.line,
end_line: None,
end_line: Some(target.end_line),
evidence: format!(
"A differently structured function invokes the same API operations at {}:{}",
other.path.display(),
Expand Down
2 changes: 1 addition & 1 deletion crates/forgeguard-core/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ pub fn render_hook_decision(agent: HookAgent, decision: &HookDecision) -> String
json!({"followup_message": reason}).to_string()
}
(HookAgent::Codex, HookDecision::Block(reason)) => {
json!({"decision": "block", "reason": reason}).to_string()
json!({"continue": true, "decision": "block", "reason": reason}).to_string()
}
(HookAgent::Antigravity, HookDecision::Block(reason)) => json!({
"decision": "continue",
Expand Down
Loading
Loading