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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ for complete role contracts, layouts, and defaults.
| --- | --- |
| `fensu rule FFL102` | Explain a rule and its effective settings. |
| `fensu map submit --root src --depth 2` | Show proven static Python calls, without running the program. |
| `fensu dupes --since origin/main --diff` | Review changed duplicated-code clusters; advisory, not a blocking check. |
| `fensu dupes --since origin/main --diff` | Review changed duplicated-code clusters; advisory unless `--fail-on-new` is passed. |
| `fensu skills --check` | Verify installed guidance matches the current policy. |

Call mapping needs neither Fensu configuration nor rule adoption. It marks
Expand Down
2 changes: 1 addition & 1 deletion crates/fensu-cli/src/command/main/_help.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
pub(super) fn help() -> String {
"Usage: fensu {init,check,dupes,rule,map,skills,target} ...\n\nCommands:\n init Initialize Fensu configuration for a repository.\n check Evaluate repository architecture rules.\n dupes Report duplicated code (advisory; never fails on findings).\n rule Show details for one rule.\n map Render a directional project call map.\n skills Generate and install agent guidance.\n target Add an explicit analyzer target.\n\nRun `fensu <command> --help` for command-specific options.\n".to_owned()
"Usage: fensu {init,check,dupes,rule,map,skills,target} ...\n\nCommands:\n init Initialize Fensu configuration for a repository.\n check Evaluate repository architecture rules.\n dupes Report duplicated code (advisory unless --fail-on-new).\n rule Show details for one rule.\n map Render a directional project call map.\n skills Generate and install agent guidance.\n target Add an explicit analyzer target.\n\nRun `fensu <command> --help` for command-specific options.\n".to_owned()
}
7 changes: 6 additions & 1 deletion crates/fensu-cli/src/dupes/_helpers/inputs/arguments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::dupes::constants::{DEFAULT_MIN_SIMILARITY, DEFAULT_MIN_TOKENS, DEFAULT_TOP, LANGUAGES};
use crate::dupes::models::DupesOptions;

