From 0db20c68deb1a2a1cb18e2c44095f84e82d82cda Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:44:35 +0000 Subject: [PATCH] feat(dupes): add an opt-in --fail-on-new gate for new duplication With --since REV --fail-on-new, fensu dupes exits 1 when a cluster not hidden by [dupes] touches lines changed since REV, so CI can block new copies without failing on the existing backlog. The report says it is gated (JSON advisory is false). The default stays advisory. Also apply a clippy unwrap_or simplification the current stable toolchain requires in the roles path parser. --- README.md | 2 +- crates/fensu-cli/src/command/main/_help.rs | 2 +- .../src/dupes/_helpers/inputs/arguments.rs | 7 +- .../src/dupes/_helpers/report/build.rs | 1 + .../src/dupes/_helpers/report/rendering.rs | 6 +- crates/fensu-cli/src/dupes/constants.rs | 2 +- crates/fensu-cli/src/dupes/main/run_dupes.rs | 29 +++-- crates/fensu-cli/src/dupes/models.rs | 2 + crates/fensu-cli/tests/dupes/test_config.rs | 4 +- .../fensu-cli/tests/dupes/test_reporting.rs | 108 +++++++++++++++++- crates/fensu-cli/tests/dupes/test_types.rs | 10 ++ .../src/rules/_helpers/roles/paths.rs | 6 +- docs/dupes.md | 11 +- tests/e2e/src/fensu/cli/main/test_entry.py | 2 +- website/src/content/docs/docs/adoption.mdx | 3 +- website/src/content/docs/docs/cli/dupes.mdx | 38 ++++-- website/src/content/docs/docs/cli/index.mdx | 6 +- .../docs/docs/concepts/duplicated-code.mdx | 5 +- website/src/content/docs/docs/index.mdx | 4 +- website/src/content/docs/docs/philosophy.mdx | 2 +- 20 files changed, 206 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 5f743dd5..bdbefad9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/fensu-cli/src/command/main/_help.rs b/crates/fensu-cli/src/command/main/_help.rs index f5ddc00a..f007ea83 100644 --- a/crates/fensu-cli/src/command/main/_help.rs +++ b/crates/fensu-cli/src/command/main/_help.rs @@ -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 --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 --help` for command-specific options.\n".to_owned() } diff --git a/crates/fensu-cli/src/dupes/_helpers/inputs/arguments.rs b/crates/fensu-cli/src/dupes/_helpers/inputs/arguments.rs index 3108ec58..dc0b2414 100644 --- a/crates/fensu-cli/src/dupes/_helpers/inputs/arguments.rs +++ b/crates/fensu-cli/src/dupes/_helpers/inputs/arguments.rs @@ -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; @@ -28,6 +28,7 @@ pub(crate) fn parse_arguments(arguments: &[String]) -> Result Result 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; @@ -61,6 +63,9 @@ pub(crate) fn parse_arguments(arguments: &[String]) -> Result String { let scope = report @@ -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), ]; @@ -72,7 +74,7 @@ pub(crate) fn render_json(report: &DupesReport, top: usize) -> Result, String>>()?; let payload = json!({ "command": "dupes", - "advisory": true, + "advisory": report.advisory, "since": report.since, "unit_counts": report.unit_counts, "allowlisted_pairs": report.allowlisted_pairs, diff --git a/crates/fensu-cli/src/dupes/constants.rs b/crates/fensu-cli/src/dupes/constants.rs index c303975f..8ee6a787 100644 --- a/crates/fensu-cli/src/dupes/constants.rs +++ b/crates/fensu-cli/src/dupes/constants.rs @@ -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"; diff --git a/crates/fensu-cli/src/dupes/main/run_dupes.rs b/crates/fensu-cli/src/dupes/main/run_dupes.rs index b0244253..c44769a9 100644 --- a/crates/fensu-cli/src/dupes/main/run_dupes.rs +++ b/crates/fensu-cli/src/dupes/main/run_dupes.rs @@ -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 { let Some(options) = parse_arguments(arguments)? else { return Ok(CliOutput::success(DUPES_HELP.to_owned())); @@ -31,14 +31,27 @@ pub(crate) fn run_dupes(arguments: &[String]) -> Result { 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), }) } diff --git a/crates/fensu-cli/src/dupes/models.rs b/crates/fensu-cli/src/dupes/models.rs index 4119526f..f7ff3999 100644 --- a/crates/fensu-cli/src/dupes/models.rs +++ b/crates/fensu-cli/src/dupes/models.rs @@ -16,6 +16,7 @@ pub(crate) struct DupesOptions { pub(crate) include_tests: bool, pub(crate) since: Option, pub(crate) diff: bool, + pub(crate) fail_on_new: bool, } #[derive(Clone, Debug, Default)] @@ -163,6 +164,7 @@ pub(crate) struct CloneCluster { #[derive(Clone, Debug)] pub(crate) struct DupesReport { pub(crate) since: Option, + pub(crate) advisory: bool, pub(crate) unit_counts: BTreeMap<&'static str, usize>, pub(crate) allowlisted_pairs: usize, pub(crate) contract_exempt_members: usize, diff --git a/crates/fensu-cli/tests/dupes/test_config.rs b/crates/fensu-cli/tests/dupes/test_config.rs index 5e3ade25..3622d836 100644 --- a/crates/fensu-cli/tests/dupes/test_config.rs +++ b/crates/fensu-cli/tests/dupes/test_config.rs @@ -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: &[], }, ]; diff --git a/crates/fensu-cli/tests/dupes/test_reporting.rs b/crates/fensu-cli/tests/dupes/test_reporting.rs index 30987cef..57d6fda5 100644 --- a/crates/fensu-cli/tests/dupes/test_reporting.rs +++ b/crates/fensu-cli/tests/dupes/test_reporting.rs @@ -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() { @@ -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) + ); + } +} diff --git a/crates/fensu-cli/tests/dupes/test_types.rs b/crates/fensu-cli/tests/dupes/test_types.rs index 75973a13..698afd7a 100644 --- a/crates/fensu-cli/tests/dupes/test_types.rs +++ b/crates/fensu-cli/tests/dupes/test_types.rs @@ -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, +} diff --git a/crates/fensu-native/src/rules/_helpers/roles/paths.rs b/crates/fensu-native/src/rules/_helpers/roles/paths.rs index 84018c39..55bd2ef7 100644 --- a/crates/fensu-native/src/rules/_helpers/roles/paths.rs +++ b/crates/fensu-native/src/rules/_helpers/roles/paths.rs @@ -281,11 +281,7 @@ fn custom_rule_coverage_faults(code: &str, context: &NativeRuleContext) -> Vec() - .map_or(0, |count| count), + parts.next().unwrap_or("0").parse::().unwrap_or(0), parts.next() == Some("true"), ) }) diff --git a/docs/dupes.md b/docs/dupes.md index 432b62df..8d1ae69a 100644 --- a/docs/dupes.md +++ b/docs/dupes.md @@ -1,8 +1,10 @@ # Duplicated-code detection -`fensu dupes` reports concrete duplicated code. It is an advisory review aid, not a gate: +`fensu dupes` reports concrete duplicated code. By default it is an advisory review aid, not a gate: - It exits 0 whenever analysis succeeds, whatever it finds. +- With `--since REV --fail-on-new` it exits 1 when a cluster not hidden by `[dupes]` touches lines + changed since `REV`, so it can block new copies without failing on existing ones. - It exits 2 for usage errors, invalid configuration, unknown `--since` revisions, and IO failures. - Unlike `fensu check`, its findings never need to reach zero. Treat each cluster as a hypothesis: consolidate genuine copies, and record intentional mirrors in `[dupes]` with a reason. @@ -11,6 +13,7 @@ fensu dupes # top 30 clusters for the whole repository fensu dupes --since origin/main # only clusters touching lines changed since a revision fensu dupes --since origin/main --diff # plus where the first two copies diverge +fensu dupes --since origin/main --fail-on-new # exit 1 on new, unlisted clusters fensu dupes --lang rust --top 10 --json ``` @@ -83,8 +86,9 @@ Each cluster shows its worst category, similarity range, duplicated-token estima `[forced]` marks contract-exempt members (below). `--diff` adds up to 12 differing lines between the first two members, which are visible (non-forced) members whenever the cluster has two. -`--json` prints the same report as deterministic JSON: `command`, `advisory`, `since`, -`unit_counts`, `allowlisted_pairs`, `contract_exempt_members`, `total_clusters`, and `clusters`. +`--json` prints the same report as deterministic JSON: `command`, `advisory` (`false` under +`--fail-on-new`), `since`, `unit_counts`, `allowlisted_pairs`, `contract_exempt_members`, +`total_clusters`, and `clusters`. Each cluster has `rank`, `category`, `similarity_min`, `similarity_max`, `duplicated_tokens`, `members` (`language`, `path`, `name`, `start_line`, `end_line`, `tokens`, `changed`, `forced`), `links` between member indexes, and, with `--diff`, `diff` with differing `lines` @@ -100,6 +104,7 @@ Each cluster has `rank`, `category`, `similarity_min`, `similarity_max`, `duplic | `--include-tests` | Also analyse test code. | | `--since REV` | Only clusters touching lines added or changed since `REV`, including uncommitted and untracked files. | | `--diff` | Show where the first two visible members diverge. | +| `--fail-on-new` | Exit 1 when a cluster touches lines changed since `--since` (required). | | `--json` | Machine-readable output. | ## Configuration diff --git a/tests/e2e/src/fensu/cli/main/test_entry.py b/tests/e2e/src/fensu/cli/main/test_entry.py index 956be03d..6e3c4ce3 100644 --- a/tests/e2e/src/fensu/cli/main/test_entry.py +++ b/tests/e2e/src/fensu/cli/main/test_entry.py @@ -24,7 +24,7 @@ "Commands:\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" + " 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" diff --git a/website/src/content/docs/docs/adoption.mdx b/website/src/content/docs/docs/adoption.mdx index 086244f7..2217e455 100644 --- a/website/src/content/docs/docs/adoption.mdx +++ b/website/src/content/docs/docs/adoption.mdx @@ -100,7 +100,8 @@ where a reviewer can see it, rather than scattered through the source. See ## Duplicated code in an existing repository [`fensu dupes`](/cli/dupes) is advisory, so you can add it on day one without -changing what CI blocks. An established repository usually has a backlog of +changing what CI blocks. When you want to stop new copies, add +`--since origin/main --fail-on-new`, which leaves the existing backlog alone. An established repository usually has a backlog of copies. Treat that backlog as a record of past drift, not as a to-do list that must reach zero. diff --git a/website/src/content/docs/docs/cli/dupes.mdx b/website/src/content/docs/docs/cli/dupes.mdx index 9ccbaddd..8b704176 100644 --- a/website/src/content/docs/docs/cli/dupes.mdx +++ b/website/src/content/docs/docs/cli/dupes.mdx @@ -1,11 +1,11 @@ --- title: fensu dupes -description: "Report ranked clusters of duplicated functions as advisory review input, and act on them as evidence of drift." +description: "Report ranked clusters of duplicated functions as review input, act on them as evidence of drift, and optionally block new copies." --- `fensu dupes` reports concrete duplicated code: ranked clusters of exact, renamed, and near-miss function-level copies in Python, Rust, TypeScript, JavaScript, and -Svelte. It is an advisory review aid, not a gate. Where [`fensu check`](/cli/check) +Svelte. By default it is an advisory review aid, not a gate. Where [`fensu check`](/cli/check) enforces the policy, `dupes` points at copies worth a second look. For why duplication counts as architectural drift, and a worked example, see [Duplicated code](/concepts/duplicated-code). @@ -13,13 +13,13 @@ duplication counts as architectural drift, and a worked example, see ```bash fensu dupes [--json] [--top TOP] [--min-similarity MIN_SIMILARITY] [--min-tokens MIN_TOKENS] [--lang LANG ...] [--path PATH ...] - [--include-tests] [--since REV] [--diff] + [--include-tests] [--since REV] [--diff] [--fail-on-new] ``` - `fensu dupes` never fails on findings. It exits `0` whenever analysis succeeds, - whatever it finds. Unlike `fensu check`, its findings never need to reach zero: - treat each cluster as a hypothesis to review. + Without `--fail-on-new`, `fensu dupes` never fails on findings: it exits `0` + whenever analysis succeeds. Unlike `fensu check`, its findings never need to + reach zero: treat each cluster as a hypothesis to review. ## Usage @@ -98,6 +98,7 @@ clean. | `--include-tests` | flag | off | Also analyse configured tests and Rust test code. | | `--since` | Git revision | none | Keep only clusters touching lines changed since the revision. | | `--diff` | flag | off | Show where the first two visible members diverge. | +| `--fail-on-new` | flag | off | Exit `1` when a cluster touches changed lines. Requires `--since`. | | `--json` | flag | off | Print deterministic JSON instead of text. | ### Changes since a revision @@ -116,6 +117,24 @@ An unknown revision is a usage error and exits `2`: fensu dupes: analysis failed: --since revision no-such-rev is not a commit in this repository. ``` +### Failing on new duplication + +`--fail-on-new` turns `--since` into a gate for new copies. It exits `1` when any +cluster not hidden by [`[dupes]`](#configuration) touches lines changed since +the revision, so existing duplication elsewhere never fails the check: + +```bash +fensu dupes --since origin/main --fail-on-new +``` + +```text +fensu dupes: found 1 duplicated-code cluster across 7 units in 0.0s +fensu dupes: failed: 1 new cluster touches changed lines; consolidate them or list them in [dupes] allowlist with a reason +``` + +To accept an intentional copy, add an [allowlist](#allowlist) entry with a +reason. Without `--since`, the flag is a usage error and exits `2`. + ### Showing divergence `--diff` adds up to 12 differing lines between the first two members of each @@ -223,8 +242,8 @@ fensu dupes --json --path 'src/shop/inventory.py' --diff } ``` -`total_clusters` counts every matching cluster, while `clusters` holds at most -`--top` entries. `links` connect member indexes, and `diff` appears only with +`advisory` is `false` under `--fail-on-new`. `total_clusters` counts every +matching cluster, while `clusters` holds at most `--top` entries. `links` connect member indexes, and `diff` appears only with `--diff`. ## What is analysed @@ -391,7 +410,8 @@ Generated [agent skills](/cli/skills) include the same guidance, so agents run | Code | Meaning | | --- | --- | -| `0` | Analysis succeeded, whether or not it found duplicated code. | +| `0` | Analysis succeeded. Without `--fail-on-new`, whether or not it found duplicated code. | +| `1` | `--fail-on-new` found a cluster touching changed lines. | | `2` | Usage error, invalid or missing configuration, unknown `--since` revision, or IO failure. | ## Related diff --git a/website/src/content/docs/docs/cli/index.mdx b/website/src/content/docs/docs/cli/index.mdx index abfbc52c..f60fa16e 100644 --- a/website/src/content/docs/docs/cli/index.mdx +++ b/website/src/content/docs/docs/cli/index.mdx @@ -17,7 +17,7 @@ Usage: fensu {init,check,dupes,rule,map,skills,target} ... Commands: init Initialize Fensu configuration for a repository. check Evaluate repository architecture rules. - dupes Report duplicated code (advisory; never fails on findings). + dupes Report duplicated code (advisory unless --fail-on-new). rule Show details for one rule. map Render a directional project call map. skills Generate and install agent guidance. @@ -67,7 +67,7 @@ fensu 0.4.0 ## Exit codes Commands generally use `0` for success and `2` for usage, configuration, or setup -errors. `fensu check` uses `1` for blocking faults, `fensu dupes` exits `0` -whatever it finds, and +errors. `fensu check` uses `1` for blocking faults, `fensu dupes` uses `1` only for +new duplication under `--fail-on-new`, and `fensu skills --check` uses `1` when generated files need an update. See each command page for its complete contract. diff --git a/website/src/content/docs/docs/concepts/duplicated-code.mdx b/website/src/content/docs/docs/concepts/duplicated-code.mdx index 53d4ec0c..073a29d5 100644 --- a/website/src/content/docs/docs/concepts/duplicated-code.mdx +++ b/website/src/content/docs/docs/concepts/duplicated-code.mdx @@ -262,8 +262,9 @@ helper to be copied. ## What it is not -- **Not a gate.** `fensu dupes` exits `0` whenever analysis succeeds. It never - fails CI on findings. +- **Not a gate by default.** `fensu dupes` exits `0` whenever analysis succeeds. + Only [`--fail-on-new`](/cli/dupes#failing-on-new-duplication) fails CI, and only + on clusters touching changed lines. - **Not a zero target.** Findings never need to reach zero. A cluster is a hypothesis, and some clusters are correct as they are. - **Not a style metric.** There is no duplication score or percentage. Clusters diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index 6b1725a2..9b70b141 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -115,8 +115,8 @@ Some drift breaks no rule. When several modules each grow their own copy of one behaviour, every copy can sit in a valid place with a valid shape, and the copies still drift apart as fixes land in only one of them. [`fensu dupes`](/cli/dupes) reports those copies as ranked clusters, with the -lines where they disagree. It is advisory: it never fails a build, and its -findings never need to reach zero. +lines where they disagree. It is advisory by default, and its findings never +need to reach zero; `--fail-on-new` blocks only new copies on changed lines. Why a copy is architectural drift, and how to diagnose and consolidate it. diff --git a/website/src/content/docs/docs/philosophy.mdx b/website/src/content/docs/docs/philosophy.mdx index a6b66a7c..b80d29df 100644 --- a/website/src/content/docs/docs/philosophy.mdx +++ b/website/src/content/docs/docs/philosophy.mdx @@ -74,7 +74,7 @@ it a concrete thing to argue with if it is wrong. The same standard is why duplicated code is reported, not enforced. Fensu can prove deterministically that two functions match. It cannot prove that they should be one, so [`fensu dupes`](/concepts/duplicated-code) presents the copies -as evidence and never fails a build. +as evidence. It fails a build only when you opt in to blocking new duplication. ## Not a style tool