const USAGE: &str = "usage: fensu dupes [-h] [--json] [--top TOP] [--min-similarity MIN_SIMILARITY]\n [--min-tokens MIN_TOKENS] [--lang LANG] [--path PATH]\n [--include-tests] [--since REV] [--diff]";
const USAGE: &str = "usage: fensu dupes [-h] [--json] [--top TOP] [--min-similarity MIN_SIMILARITY]\n [--min-tokens MIN_TOKENS] [--lang LANG] [--path PATH]\n [--include-tests] [--since REV] [--diff]\n [--fail-on-new]";
const MIN_SIMILARITY_EXCLUSIVE: f64 = 0.0;
const MAX_SIMILARITY: f64 = 1.0;
const MIN_COUNT: usize = 1;
Expand All @@ -28,6 +28,7 @@ pub(crate) fn parse_arguments(arguments: &[String]) -> Result<Option<DupesOption
include_tests: false,
since: None,
diff: false,
fail_on_new: false,
};
let mut position = 0;
while position < arguments.len() {
Expand All @@ -44,6 +45,7 @@ pub(crate) fn parse_arguments(arguments: &[String]) -> Result<Option<DupesOption
"--json" => options.json = true,
"--include-tests" => options.include_tests = true,
"--diff" => options.diff = true,
"--fail-on-new" => options.fail_on_new = true,
_ => return Err(usage_error(&format!("unrecognized arguments: {argument}"))),
}
position += 1;
Expand All @@ -61,6 +63,9 @@ pub(crate) fn parse_arguments(arguments: &[String]) -> Result<Option<DupesOption
options = apply_value(options, name, value)?;
position += 1;
}
if options.fail_on_new && options.since.is_none() {
return Err(usage_error("argument --fail-on-new: requires --since"));
}
if options.languages.is_empty() {
options.languages = LANGUAGES.to_vec();
}
Expand Down
1 change: 1 addition & 0 deletions crates/fensu-cli/src/dupes/_helpers/report/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub(crate) fn build_report(
}
Ok(DupesReport {
since: options.since.clone(),
advisory: !options.fail_on_new,
unit_counts,
allowlisted_pairs: retained.len() - pairs.len(),
contract_exempt_members: forced.difference(&retained_units).count(),
Expand Down
6 changes: 4 additions & 2 deletions crates/fensu-cli/src/dupes/_helpers/report/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::dupes::models::{CloneCluster, CloneMember, DupesReport, MemberDiff};
const INDENT: &str = " ";
const DIFF_INDENT: &str = " ";
const ADVISORY: &str = "advisory; duplicated-code findings to review, not fensu check failures";
const GATED: &str = "--fail-on-new; each cluster touching changed lines fails the command";

pub(crate) fn render_text(report: &DupesReport, top: usize) -> String {
let scope = report
Expand All @@ -16,9 +17,10 @@ pub(crate) fn render_text(report: &DupesReport, top: usize) -> String {
.map_or_else(String::new, |revision| format!(" changed since {revision}"));
let mut lines = vec![
format!(
"fensu dupes: {} duplicated-code cluster{}{scope} ({ADVISORY})",
"fensu dupes: {} duplicated-code cluster{}{scope} ({})",
report.clusters.len(),
if report.clusters.len() == 1 { "" } else { "s" },
if report.advisory { ADVISORY } else { GATED },
),
summary_line(report),
];
Expand Down Expand Up @@ -72,7 +74,7 @@ pub(crate) fn render_json(report: &DupesReport, top: usize) -> Result<String, St
.collect::<Result<Vec<_>, String>>()?;
let payload = json!({
"command": "dupes",
"advisory": true,
"advisory": report.advisory,
"since": report.since,
"unit_counts": report.unit_counts,
"allowlisted_pairs": report.allowlisted_pairs,
Expand Down
2 changes: 1 addition & 1 deletion crates/fensu-cli/src/dupes/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,4 @@ pub(crate) const CONFIG_FORBIDDEN_OWNERS_KEY: &str = "forbidden_owners";
pub(crate) const RUST_TEST_DIRECTORIES: &[&str] = &["tests", "benches"];
pub(crate) const SCOPE_TEST: &str = "test";

pub(crate) const DUPES_HELP: &str = "usage: fensu dupes [-h] [--json] [--top TOP] [--min-similarity MIN_SIMILARITY]\n [--min-tokens MIN_TOKENS]\n [--lang {python,rust,typescript,javascript,svelte}]\n [--path PATH] [--include-tests] [--since REV] [--diff]\n\nReport duplicated code (advisory). Findings never fail the command: it exits 0\nwhenever analysis succeeds and 2 for usage, configuration, or IO errors.\n\noptions:\n -h, --help show this help message and exit\n --json print deterministic JSON instead of text\n --top TOP number of clusters to print (default 30)\n --min-similarity MIN_SIMILARITY\n minimum token similarity in (0, 1] (default 0.8)\n --min-tokens MIN_TOKENS\n minimum normalised tokens per unit (default 60)\n --lang {python,rust,typescript,javascript,svelte}\n analyze one language; repeat for several\n --path PATH only clusters with a member matching this glob; repeatable\n --include-tests also analyze configured tests and Rust test code\n --since REV only clusters touching lines changed since REV,\n including uncommitted and untracked files\n --diff show where the first two visible members diverge\n";
pub(crate) const DUPES_HELP: &str = "usage: fensu dupes [-h] [--json] [--top TOP] [--min-similarity MIN_SIMILARITY]\n [--min-tokens MIN_TOKENS]\n [--lang {python,rust,typescript,javascript,svelte}]\n [--path PATH] [--include-tests] [--since REV] [--diff]\n [--fail-on-new]\n\nReport duplicated code (advisory). Findings fail the command only with\n--fail-on-new: it exits 0 whenever analysis succeeds, 1 when --fail-on-new finds\na cluster touching changed lines, and 2 for usage, configuration, or IO errors.\n\noptions:\n -h, --help show this help message and exit\n --json print deterministic JSON instead of text\n --top TOP number of clusters to print (default 30)\n --min-similarity MIN_SIMILARITY\n minimum token similarity in (0, 1] (default 0.8)\n --min-tokens MIN_TOKENS\n minimum normalised tokens per unit (default 60)\n --lang {python,rust,typescript,javascript,svelte}\n analyze one language; repeat for several\n --path PATH only clusters with a member matching this glob; repeatable\n --include-tests also analyze configured tests and Rust test code\n --since REV only clusters touching lines changed since REV,\n including uncommitted and untracked files\n --diff show where the first two visible members diverge\n --fail-on-new exit 1 when a cluster not hidden by [dupes] touches\n lines changed since --since REV\n";
29 changes: 21 additions & 8 deletions crates/fensu-cli/src/dupes/main/run_dupes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::dupes::_helpers::report::rendering::{render_json, render_text};
use crate::dupes::constants::DUPES_HELP;
use crate::models::CliOutput;

/// Exit 0 whenever analysis succeeds, regardless of findings; errors exit 2.
/// Exit 0 when analysis succeeds, 1 when `--fail-on-new` finds a cluster, and 2 on errors.
pub(crate) fn run_dupes(arguments: &[String]) -> Result<CliOutput, String> {
let Some(options) = parse_arguments(arguments)? else {
return Ok(CliOutput::success(DUPES_HELP.to_owned()));
Expand All @@ -31,14 +31,27 @@ pub(crate) fn run_dupes(arguments: &[String]) -> Result<CliOutput, String> {
render_text(&report, options.top)
};
let units: usize = report.unit_counts.values().sum();
let clusters = report.clusters.len();
let plural = if clusters == 1 { "" } else { "s" };
let mut stderr = format!(
"fensu dupes: found {clusters} duplicated-code cluster{plural} across {units} units in {:.1}s",
started.elapsed().as_secs_f64()
);
let failed = options.fail_on_new && clusters > 0;
stderr.push_str(match (options.fail_on_new, failed) {
(false, _) => " (advisory)\n",
(true, false) => " (no new clusters)\n",
(true, true) => "\n",
});
if failed {
let verb = if clusters == 1 { "touches" } else { "touch" };
stderr.push_str(&format!(
"fensu dupes: failed: {clusters} new cluster{plural} {verb} changed lines; consolidate them or list them in [dupes] allowlist with a reason\n"
));
}
Ok(CliOutput {
stdout,
stderr: format!(
"fensu dupes: found {} duplicated-code cluster{} across {units} units in {:.1}s (advisory)\n",
report.clusters.len(),
if report.clusters.len() == 1 { "" } else { "s" },
started.elapsed().as_secs_f64()
),
exit_code: 0,
stderr,
exit_code: i32::from(failed),
})
}
2 changes: 2 additions & 0 deletions crates/fensu-cli/src/dupes/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub(crate) struct DupesOptions {
pub(crate) include_tests: bool,
pub(crate) since: Option<String>,
pub(crate) diff: bool,
pub(crate) fail_on_new: bool,
}

#[derive(Clone, Debug, Default)]
Expand Down Expand Up @@ -163,6 +164,7 @@ pub(crate) struct CloneCluster {
#[derive(Clone, Debug)]
pub(crate) struct DupesReport {
pub(crate) since: Option<String>,
pub(crate) advisory: bool,
pub(crate) unit_counts: BTreeMap<&'static str, usize>,
pub(crate) allowlisted_pairs: usize,
pub(crate) contract_exempt_members: usize,
Expand Down
4 changes: 2 additions & 2 deletions crates/fensu-cli/tests/dupes/test_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,12 @@ fn given_invalid_configuration_or_arguments_when_running_dupes_then_errors_exit_
expected_stderr: &["argument --lang: invalid choice: 'cobol'"],
},
CommandOutputTestCase {
description: "help documents the advisory exit contract",
description: "help documents the exit contract",
config: with_dupes(""),
files: copies(),
arguments: &["--help"],
expected_exit_code: 0,
expected_stdout: &["usage: fensu dupes", "Findings never fail the command"],
expected_stdout: &["usage: fensu dupes", "Findings fail the command only with\n--fail-on-new"],
expected_stderr: &[],
},
];
Expand Down
108 changes: 107 additions & 1 deletion crates/fensu-cli/tests/dupes/test_reporting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use crate::helpers::{
spaced_summaries, summary, text, write, write_files, PYTHON_CONFIG, PYTHON_EXTRA,
PYTHON_UNRELATED,
};
use crate::test_types::{ChangedSinceTestCase, CommandOutputTestCase, JsonShapeTestCase};
use crate::test_types::{
ChangedSinceTestCase, CommandOutputTestCase, FailOnNewTestCase, JsonShapeTestCase,
};

#[test]
fn given_clone_findings_when_rendering_text_then_output_is_advisory_ranked_and_exits_zero() {
Expand Down Expand Up @@ -362,3 +364,107 @@ fn given_git_changes_when_filtering_since_revision_then_only_touched_clusters_re
);
}
}

#[test]
fn given_fail_on_new_when_a_changed_line_joins_a_cluster_then_only_unlisted_clusters_fail() {
let allowlisted = format!(
"{PYTHON_CONFIG}\n[dupes]\n\n[[dupes.allowlist]]\npaths = [\"src/shop/*/summary.py\"]\nreason = \"Exports mirror the order summary contract.\"\n"
);
let test_cases = [
FailOnNewTestCase {
description: "a new copy of committed code fails",
config: PYTHON_CONFIG.to_owned(),
worktree: vec![("src/shop/exports/summary.py", summary(""))],
arguments: &["--since", "HEAD", "--fail-on-new"],
expected_exit: 1,
expected_stdout:
"(--fail-on-new; each cluster touching changed lines fails the command)",
expected_stderr: "1 new cluster touches changed lines",
},
FailOnNewTestCase {
description: "an allowlisted new copy passes",
config: allowlisted,
worktree: vec![("src/shop/exports/summary.py", summary(""))],
arguments: &["--since", "HEAD", "--fail-on-new"],
expected_exit: 0,
expected_stdout: "(--fail-on-new;",
expected_stderr: "(no new clusters)",
},
FailOnNewTestCase {
description: "an existing cluster untouched by the change passes",
config: PYTHON_CONFIG.to_owned(),
worktree: vec![("src/shop/warehouse/notes.py", "NOTE = 1\n".to_owned())],
arguments: &["--since", "HEAD", "--fail-on-new"],
expected_exit: 0,
expected_stdout: "(--fail-on-new;",
expected_stderr: "(no new clusters)",
},
FailOnNewTestCase {
description: "the same new copy without the flag stays advisory",
config: PYTHON_CONFIG.to_owned(),
worktree: vec![("src/shop/exports/summary.py", summary(""))],
arguments: &["--since", "HEAD"],
expected_exit: 0,
expected_stdout: "(advisory;",
expected_stderr: "(advisory)",
},
FailOnNewTestCase {
description: "JSON output reports the run as gated",
config: PYTHON_CONFIG.to_owned(),
worktree: vec![("src/shop/exports/summary.py", summary(""))],
arguments: &["--since", "HEAD", "--fail-on-new", "--json"],
expected_exit: 1,
expected_stdout: "\"advisory\": false",
expected_stderr: "1 new cluster touches changed lines",
},
FailOnNewTestCase {
description: "the flag without a base revision is a usage error",
config: PYTHON_CONFIG.to_owned(),
worktree: vec![],
arguments: &["--fail-on-new"],
expected_exit: 2,
expected_stdout: "",
expected_stderr: "--fail-on-new: requires --since",
},
];
for test_case in &test_cases {
let repository = tempfile::tempdir().expect("temporary repository");
write(repository.path().join("fensu.toml"), &test_case.config);
write_files(
repository.path(),
&[
("src/shop/orders/summary.py", summary("")),
("src/shop/billing/summary.py", summary("")),
],
);
git(repository.path(), &["init", "--quiet"]);
git(repository.path(), &["add", "."]);
git(
repository.path(),
&["commit", "--quiet", "-m", "seed orders"],
);
write_files(repository.path(), &test_case.worktree);

let output = run_dupes(repository.path(), test_case.arguments);

assert_eq!(
output.status.code(),
Some(test_case.expected_exit),
"{}: {}",
test_case.description,
text(&output.stderr)
);
assert!(
text(&output.stdout).contains(test_case.expected_stdout),
"{}: {}",
test_case.description,
text(&output.stdout)
);
assert!(
text(&output.stderr).contains(test_case.expected_stderr),
"{}: {}",
test_case.description,
text(&output.stderr)
);
}
}
10 changes: 10 additions & 0 deletions crates/fensu-cli/tests/dupes/test_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,13 @@ pub(crate) struct JsonShapeTestCase {
pub(crate) expected_member_keys: &'static [&'static str],
pub(crate) expected_diff_lines: &'static [(u64, u64, &'static str)],
}

pub(crate) struct FailOnNewTestCase {
pub(crate) description: &'static str,
pub(crate) config: String,
pub(crate) worktree: Vec<(&'static str, String)>,
pub(crate) arguments: &'static [&'static str],
pub(crate) expected_exit: i32,
pub(crate) expected_stdout: &'static str,
pub(crate) expected_stderr: &'static str,
}
6 changes: 1 addition & 5 deletions crates/fensu-native/src/rules/_helpers/roles/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,7 @@ fn custom_rule_coverage_faults(code: &str, context: &NativeRuleContext) -> Vec<N
let mut parts = answer.split('\0');
(parts.next()? == rule_code).then(|| {
(
parts
.next()
.unwrap_or("0")
.parse::<u32>()
.map_or(0, |count| count),
parts.next().unwrap_or("0").parse::<u32>().unwrap_or(0),
parts.next() == Some("true"),
)
})
Expand Down
Loading
Loading