From b5098c33ff7b65227ab68ae03fa8b6268e0ad9a3 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Sun, 31 May 2026 19:54:33 +0200 Subject: [PATCH 1/5] fix(mcp): restore schema descriptions and regenerate schema --- crates/cli/src/api.rs | 30 - crates/cli/src/audit.rs | 317 +-------- crates/cli/src/baseline.rs | 54 -- crates/cli/src/bin/schema_emit.rs | 526 +------------- crates/cli/src/bin/stub_sidecar.rs | 3 - crates/cli/src/check/filtering.rs | 122 ---- crates/cli/src/check/mod.rs | 60 -- crates/cli/src/check/output.rs | 28 +- crates/cli/src/check/rules.rs | 41 -- crates/cli/src/ci.rs | 54 -- crates/cli/src/ci_template.rs | 10 - crates/cli/src/codeowners.rs | 57 -- crates/cli/src/combined.rs | 67 -- crates/cli/src/config.rs | 16 - crates/cli/src/coverage/analyze.rs | 36 - crates/cli/src/coverage/cloud_client.rs | 12 - crates/cli/src/coverage/mod.rs | 7 - crates/cli/src/coverage/upload_inventory.rs | 65 -- crates/cli/src/coverage/upload_source_maps.rs | 6 - .../src/coverage/upload_static_findings.rs | 36 +- crates/cli/src/dupes.rs | 113 ---- crates/cli/src/explain.rs | 47 -- crates/cli/src/fix/catalog.rs | 106 +-- crates/cli/src/fix/config.rs | 17 - crates/cli/src/fix/deps.rs | 30 +- crates/cli/src/fix/enum_members.rs | 31 - crates/cli/src/fix/exports.rs | 57 -- crates/cli/src/fix/io.rs | 21 +- crates/cli/src/fix/mod.rs | 40 -- crates/cli/src/fix/plan.rs | 233 +------ crates/cli/src/flags.rs | 29 - crates/cli/src/health/coverage.rs | 113 ---- crates/cli/src/health/grouping.rs | 3 - crates/cli/src/health/hotspots.rs | 60 +- crates/cli/src/health/mod.rs | 304 --------- crates/cli/src/health/ownership.rs | 48 +- crates/cli/src/health/scoring.rs | 331 +-------- crates/cli/src/health/targets.rs | 65 -- crates/cli/src/health_types/finding.rs | 237 +------ crates/cli/src/health_types/mod.rs | 1 - crates/cli/src/health_types/scores.rs | 397 +---------- crates/cli/src/health_types/targets.rs | 10 - crates/cli/src/health_types/vital_signs.rs | 3 - crates/cli/src/impact.rs | 312 +-------- crates/cli/src/init.rs | 49 -- crates/cli/src/license/mod.rs | 8 +- crates/cli/src/list.rs | 101 --- crates/cli/src/main.rs | 212 +----- crates/cli/src/migrate/jscpd.rs | 74 +- crates/cli/src/migrate/jsonc.rs | 6 - crates/cli/src/migrate/knip.rs | 99 --- crates/cli/src/migrate/knip_fields.rs | 31 - crates/cli/src/migrate/knip_tables.rs | 8 - crates/cli/src/migrate/mod.rs | 29 +- crates/cli/src/migrate/tests.rs | 125 ---- crates/cli/src/migrate/toml_gen.rs | 7 - crates/cli/src/output_dupes.rs | 5 - crates/cli/src/output_envelope.rs | 554 +-------------- crates/cli/src/programmatic.rs | 5 - crates/cli/src/rayon_pool.rs | 7 - crates/cli/src/regression/baseline.rs | 74 -- crates/cli/src/regression/counts.rs | 31 +- crates/cli/src/regression/mod.rs | 3 - crates/cli/src/regression/outcome.rs | 15 +- crates/cli/src/regression/tolerance.rs | 40 +- crates/cli/src/report/badge.rs | 8 - crates/cli/src/report/ci/diff_filter.rs | 64 -- crates/cli/src/report/ci/fingerprint.rs | 34 +- crates/cli/src/report/ci/pr_comment.rs | 52 -- crates/cli/src/report/ci/review.rs | 59 -- crates/cli/src/report/ci/suggestion.rs | 23 +- crates/cli/src/report/codeclimate.rs | 43 -- crates/cli/src/report/compact.rs | 22 - crates/cli/src/report/dupes_grouping.rs | 16 - crates/cli/src/report/grouping.rs | 58 -- crates/cli/src/report/human/check.rs | 132 ---- crates/cli/src/report/human/cross_ref.rs | 2 - crates/cli/src/report/human/dupes.rs | 36 - crates/cli/src/report/human/health.rs | 161 +---- crates/cli/src/report/human/mod.rs | 12 - crates/cli/src/report/human/perf.rs | 24 - crates/cli/src/report/human/traces.rs | 1 - crates/cli/src/report/json.rs | 275 -------- crates/cli/src/report/markdown.rs | 79 --- crates/cli/src/report/mod.rs | 64 +- crates/cli/src/report/sarif.rs | 77 --- crates/cli/src/runtime_support.rs | 25 - crates/cli/src/schema.rs | 5 - crates/cli/src/setup_hooks.rs | 65 +- crates/cli/src/signal/mod.rs | 5 - crates/cli/src/signal/registry.rs | 15 +- crates/cli/src/signal/scoped_child.rs | 13 - crates/cli/src/signal/unix.rs | 5 - crates/cli/src/signal/windows.rs | 8 - crates/cli/src/telemetry.rs | 23 - crates/cli/src/validate.rs | 12 - crates/cli/src/vital_signs.rs | 80 --- crates/cli/src/watch.rs | 27 +- crates/cli/tests/audit_tests.rs | 41 -- crates/cli/tests/changed_workspaces_tests.rs | 24 - crates/cli/tests/check_tests.rs | 45 -- crates/cli/tests/codeowners_tests.rs | 14 - crates/cli/tests/common/mod.rs | 1 - crates/cli/tests/common/sign.rs | 1 - crates/cli/tests/coverage_analyze_tests.rs | 3 - crates/cli/tests/dupes_tests.rs | 26 - crates/cli/tests/dupes_workspace_tests.rs | 26 - crates/cli/tests/exit_code_tests.rs | 93 --- crates/cli/tests/fix_tests.rs | 78 --- crates/cli/tests/flags_tests.rs | 6 - crates/cli/tests/health_tests.rs | 147 ---- crates/cli/tests/init_tests.rs | 5 - crates/cli/tests/list_tests.rs | 68 -- crates/cli/tests/migrate_tests.rs | 60 -- .../cli/tests/production_workspace_tests.rs | 20 - crates/cli/tests/runtime_coverage_tests.rs | 32 - crates/cli/tests/schema_tests.rs | 12 - crates/cli/tests/signal_tests.rs | 5 - crates/cli/tests/snapshot_tests.rs | 71 -- crates/config/src/config/boundaries.rs | 640 ++---------------- crates/config/src/config/duplicates_config.rs | 28 - crates/config/src/config/format.rs | 6 - crates/config/src/config/glob_validation.rs | 17 - crates/config/src/config/health.rs | 16 - crates/config/src/config/mod.rs | 263 ------- crates/config/src/config/parsing.rs | 284 +------- crates/config/src/config/resolution.rs | 263 +------ crates/config/src/config/rules.rs | 62 -- crates/config/src/config_writer.rs | 43 +- crates/config/src/external_plugin.rs | 33 - crates/config/src/levenshtein.rs | 2 - crates/config/src/workspace/diagnostics.rs | 21 - crates/config/src/workspace/mod.rs | 120 ---- crates/config/src/workspace/package_json.rs | 37 - crates/config/src/workspace/parsers.rs | 88 --- crates/config/src/workspace/pnpm_catalog.rs | 13 - crates/config/src/workspace/pnpm_overrides.rs | 16 - crates/core/benches/allocations.rs | 6 - crates/core/benches/analysis.rs | 37 - crates/core/benches/helpers.rs | 6 - crates/core/benches/large_analysis.rs | 4 - crates/core/src/analyze/boundary.rs | 25 - crates/core/src/analyze/feature_flags.rs | 3 - crates/core/src/analyze/mod.rs | 57 -- crates/core/src/analyze/package_json_utils.rs | 25 - crates/core/src/analyze/predicates/file.rs | 41 -- crates/core/src/analyze/predicates/import.rs | 75 -- .../core/src/analyze/predicates/lifecycle.rs | 20 - crates/core/src/analyze/unused_catalog.rs | 32 - crates/core/src/analyze/unused_deps.rs | 69 -- .../unused_deps_tests/collect_unused.rs | 17 +- .../src/analyze/unused_deps_tests/helpers.rs | 2 - .../analyze/unused_deps_tests/predicates.rs | 7 - .../unused_deps_tests/skip_dependency.rs | 8 - .../unused_deps_tests/test_only_deps.rs | 2 - .../unused_deps_tests/type_only_deps.rs | 8 - .../unused_deps_tests/unlisted_deps.rs | 26 - .../unused_deps_tests/unresolved_imports.rs | 9 - .../analyze/unused_deps_tests/unused_deps.rs | 25 - crates/core/src/analyze/unused_exports.rs | 275 +------- crates/core/src/analyze/unused_files.rs | 23 - crates/core/src/analyze/unused_members.rs | 403 +---------- crates/core/src/analyze/unused_overrides.rs | 17 +- crates/core/src/changed_files.rs | 68 -- crates/core/src/churn.rs | 81 --- crates/core/src/cross_reference.rs | 26 +- crates/core/src/discover/entry_points.rs | 90 --- crates/core/src/discover/infrastructure.rs | 30 - crates/core/src/discover/mod.rs | 32 - crates/core/src/discover/parse_scripts.rs | 21 - crates/core/src/discover/walk.rs | 48 -- crates/core/src/duplicates/deepdive.rs | 168 ++++- .../src/duplicates/detect/concatenation.rs | 1 - .../core/src/duplicates/detect/extraction.rs | 15 +- .../core/src/duplicates/detect/filtering.rs | 80 --- crates/core/src/duplicates/detect/lcp.rs | 1 - crates/core/src/duplicates/detect/mod.rs | 12 - crates/core/src/duplicates/detect/ranking.rs | 3 - .../core/src/duplicates/detect/statistics.rs | 5 - .../src/duplicates/detect/suffix_array.rs | 42 -- crates/core/src/duplicates/detect/tests.rs | 185 ----- crates/core/src/duplicates/detect/utils.rs | 6 - crates/core/src/duplicates/families.rs | 27 +- crates/core/src/duplicates/mod.rs | 37 - crates/core/src/duplicates/normalize.rs | 31 +- crates/core/src/duplicates/shingle_filter.rs | 8 - crates/core/src/duplicates/token_types.rs | 5 - crates/core/src/duplicates/token_visitor.rs | 36 +- crates/core/src/duplicates/tokenize/mod.rs | 3 - .../tokenize/tests/basic_tokenization.rs | 13 - .../duplicates/tokenize/tests/es_modules.rs | 11 - .../tokenize/tests/ignore_imports.rs | 14 - .../tokenize/tests/token_ordering.rs | 72 -- .../duplicates/tokenize/tests/token_types.rs | 8 - crates/core/src/duplicates/types.rs | 5 - crates/core/src/errors.rs | 17 - crates/core/src/external_style_usage.rs | 2 - crates/core/src/extract.rs | 3 - crates/core/src/lib.rs | 117 ---- crates/core/src/plugins/adonis.rs | 81 +-- crates/core/src/plugins/angular.rs | 71 -- crates/core/src/plugins/babel.rs | 12 - crates/core/src/plugins/config_parser.rs | 209 +----- crates/core/src/plugins/cypress.rs | 5 - crates/core/src/plugins/drizzle.rs | 23 - crates/core/src/plugins/electron.rs | 28 +- crates/core/src/plugins/ember.rs | 138 +--- crates/core/src/plugins/eslint.rs | 117 ---- crates/core/src/plugins/gatsby.rs | 17 - crates/core/src/plugins/hardhat.rs | 1 - crates/core/src/plugins/jest.rs | 47 -- crates/core/src/plugins/lexical.rs | 6 - crates/core/src/plugins/lit.rs | 1 - crates/core/src/plugins/mintlify.rs | 6 - crates/core/src/plugins/mod.rs | 36 - crates/core/src/plugins/nextjs.rs | 23 - crates/core/src/plugins/nuxt.rs | 88 +-- crates/core/src/plugins/nx.rs | 47 -- crates/core/src/plugins/oxlint.rs | 11 - crates/core/src/plugins/pandacss.rs | 3 - crates/core/src/plugins/pkg_utils.rs | 6 - crates/core/src/plugins/playwright.rs | 12 - crates/core/src/plugins/pnpm.rs | 1 - crates/core/src/plugins/postcss.rs | 3 - crates/core/src/plugins/prettier.rs | 13 - crates/core/src/plugins/prisma.rs | 15 - crates/core/src/plugins/react_compiler.rs | 63 +- crates/core/src/plugins/registry/builtin.rs | 18 - crates/core/src/plugins/registry/helpers.rs | 13 +- crates/core/src/plugins/registry/mod.rs | 50 -- crates/core/src/plugins/registry/tests.rs | 194 ------ crates/core/src/plugins/rolldown.rs | 2 - crates/core/src/plugins/rollup.rs | 2 - crates/core/src/plugins/rsbuild.rs | 6 - crates/core/src/plugins/rspack.rs | 5 - crates/core/src/plugins/rspress.rs | 6 - crates/core/src/plugins/semantic_release.rs | 2 - crates/core/src/plugins/storybook.rs | 11 - crates/core/src/plugins/supabase.rs | 2 - crates/core/src/plugins/sveltekit.rs | 19 - crates/core/src/plugins/tailwind.rs | 6 - crates/core/src/plugins/test_alias.rs | 69 +- crates/core/src/plugins/tooling.rs | 46 -- crates/core/src/plugins/tsdown.rs | 1 - crates/core/src/plugins/tsup.rs | 1 - crates/core/src/plugins/typescript.rs | 31 +- crates/core/src/plugins/unocss.rs | 1 - crates/core/src/plugins/velite.rs | 18 - crates/core/src/plugins/vite.rs | 46 -- crates/core/src/plugins/vitest.rs | 55 -- crates/core/src/plugins/webpack.rs | 8 - crates/core/src/plugins/wrangler.rs | 2 - crates/core/src/results.rs | 1 - crates/core/src/scripts/ci.rs | 40 -- crates/core/src/scripts/mod.rs | 103 --- crates/core/src/scripts/resolve.rs | 41 +- crates/core/src/scripts/shell.rs | 40 -- crates/core/src/suppress.rs | 35 - crates/core/src/trace.rs | 19 - crates/core/tests/dupes_profile.rs | 2 - crates/core/tests/dupes_stress_test.rs | 68 -- .../integration_test/angular_ng_package.rs | 21 +- .../angular_template_members.rs | 23 - .../integration_test/arrow_wrapped_imports.rs | 7 - .../tests/integration_test/barrel_exports.rs | 72 -- .../tests/integration_test/basic_analysis.rs | 115 ---- .../tests/integration_test/bin_script_deps.rs | 7 - .../integration_test/boundary_violations.rs | 44 -- crates/core/tests/integration_test/caching.rs | 20 - .../integration_test/config_file_loading.rs | 34 - .../integration_test/css_modules_unused.rs | 3 - .../tests/integration_test/dependencies.rs | 34 - .../core/tests/integration_test/duplicates.rs | 12 - .../integration_test/dynamic_import_then.rs | 13 - .../tests/integration_test/dynamic_imports.rs | 41 -- .../tests/integration_test/ember_classic.rs | 41 +- .../entry_export_validation.rs | 7 - .../integration_test/external_plugins.rs | 10 - .../core/tests/integration_test/extraction.rs | 2 - .../integration_test/false_positive_fixes.rs | 52 -- ...mework_convention_coverage_astro_gatsby.rs | 4 - .../framework_convention_coverage_electron.rs | 8 - .../framework_convention_coverage_mintlify.rs | 7 - .../core/tests/integration_test/frameworks.rs | 44 -- .../core/tests/integration_test/html_entry.rs | 17 - .../integration_test/inheritance_members.rs | 8 - .../issue_195_non_source_entry_points.rs | 19 - ...sue_317_namespace_barrel_ignore_exports.rs | 13 +- .../issue_329_pnpm_catalog.rs | 7 - .../issue_334_unresolved_catalog_ref.rs | 10 - .../issue_336_unused_overrides.rs | 5 - .../issue_346_static_factory_method.rs | 8 - .../issue_358_custom_eslint_config.rs | 2 - ...e_396_397_399_typeof_import_and_new_url.rs | 6 - .../issue_462_tooling_catalogue.rs | 17 - .../issue_463_glob_validation.rs | 36 - .../issue_515_re_export_cycles.rs | 16 +- .../issue_601_vitest_test_alias.rs | 30 +- .../issue_604_vite_rollup_path_helpers.rs | 4 - .../issue_605_new_class_member.rs | 19 - .../integration_test/issue_609_velite.rs | 8 - .../issue_620_error_subclass_name.rs | 5 - .../issue_624_supabase_edge.rs | 8 - .../issue_704_auto_import_components.rs | 7 - .../issue_752_svelte_typed_props.rs | 13 - .../issue_753_oxlint_cli_tooling.rs | 9 - .../issue_754_eslint_meta_preset.rs | 7 - .../issue_758_danger_no_dep.rs | 3 - .../issue_772_workspace_plugin_merge.rs | 14 - .../tests/integration_test/lexical_nodes.rs | 12 - .../integration_test/lit_custom_element.rs | 11 - .../integration_test/member_detection.rs | 62 -- .../integration_test/pkg_utils_plugin.rs | 8 - .../integration_test/private_type_leaks.rs | 16 - .../tests/integration_test/production_mode.rs | 16 - .../integration_test/re_export_chains.rs | 3 - .../tests/integration_test/rspress_theme.rs | 2 - .../tests/integration_test/rules_config.rs | 13 - .../tests/integration_test/safe_analysis.rs | 12 - .../integration_test/script_multiplexers.rs | 3 - .../tests/integration_test/scss_partials.rs | 26 - .../tests/integration_test/sfc_parsing.rs | 39 -- .../integration_test/stale_suppressions.rs | 26 - .../integration_test/super_method_calls.rs | 4 - .../integration_test/suppression_comments.rs | 12 - .../tests/integration_test/test_only_deps.rs | 2 - .../tests/integration_test/type_only_deps.rs | 3 - .../integration_test/unreachable_exports.rs | 3 - .../integration_test/unused_enum_members.rs | 3 - .../tests/integration_test/visibility_tags.rs | 40 +- .../workspace_cross_imports.rs | 10 - .../core/tests/integration_test/workspaces.rs | 55 -- crates/extract/src/asset_url.rs | 8 - crates/extract/src/astro.rs | 53 +- crates/extract/src/cache/store.rs | 83 +-- crates/extract/src/cache/tests.rs | 51 -- crates/extract/src/complexity.rs | 150 +--- crates/extract/src/css.rs | 124 ---- crates/extract/src/flags.rs | 42 -- crates/extract/src/glimmer.rs | 32 - crates/extract/src/html.rs | 58 -- crates/extract/src/iconify.rs | 7 - crates/extract/src/inventory.rs | 55 -- crates/extract/src/lib.rs | 29 - crates/extract/src/mdx.rs | 33 +- crates/extract/src/parse.rs | 91 --- crates/extract/src/sfc.rs | 73 -- crates/extract/src/sfc_template/angular.rs | 96 --- crates/extract/src/sfc_template/glimmer.rs | 129 ---- crates/extract/src/sfc_template/shared.rs | 54 -- crates/extract/src/sfc_template/svelte.rs | 87 --- crates/extract/src/sfc_template/vue.rs | 20 - crates/extract/src/suppress.rs | 43 +- crates/extract/src/template_complexity.rs | 5 - crates/extract/src/template_usage.rs | 6 +- crates/extract/src/tests/astro.rs | 4 - crates/extract/src/tests/css.rs | 12 - crates/extract/src/tests/js_ts/cjs.rs | 11 - crates/extract/src/tests/js_ts/classes.rs | 77 --- crates/extract/src/tests/js_ts/complexity.rs | 60 -- .../src/tests/js_ts/dynamic_imports.rs | 70 -- crates/extract/src/tests/js_ts/exports.rs | 9 - .../src/tests/js_ts/html_tagged_template.rs | 14 - crates/extract/src/tests/js_ts/jsdoc.rs | 33 - crates/extract/src/tests/js_ts/jsx_retry.rs | 14 - .../extract/src/tests/js_ts/member_access.rs | 5 - .../src/tests/js_ts/unused_bindings.rs | 8 - crates/extract/src/tests/mdx.rs | 1 - crates/extract/src/tests/mod.rs | 69 -- crates/extract/src/tests/regex_compile.rs | 2 - crates/extract/src/tests/sfc.rs | 42 -- crates/extract/src/visitor/declarations.rs | 6 - crates/extract/src/visitor/helpers.rs | 246 ------- crates/extract/src/visitor/mod.rs | 328 --------- crates/extract/src/visitor/tests.rs | 209 +----- crates/extract/src/visitor/visit_impl.rs | 349 +--------- crates/graph/src/graph/build.rs | 48 -- crates/graph/src/graph/cycles.rs | 192 +----- crates/graph/src/graph/mod.rs | 45 -- crates/graph/src/graph/namespace_aliases.rs | 21 - .../graph/src/graph/namespace_re_exports.rs | 58 -- crates/graph/src/graph/narrowing.rs | 49 -- crates/graph/src/graph/re_exports/mod.rs | 52 -- .../graph/src/graph/re_exports/propagate.rs | 39 -- crates/graph/src/graph/re_exports/tests.rs | 215 ------ crates/graph/src/graph/reachability.rs | 46 -- crates/graph/src/graph/types.rs | 17 - crates/graph/src/lib.rs | 4 - crates/graph/src/project.rs | 3 - crates/graph/src/resolve/dynamic_imports.rs | 11 - crates/graph/src/resolve/fallbacks.rs | 132 ---- crates/graph/src/resolve/mod.rs | 36 +- crates/graph/src/resolve/path_info.rs | 30 - crates/graph/src/resolve/react_native.rs | 25 - crates/graph/src/resolve/specifier.rs | 139 ---- crates/graph/src/resolve/tests.rs | 186 +---- crates/graph/src/resolve/types.rs | 1 - crates/graph/src/resolve/upgrades.rs | 29 - crates/graph/tests/cross_platform.rs | 88 --- crates/license/src/lib.rs | 40 -- crates/lsp/src/code_actions/quick_fix.rs | 243 +------ crates/lsp/src/code_lens.rs | 21 +- crates/lsp/src/diagnostics/quality.rs | 14 - crates/lsp/src/diagnostics/structural.rs | 21 +- crates/lsp/src/diagnostics/unused.rs | 28 - crates/lsp/src/hover.rs | 69 -- crates/lsp/src/main.rs | 297 -------- crates/lsp/src/markdown.rs | 23 - crates/mcp/src/main.rs | 2 - crates/mcp/src/params.rs | 202 +----- crates/mcp/src/server/mod.rs | 7 - crates/mcp/src/server/tests/args.rs | 59 -- crates/mcp/src/server/tests/e2e.rs | 16 - crates/mcp/src/server/tests/edge_cases.rs | 86 --- crates/mcp/src/server/tests/params.rs | 50 -- crates/mcp/src/server/tests/run.rs | 61 -- crates/mcp/src/server/tests/server_info.rs | 32 - crates/mcp/src/tools/analyze.rs | 2 - crates/mcp/src/tools/health.rs | 3 - crates/mcp/src/tools/mod.rs | 19 +- crates/mcp/src/tools/trace.rs | 2 - crates/types/src/discover.rs | 15 - crates/types/src/extract.rs | 298 ++------ crates/types/src/output_dead_code.rs | 396 +---------- crates/types/src/output_health.rs | 231 +------ crates/types/src/results.rs | 501 +------------- crates/types/src/serde_path.rs | 5 - crates/types/src/suppress.rs | 16 - crates/v8-coverage/src/lib.rs | 18 - schema.json | 145 +--- 430 files changed, 694 insertions(+), 22369 deletions(-) diff --git a/crates/cli/src/api.rs b/crates/cli/src/api.rs index cb2ab06e5..821e86357 100644 --- a/crates/cli/src/api.rs +++ b/crates/cli/src/api.rs @@ -281,14 +281,9 @@ pub fn actionable_error_hint(operation: &str, code: &str) -> Option<&'static str ("refresh", "invalid_token") => Some( "your stored license token is missing required claims. Reactivate with: fallow license activate --trial --email ", ), - // Trial + refresh are license-JWT flows: a stale / invalid JWT is - // fixed by reactivating via the trial endpoint. ("refresh" | "trial", "unauthorized") => Some( "authentication failed. Reactivate with: fallow license activate --trial --email ", ), - // upload-inventory uses a separate API key (`fallow_live_k1_*`), not - // the license JWT. Reactivating the trial does NOT rotate the API - // key. Point users at key generation instead. ("upload-inventory", "unauthorized") => Some( "authentication failed. Generate an API key at https://fallow.cloud/settings#api-keys and set FALLOW_API_KEY on the runner. Note: this key is separate from the license JWT; `fallow license activate --trial` will not fix this error.", ), @@ -298,9 +293,6 @@ pub fn actionable_error_hint(operation: &str, code: &str) -> Option<&'static str ("upload-inventory", "payload_too_large") => Some( "inventory exceeds the 200,000-function server limit. Scope the walk with --exclude-paths, or open an issue if this is a legitimately large repo.", ), - // upload-static-findings uses the same live API key class as - // upload-inventory. A publishable ingest key (`fallow_pub_k1_*`) is - // rejected here with a scope error, so point users at a live key. ("upload-static-findings", "unauthorized") => Some( "authentication failed. Generate a live API key at https://fallow.cloud/settings#api-keys and set FALLOW_API_KEY on the runner. Note: a publishable ingest key (fallow_pub_k1_) is not accepted here, and `fallow license activate --trial` will not fix this error.", ), @@ -373,8 +365,6 @@ fn redact_bearer_tokens(detail: &str) -> String { token_end += 1; } if token_end == token_start { - // `Bearer` followed by no token character: preserve as-is and - // advance past the literal so we do not infinite-loop. out.push_str(BEARER); cursor = token_end; continue; @@ -517,12 +507,6 @@ mod tests { body: r#"{"error":true,"code":"unauthorized"}"#.to_owned(), }; let message = http_status_message(&mut response, "upload-inventory"); - // API keys are a distinct secret from the license JWT. Sending trial - // users to `license activate --trial` when they get a 401 on upload - // is a dead-end support loop. The hint MUST both direct them to the - // API-keys page AND explain that the trial flow won't fix it, so we - // require the disqualifier to appear adjacent to "will not fix". - // Regression test for BLOCK 3 from the public-readiness panel. assert!( message.contains("https://fallow.cloud/settings#api-keys"), "expected api-keys URL, got: {message}" @@ -632,9 +616,6 @@ mod tests { #[expect(unsafe_code, reason = "env var mutation requires unsafe")] fn ca_bundle_read_errors_are_reported_as_client_setup_errors() { let prior = std::env::var(CA_BUNDLE_ENV).ok(); - // SAFETY: env mutation is unsafe because it is not thread-safe. This - // test serializes its own writes and restores the prior value before - // returning; no other test in this module touches FALLOW_CA_BUNDLE. unsafe { std::env::set_var(CA_BUNDLE_ENV, "/definitely/missing/fallow-ca.pem"); } @@ -642,7 +623,6 @@ mod tests { let message = err.to_string(); assert!(message.contains(CA_BUNDLE_ENV)); assert!(message.contains("failed to read PEM bundle")); - // SAFETY: see the `set_var` safety note above. unsafe { if let Some(value) = prior { std::env::set_var(CA_BUNDLE_ENV, value); @@ -707,9 +687,6 @@ mod tests { #[test] fn sanitize_network_error_preserves_literal_bearer_when_no_token_follows() { - // `Bearer ` followed by a non-token byte (e.g. `@`) leaves the prefix - // untouched so we do not corrupt non-secret prose that mentions the - // literal `Bearer `. let input = "Bearer @other"; let output = sanitize_network_error(input); assert_eq!(output, input); @@ -840,16 +817,11 @@ mod tests { ); } - // Env-var assertions run in one test to avoid interleaving with parallel - // tests that also touch `FALLOW_API_URL`. Restores the prior value. #[test] #[expect(unsafe_code, reason = "env var mutation requires unsafe")] fn api_url_respects_env_override_and_default() { let prior = std::env::var("FALLOW_API_URL").ok(); - // SAFETY: env mutation is unsafe because it is not thread-safe. This - // test serializes its own writes and restores the prior value before - // returning; no other test in this module touches FALLOW_API_URL. unsafe { std::env::remove_var("FALLOW_API_URL"); } @@ -858,7 +830,6 @@ mod tests { "https://api.fallow.cloud/v1/coverage/repo/inventory", ); - // SAFETY: see the `remove_var` safety note above. unsafe { std::env::set_var("FALLOW_API_URL", "http://127.0.0.1:3000/"); } @@ -867,7 +838,6 @@ mod tests { "http://127.0.0.1:3000/v1/coverage/a/inventory", ); - // SAFETY: see the `remove_var` safety note above. unsafe { if let Some(value) = prior { std::env::set_var("FALLOW_API_URL", value); diff --git a/crates/cli/src/audit.rs b/crates/cli/src/audit.rs index 5ce204d51..8a750931c 100644 --- a/crates/cli/src/audit.rs +++ b/crates/cli/src/audit.rs @@ -18,8 +18,6 @@ use crate::health::{HealthOptions, HealthResult, SortBy}; use crate::report; use crate::report::plural; -// ── Types ──────────────────────────────────────────────────────── - const AUDIT_BASE_SNAPSHOT_CACHE_VERSION: u8 = 2; const MAX_AUDIT_BASE_SNAPSHOT_CACHE_SIZE: usize = 16 * 1024 * 1024; @@ -124,20 +122,12 @@ pub struct AuditOptions<'a> { pub runtime_coverage: Option<&'a std::path::Path>, /// Threshold for hot-path classification, forwarded to the sidecar. pub min_invocations_hot: u64, - // `diff_file` was removed from this struct: audit now sources the - // parsed diff index from the process-wide cache in - // `crate::report::ci::diff_filter::shared_diff_index()`, populated - // by `main()`. The cache covers `--diff-file PATH`, `--diff-file -`, - // `--diff-stdin`, and the `$FALLOW_DIFF_FILE` env var. } -// ── Auto-detect base branch ────────────────────────────────────── - /// Try to determine the default branch for the repository. /// Priority: `git symbolic-ref refs/remotes/origin/HEAD` → `main` → `master`. /// Returns `None` if none of these exist. fn auto_detect_base_branch(root: &std::path::Path) -> Option { - // Try symbolic-ref first (works when origin HEAD is set) let mut symbolic_ref = std::process::Command::new("git"); symbolic_ref .args(["symbolic-ref", "refs/remotes/origin/HEAD"]) @@ -152,7 +142,6 @@ fn auto_detect_base_branch(root: &std::path::Path) -> Option { } } - // Try main let mut verify_main = std::process::Command::new("git"); verify_main .args(["rev-parse", "--verify", "main"]) @@ -164,7 +153,6 @@ fn auto_detect_base_branch(root: &std::path::Path) -> Option { return Some("main".to_string()); } - // Try master let mut verify_master = std::process::Command::new("git"); verify_master .args(["rev-parse", "--verify", "master"]) @@ -194,8 +182,6 @@ fn get_head_sha(root: &std::path::Path) -> Option { } } -// ── Verdict computation ────────────────────────────────────────── - fn compute_verdict( check: Option<&CheckResult>, dupes: Option<&DupesResult>, @@ -204,7 +190,6 @@ fn compute_verdict( let mut has_errors = false; let mut has_warnings = false; - // Dead code: use rules severity if let Some(result) = check { if crate::check::has_error_severity_issues( &result.results, @@ -217,16 +202,12 @@ fn compute_verdict( } } - // Complexity: findings that exceeded configured thresholds are always errors. - // Health rules don't have a warn-severity concept — any finding above the - // threshold is a quality gate failure, matching `fallow health` exit code semantics. if let Some(result) = health && !result.report.findings.is_empty() { has_errors = true; } - // Duplication: clone groups are warnings (unless threshold exceeded) if let Some(result) = dupes && !result.report.clone_groups.is_empty() { @@ -704,12 +685,6 @@ fn compute_base_snapshot( coverage_root: opts.coverage_root, gate: AuditGate::All, include_entry_exports: opts.include_entry_exports, - // Base-snapshot pass intentionally does NOT spawn the sidecar - // again or apply hot-path filtering: hot-path-touched is a - // PR-vs-HEAD signal, and the recursive base run is HEAD's - // baseline, so it has nothing to compare against. Suppressing - // here also avoids a duplicate license check + sidecar download - // cost on every audit run. runtime_coverage: None, min_invocations_hot: opts.min_invocations_hot, }; @@ -719,10 +694,6 @@ fn compute_base_snapshot( let health_production = opts.production_health.unwrap_or(opts.production); let share_dead_code_parse_with_health = check_production == health_production; - // Base-snapshot check and dupes share no mutable state. Running them - // concurrently keeps the expensive duplication pass overlapped with - // dead-code analysis; health then consumes check's retained parse when the - // production modes match, mirroring the HEAD-side audit pipeline. let (check_res, dupes_res) = rayon::join( || run_audit_check(&base_opts, None, share_dead_code_parse_with_health), || run_audit_dupes(&base_opts, None, base_changed_files.as_ref(), None), @@ -756,9 +727,6 @@ fn base_analysis_root(current_root: &Path, base_worktree_root: &Path) -> PathBuf let Some(git_root) = git_toplevel(current_root) else { return base_worktree_root.to_path_buf(); }; - // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so this - // current_root matches `git_root` (also dunce-canonicalised above) when - // `strip_prefix` walks the component graph. let current_root = dunce::canonicalize(current_root).unwrap_or_else(|_| current_root.to_path_buf()); match current_root.strip_prefix(&git_root) { @@ -801,15 +769,7 @@ fn can_reuse_current_as_base( let Some(git_root) = git_toplevel(opts.root) else { return false; }; - // `try_get_changed_files` joins the canonical git toplevel onto each - // relative diff entry, so changed-file paths land canonical even when - // `opts.root` itself was passed un-canonical (typical in tests). Match - // against both forms so the cache-artifact check works in either case. let cache_dir = opts.root.join(".fallow"); - // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so the - // `starts_with` checks below compare against a shape that matches the - // changed_files paths (which also flow through dunce-canonicalised - // `resolve_git_toplevel`). On POSIX dunce is identical to std. let canonical_cache_dir = dunce::canonicalize(&cache_dir).ok(); changed_files.iter().all(|path| { if is_fallow_cache_artifact(path, &cache_dir, canonical_cache_dir.as_deref()) { @@ -834,13 +794,6 @@ fn can_reuse_current_as_base( }) } -// `cache_dir` is the project-local cache root (`/.fallow`). -// Anything under it is a fallow internal artifact (token cache, parse cache, -// gitignore stubs) with no semantic effect on analysis, so a "changed" entry -// inside it must not block the audit-gate base-snapshot fast path. We accept -// both the as-given and the canonicalized cache_dir because changed-file -// paths from `try_get_changed_files` are joined onto the canonical git -// toplevel while `opts.root` may be un-canonical in tests. fn is_fallow_cache_artifact( path: &Path, cache_dir: &Path, @@ -861,10 +814,6 @@ fn git_toplevel(root: &Path) -> Option { return None; } let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); - // Mirror `fallow_core::changed_files::resolve_git_toplevel`: use - // `dunce::canonicalize` to strip Windows `\\?\` verbatim prefix so this - // canonical form matches the shape `opts.root` and finding paths use - // downstream. `std::fs::canonicalize` would diverge on Windows. Some(dunce::canonicalize(&path).unwrap_or(path)) } @@ -933,23 +882,6 @@ fn js_ts_tokens_equivalent(path: &Path, current: &str, base: &str) -> bool { .eq(base_tokens.tokens.iter().map(|token| &token.kind)) } -// Remap focused-file paths from the current working tree into the base -// worktree, used so the duplication detector can scope clone-group -// extraction at base to the same files we focus on at HEAD. -// -// Path matching at base must align with `discover_files`, which walks -// `config.root` un-canonicalized and emits paths under that exact prefix. -// Canonicalizing here would silently shift the prefix on systems where the -// tempdir path traverses a symlink (`/tmp` → `/private/tmp`, `/var` → -// `/private/var` on macOS); the focus set would then miss every discovered -// file at base and disable the optimization. Use the prefixes as-is. -// -// `opts.root` is already canonical (from `validate_root`), and -// `changed_files` was joined onto the canonical git toplevel, so -// `strip_prefix(from_root)` succeeds for paths inside `opts.root`. Files -// outside `opts.root` (e.g., a sibling workspace touched in the same -// commit) are skipped rather than collapsing the whole set, so the focus -// optimization stays active for the in-scope subset. fn remap_focus_files( files: &FxHashSet, from_root: &Path, @@ -1019,11 +951,6 @@ impl BaseWorktree { fn reuse_or_create(repo_root: &Path, base_sha: &str) -> Option { let path = reusable_audit_worktree_path(repo_root, base_sha); - // Serialise concurrent audits against the same base_sha. On contention, - // fall through to the non-reusable PID-named path so the loser does not - // block; matrix CI then gets at most one slow rebuild rather than racing - // git worktree add against the same directory. The lock is released - // automatically when `_lock` drops. let _lock = ReusableWorktreeLock::try_acquire(&path)?; if reusable_audit_worktree_is_ready(repo_root, &path, base_sha) { @@ -1033,8 +960,6 @@ impl BaseWorktree { persistent: true, }; materialize_base_dependency_context(repo_root, worktree.path()); - // Update the staleness signal so the age-based GC sweep does - // not nuke a frequently-reused cache. touch_last_used(worktree.path()); return Some(worktree); } @@ -1067,11 +992,6 @@ impl BaseWorktree { persistent: true, }; materialize_base_dependency_context(repo_root, worktree.path()); - // Stamp the sidecar at fresh-create time so the cache's age is - // measured from "first existence" rather than "first reuse". The - // sweep's sidecar-absent branch (`touch + skip`) is still - // load-bearing for pre-upgrade caches created before this - // feature shipped. touch_last_used(worktree.path()); Some(worktree) } @@ -1133,18 +1053,12 @@ impl Drop for WorktreeCleanupGuard<'_> { /// Concurrent acquirers either fall through (`None`) or observe a /// freshly-prepared cache after the holder releases. struct ReusableWorktreeLock { - // Drop on `File` calls the kernel's unlock automatically; we never call - // `unlock_exclusive` explicitly. _file: std::fs::File, } impl ReusableWorktreeLock { fn try_acquire(reusable_path: &Path) -> Option { let lock_path = reusable_worktree_lock_path(reusable_path); - // We never read the lock file's bytes, only its kernel-level lock - // state, so set `truncate(false)` explicitly. Combining `O_TRUNC` with - // `flock(2)` produced flaky `WouldBlock` returns under concurrent - // acquire/release on macOS APFS during local tests. let file = std::fs::OpenOptions::new() .create(true) .truncate(false) @@ -1362,8 +1276,6 @@ fn sweep_old_reusable_caches(repo_root: &Path, max_age: Duration, quiet: bool) { fn reusable_audit_worktree_path(repo_root: &Path, base_sha: &str) -> PathBuf { let repo_root = git_toplevel(repo_root).unwrap_or_else(|| repo_root.to_path_buf()); - // `dunce::canonicalize` keeps the hash deterministic across Windows - // callers that pass verbatim-vs-non-verbatim shapes for the same repo. let repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root); let repo_hash = xxh3_64(repo_root.to_string_lossy().as_bytes()); let sha_prefix = base_sha.get(..16).unwrap_or(base_sha); @@ -1390,8 +1302,6 @@ fn paths_equal(left: &Path, right: &Path) -> bool { if left == right { return true; } - // `dunce::canonicalize` strips Windows `\\?\` verbatim prefix so two - // paths that differ only in prefix shape compare equal. match (dunce::canonicalize(left), dunce::canonicalize(right)) { (Ok(left), Ok(right)) => left == right, _ => false, @@ -1461,10 +1371,6 @@ fn remove_audit_worktree(repo_root: &Path, path: &Path) { clear_ambient_git_env(&mut command); match crate::signal::scoped_child::output(&mut command) { Ok(output) => { - // Only warn when an observable leak survives: the on-disk path still - // exists after a non-zero `git worktree remove --force`. A missing - // registration with no surviving directory is the partial-create - // cleanup case and not noteworthy. if !output.status.success() && path.exists() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!( @@ -1549,21 +1455,11 @@ fn is_reusable_audit_worktree_path(path: &Path) -> bool { fn path_is_inside_temp_dir(path: &Path) -> bool { let temp = std::env::temp_dir(); - // `dunce::simplified` strips Windows `\\?\` verbatim prefix WITHOUT any - // filesystem I/O, so this handles both verbatim and non-verbatim inputs - // (synthetic test paths, real canonical paths from std OR dunce) without - // requiring the path to actually exist on disk. The earlier - // `dunce::canonicalize` attempt failed for the synthetic test paths in - // `audit_worktree_helpers_filter_to_fallow_temp_prefix` because the - // worktree dirs are constructed in-memory and never written. let simple_path = dunce::simplified(path); let simple_temp = dunce::simplified(&temp); if simple_path.starts_with(simple_temp) { return true; } - // Fallback for symlinked temp dirs: canonicalize via std::fs (POSIX - // resolves the symlink target; on Windows this also matches when path - // canonicalises to something under temp). let Ok(canonical_temp) = std::fs::canonicalize(&temp) else { return false; }; @@ -1608,8 +1504,6 @@ pub fn process_is_alive(pid: u32) -> bool { #[cfg(not(any(unix, windows)))] pub fn process_is_alive(_pid: u32) -> bool { - // Conservative default on unknown platforms: treat every PID as alive so the - // orphan sweep never removes anything we can't prove is dead. true } @@ -1634,9 +1528,6 @@ mod windows_process { impl Drop for ProcessHandle { fn drop(&mut self) { - // SAFETY: `self.0` is a non-null handle obtained from a successful - // `OpenProcess` call. We have unique ownership (the value is only - // ever created inside `is_alive`), so this is the sole consumer. unsafe { CloseHandle(self.0); } @@ -1651,48 +1542,20 @@ mod windows_process { /// definitively does not exist (`ERROR_INVALID_PARAMETER`) or the wait /// reports the process has exited. pub(super) fn is_alive(pid: u32) -> bool { - // SAFETY: `OpenProcess` accepts any `u32` PID; it either returns a - // non-null handle we own, or null on failure with `GetLastError` - // describing why. No memory is borrowed across the FFI boundary. let raw = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if raw.is_null() { - // SAFETY: `GetLastError` reads thread-local storage set by the - // failing `OpenProcess` call. It has no preconditions. let err = unsafe { GetLastError() }; - // The named `ERROR_ACCESS_DENIED` arm and the `_` arm map to the - // same conservative default; the named arm is kept solely to - // document the protected-process / cross-session case. Collapsing - // would lose that documentation. #[expect( clippy::match_same_arms, - reason = "named arm documents the cross-session protected-process case; collapsing loses that intent" + reason = "named arm documents the cross-session case" )] return match err { - // PID never existed or has already been fully reaped. ERROR_INVALID_PARAMETER => false, - // Process exists but is owned by another session / under - // protected access. Conservative default: treat as alive so we - // never sweep a worktree owned by a live process we can't see. ERROR_ACCESS_DENIED => true, - // Anything else (transient, unknown): conservative default. _ => true, }; } let handle = ProcessHandle(raw); - // `WaitForSingleObject(handle, 0)` returns `WAIT_OBJECT_0` (0) when the - // process has exited and its handle is signalled, `WAIT_TIMEOUT` (0x102) - // when the process is still running, and `WAIT_FAILED` (0xFFFF_FFFF) on - // unexpected errors. We compare against `WAIT_OBJECT_0` specifically so - // every other return value (including `WAIT_FAILED`) follows the - // conservative default: treat as alive when we cannot prove the - // process is dead. - // - // This is preferred over `GetExitCodeProcess + STILL_ACTIVE` because - // `STILL_ACTIVE` (259) is a valid u32 exit code: a process that - // legitimately exits with 259 would otherwise be misreported as alive. - // - // SAFETY: `handle.0` is non-null (checked above) and owned by the - // `ProcessHandle` RAII wrapper. let wait_result = unsafe { WaitForSingleObject(handle.0, 0) }; wait_result != WAIT_OBJECT_0 } @@ -1709,16 +1572,6 @@ impl Drop for BaseWorktree { } fn relative_key_path(path: &Path, root: &Path) -> String { - // `dunce::simplified` strips the Windows `\\?\` verbatim prefix when present - // without touching the filesystem, so a path that came back from - // `std::fs::canonicalize` (verbatim form on Windows) compares equal to a - // path that did not (e.g., the BASE worktree path built via - // `std::env::temp_dir().join(...)`). On POSIX `dunce::simplified` is a - // no-op. Without this, audit's BASE-vs-HEAD finding-key intersection on - // Windows produced 0 matches because `config.root` and `finding.path` - // disagreed on the prefix shape, so every BASE key landed as a full - // absolute path while HEAD keys landed as relative; the intersection - // was empty and every pre-existing issue surfaced as "introduced". let simple_path = dunce::simplified(path); let simple_root = dunce::simplified(root); simple_path @@ -1907,9 +1760,6 @@ fn dead_code_keys( keys.insert(format!("circular-dependency:{}", files.join("|"))); } for item in &results.re_export_cycles { - // Prefix the audit-gate key with the kind discriminator so self-loops - // cannot keyspace-collide with future single-file multi-node shapes - // (panel catch #7; same rationale as `baseline.rs::re_export_cycle_key`). let kind = match item.cycle.kind { fallow_core::results::ReExportCycleKind::MultiNode => "multi-node", fallow_core::results::ReExportCycleKind::SelfLoop => "self-loop", @@ -2004,8 +1854,6 @@ fn retain_introduced_dead_code( item.export.export_name )) }); - // The verdict path only needs correct issue counts and severities. For the - // less common categories, rebuild the full key set and retain by membership. let introduced = dead_code_keys(results, root) .into_iter() .filter(|key| !base.contains(key)) @@ -2566,8 +2414,6 @@ fn dupe_group_key(group: &fallow_core::duplicates::CloneGroup, root: &Path) -> S ) } -// ── Execute ────────────────────────────────────────────────────── - /// Bundle of HEAD-side analysis results returned from [`run_audit_head_analyses`]. /// /// Lets the call site move all three results out of the parallel branch in one @@ -2624,16 +2470,10 @@ pub fn execute_audit(opts: &AuditOptions<'_>) -> Result { let base_ref = resolve_base_ref(opts)?; - // Age-based GC of persistent reusable base-snapshot caches. Runs on - // every invocation (not gated on whether this audit needs a real - // base snapshot) so disk-reclaim happens even when this run is fully - // cache-warm. Skipped entirely when the user sets - // `FALLOW_AUDIT_CACHE_MAX_AGE_DAYS=0` or `audit.cacheMaxAgeDays = 0`. if let Some(max_age) = resolve_cache_max_age(opts) { sweep_old_reusable_caches(opts.root, max_age, opts.quiet); } - // Get changed files (hard error if it fails, unlike combined mode) let Some(changed_files) = crate::check::get_changed_files(opts.root, &base_ref) else { return Err(emit_error( &format!( @@ -2651,13 +2491,6 @@ pub fn execute_audit(opts: &AuditOptions<'_>) -> Result { let changed_since = Some(base_ref.as_str()); - // The HEAD analyses (check + dupes + health) operate on the working tree; - // the base snapshot operates on an isolated git worktree checked out at - // `base_ref` (reused by SHA when possible). They share no mutable state, so - // we can run them concurrently via `rayon::join`, halving wall-clock time - // on `--gate new-only` (the default). Inside each branch we keep the - // existing share-the-parse optimization between dead-code and health, since - // check finishes before either of its dependants run. let needs_real_base_snapshot = matches!(opts.gate, AuditGate::NewOnly) && !can_reuse_current_as_base(opts, &base_ref, &changed_files); let base_cache_key = if needs_real_base_snapshot { @@ -2710,7 +2543,6 @@ pub fn execute_audit(opts: &AuditOptions<'_>) -> Result { } else { (None, false) }; - // Drop shared parse data (no longer needed after base snapshot completed). if let Some(ref mut check) = check_result { check.shared_parse = None; } @@ -2772,7 +2604,6 @@ fn resolve_base_ref(opts: &AuditOptions<'_>) -> Result { opts.output, )); }; - // Validate auto-detected branch name (explicit --changed-since is validated in main.rs) if let Err(e) = crate::validate::validate_git_ref(&branch) { return Err(emit_error( &format!("auto-detected base branch '{branch}' is not a valid git ref: {e}"), @@ -2902,9 +2733,6 @@ fn run_audit_dupes<'a>( no_cache: opts.no_cache, threads: opts.threads, quiet: opts.quiet, - // The audit pipeline has already merged config + global flags into - // `dupes_cfg`; pass them as explicit overrides so `build_dupes_config` - // doesn't re-merge with stale toml values. mode: Some(DupesMode::from(dupes_cfg.mode)), min_tokens: Some(dupes_cfg.min_tokens), min_lines: Some(dupes_cfg.min_lines), @@ -2929,8 +2757,6 @@ fn run_audit_dupes<'a>( explain_skipped: opts.explain_skipped, summary: false, group_by: opts.group_by, - // Audit emits its own performance breakdown via the audit JSON / human - // formatter; the standalone dupes panel would be redundant noise here. performance: false, }; let dupes_run = if let Some(files) = pre_discovered { @@ -2950,10 +2776,6 @@ fn run_audit_health<'a>( changed_since: Option<&'a str>, shared_parse: Option, ) -> Result, ExitCode> { - // Build runtime-coverage sidecar options when --runtime-coverage was - // supplied. License JWT loading + 7/30/hard-fail grace evaluation - // happen inside prepare_options; an exit here means the user is past - // the hard-fail line and audit cannot proceed. let runtime_coverage = match opts.runtime_coverage { Some(path) => match crate::health::coverage::prepare_options( path, @@ -3028,8 +2850,6 @@ fn run_audit_health<'a>( } } -// ── Print ──────────────────────────────────────────────────────── - /// Print audit results and return the appropriate exit code. #[must_use] pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> ExitCode { @@ -3091,12 +2911,9 @@ pub fn print_audit_result(result: &AuditResult, quiet: bool, explain: bool) -> E } } -// ── Human format ───────────────────────────────────────────────── - fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: OutputFormat) { let show_headers = matches!(output, OutputFormat::Human) && !quiet; - // Scope line (stderr) if !quiet { let scope = format_scope_line(result); eprintln!(); @@ -3108,7 +2925,6 @@ fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: O let has_dupe_groups = result.summary.duplication_clone_groups > 0; let has_any_findings = has_check_issues || has_health_findings || has_dupe_groups; - // On fail/warn with findings: show detail sections (reuse existing renderers) if has_any_findings { if show_headers && std::io::stdout().is_terminal() { println!( @@ -3119,7 +2935,6 @@ fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: O println!(); } - // Vital signs summary line (stdout) — only when verdict is pass/warn if result.verdict != AuditVerdict::Fail && !quiet { print_audit_vital_signs(result); } @@ -3157,9 +2972,6 @@ fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: O eprintln!(); eprintln!("── Complexity ─────────────────────────────────────"); } - // `fallow audit` does not surface the health score / trend block - // (no orientation header), so let the standalone health renderer - // emit it inline like `fallow health`. crate::health::print_health_result( health, quiet, explain, None, None, false, false, true, false, false, ); @@ -3171,7 +2983,6 @@ fn print_audit_human(result: &AuditResult, quiet: bool, explain: bool, output: O crate::dupes::print_min_occurrences_note(dupes, quiet); } - // Status line (stderr) — always last if !quiet { print_audit_status_line(result); } @@ -3294,8 +3105,6 @@ fn print_audit_status_line(result: &AuditResult) { } } -// ── JSON format ────────────────────────────────────────────────── - #[expect( clippy::cast_possible_truncation, reason = "elapsed milliseconds won't exceed u64::MAX" @@ -3340,7 +3149,6 @@ fn print_audit_json(result: &AuditResult) -> ExitCode { ); } - // Summary if let Ok(summary_val) = serde_json::to_value(&result.summary) { obj.insert("summary".into(), summary_val); } @@ -3348,7 +3156,6 @@ fn print_audit_json(result: &AuditResult) -> ExitCode { obj.insert("attribution".into(), attribution_val); } - // Full sub-results if let Some(ref check) = result.check { match report::build_json_with_config_fixable( &check.results, @@ -3428,8 +3235,6 @@ fn print_audit_json(result: &AuditResult) -> ExitCode { report::emit_json(&output, "audit") } -// ── SARIF format ───────────────────────────────────────────────── - fn print_audit_sarif(result: &AuditResult) -> ExitCode { let mut all_runs = Vec::new(); @@ -3479,8 +3284,6 @@ fn print_audit_sarif(result: &AuditResult) -> ExitCode { report::emit_json(&combined, "SARIF audit") } -// ── CodeClimate format ─────────────────────────────────────────── - fn print_audit_codeclimate(result: &AuditResult) -> ExitCode { let value = build_audit_codeclimate(result); report::emit_json(&value, "CodeClimate audit") @@ -3514,8 +3317,6 @@ fn build_audit_codeclimate(result: &AuditResult) -> serde_json::Value { serde_json::to_value(&all_issues).expect("CodeClimateIssue serializes infallibly") } -// ── Entry point ────────────────────────────────────────────────── - /// Run the full audit command: execute analyses, print results, return exit code. /// Run audit, optionally tagged with a gate marker (e.g. `"pre-commit"`) so /// Fallow Impact can record a containment event when the gate blocks then @@ -3525,23 +3326,9 @@ pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode if let Err(e) = crate::health::scoring::validate_coverage_root_absolute(opts.coverage_root) { return emit_error(&e, 2, opts.output); } - // Resolve the coverage input path to absolute UP FRONT, against the user's - // original `--root`. The base-snapshot recursion in `compute_base_snapshot` - // swaps `--root` to a temp worktree directory, so a relative path that - // worked at the entry would re-resolve against the worktree (which doesn't - // contain the coverage file) on the recursive pass. Resolving once at the - // top means downstream `resolve_relative_to_root` calls become no-ops on - // an already-absolute path, regardless of which `--root` is in effect. let coverage_resolved = opts .coverage .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root))); - // Absolutize runtime_coverage at the public entry for the same - // reason coverage is absolutized: `compute_base_snapshot` swaps - // `opts.root` to a temp worktree directory, and any relative path - // would re-resolve against that worktree on the recursive base - // pass. The diff source is resolved separately by `main()` into - // the process-wide shared-diff cache before audit even runs, so - // it does not need entry-point absolutization here. let runtime_coverage_resolved = opts .runtime_coverage .map(|p| crate::health::scoring::resolve_relative_to_root(p, Some(opts.root))); @@ -3552,12 +3339,6 @@ pub fn run_audit(opts: &AuditOptions<'_>, gate_marker: Option<&str>) -> ExitCode }; match execute_audit(&resolved_opts) { Ok(result) => { - // Best-effort: record this run into the local Impact store. No-op - // when Impact tracking is disabled; never affects exit/output. - // Build the per-finding attribution input from the typed results so - // impact can credit genuinely-resolved findings. `check.results` - // also carries the present-suppression snapshot used to tell a fix - // from a `fallow-ignore`. let mut findings = result .check .as_ref() @@ -3684,8 +3465,6 @@ mod tests { let repo = init_throwaway_repo(tmp.path(), "repo"); let worktree_path = tmp.path().join("fallow-audit-base-1234-5678"); - // Register a real worktree with git so the guard's `git worktree remove` - // has something concrete to roll back. git( &repo, &[ @@ -3702,7 +3481,6 @@ mod tests { { let _guard = WorktreeCleanupGuard::new(&repo, &worktree_path); - // Guard drops at end of scope without `defuse()`. } assert!( @@ -3737,7 +3515,6 @@ mod tests { { let mut guard = WorktreeCleanupGuard::new(&repo, &worktree_path); guard.defuse(); - // Idempotent: a second defuse must not panic. guard.defuse(); } @@ -3750,27 +3527,18 @@ mod tests { "defused guard must not unregister the worktree from git", ); - // Clean up manually so the tempdir teardown does not race git's lock files. remove_audit_worktree(&repo, &worktree_path); let _ = fs::remove_dir_all(&worktree_path); } #[test] fn audit_orphan_sweep_removes_dead_pid_worktree() { - // Use a PID well above all platforms' typical and maximum ranges: - // - Linux: pid_max defaults to 32 768, max cap 4 194 304 (2^22) - // - macOS: kern.maxproc defaults to 99 998 - // - Windows: PIDs are multiples of 4; 99 999 999 mod 4 == 3, so it - // cannot be a valid Windows PID either. - // 99 999 999 exceeds all three. const DEAD_PID: u32 = 99_999_999; assert!(!process_is_alive(DEAD_PID)); let tmp = tempfile::TempDir::new().expect("temp dir should be created"); let repo = init_throwaway_repo(tmp.path(), "repo"); - // The sweep only considers worktrees whose parent is the system temp dir. - // Mirror that here so the test exercises the real filter path. let worktree_path = std::env::temp_dir().join(format!( "fallow-audit-base-{}-{}", DEAD_PID, @@ -3844,7 +3612,6 @@ mod tests { "sweep must not unregister worktree owned by a live PID", ); - // Tear down the live-PID worktree so it does not leak across tests. remove_audit_worktree(&repo, &worktree_path); let _ = fs::remove_dir_all(&worktree_path); } @@ -3923,8 +3690,6 @@ mod tests { !reusable_worktree_last_used_path(&worktree_path).exists(), "sweep should remove the sidecar `.last-used` file alongside the worktree", ); - // Lock file may or may not exist; it is created only when - // `try_acquire` is called. We do NOT assert on it here. cleanup_reusable_worktree(&repo, &worktree_path); } @@ -3957,8 +3722,6 @@ mod tests { register_reusable_worktree(&repo, &worktree_path); write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24)); - // Hold the lock from this thread so the sweep's `try_acquire` - // observes contention and skips the entry. Drop after the sweep. let lock = ReusableWorktreeLock::try_acquire(&worktree_path) .expect("test should acquire the lock first"); @@ -3982,11 +3745,6 @@ mod tests { let repo = init_throwaway_repo(tmp.path(), "repo-gc-grace"); let worktree_path = make_reusable_path("gc-grace"); register_reusable_worktree(&repo, &worktree_path); - // No sidecar written. Backdate the dir's own mtime so that "fall back - // to dir mtime" would falsely trigger removal; the grace path must - // NOT consult dir mtime. - // (Skipping dir mtime backdate is fine: the implementation never - // reads it, so the assertion is structural: sidecar absent => keep.) let sidecar = reusable_worktree_last_used_path(&worktree_path); assert!( !sidecar.exists(), @@ -4018,20 +3776,11 @@ mod tests { #[test] fn reusable_cache_gc_preserves_lock_file_after_removal() { - // Lock-file lifecycle invariant: the sweep MUST NOT delete the - // `.lock` file. If it did, a sibling acquirer holding a kernel - // flock on the now-unlinked inode could race with a later - // `open(O_CREAT)` that produces a fresh inode at the same path, - // letting two processes hold "the lock" concurrently on - // different inodes. let tmp = tempfile::TempDir::new().expect("temp dir should be created"); let repo = init_throwaway_repo(tmp.path(), "repo-gc-lockfile"); let worktree_path = make_reusable_path("gc-lockfile"); register_reusable_worktree(&repo, &worktree_path); write_sidecar_with_age(&worktree_path, Duration::from_hours(31 * 24)); - // Create the lock file by attempting (and immediately dropping) a lock. - // This mirrors the file shape `ReusableWorktreeLock::try_acquire` - // leaves behind under normal usage. let lock_path = reusable_worktree_lock_path(&worktree_path); drop( ReusableWorktreeLock::try_acquire(&worktree_path) @@ -4058,14 +3807,6 @@ mod tests { #[test] fn reuse_or_create_stamps_sidecar_on_fresh_create_and_age_threshold_applies() { - // Documented contract on `cache_max_age_days`: "Maximum age (in days - // since last reuse or fresh create)". This test pins both halves: - // (a) a fresh `reuse_or_create` writes the sidecar with a near-now - // mtime, AND - // (b) backdating that sidecar past the threshold causes the next - // sweep to actually remove the entry. Without (a), one-off - // base SHAs would persist through the first sweep regardless - // of age, contradicting the contract. let tmp = tempfile::TempDir::new().expect("temp dir should be created"); let repo = init_throwaway_repo(tmp.path(), "repo-fresh-create-stamp"); let base_sha = git_rev_parse(&repo, "HEAD").expect("HEAD should resolve"); @@ -4089,11 +3830,8 @@ mod tests { "fresh-create sidecar mtime should be near now(), got age {initial_age:?}", ); - // Drop the worktree handle so the persistent cache survives but we - // can mutate the sidecar. drop(worktree); - // Backdate the sidecar past the threshold; sweep must now remove it. write_sidecar_with_age(&cache_path, Duration::from_hours(31 * 24)); sweep_old_reusable_caches(&repo, Duration::from_hours(30 * 24), true); @@ -4152,8 +3890,6 @@ mod tests { #[test] fn reusable_worktree_lock_excludes_concurrent_acquires() { let tmp = tempfile::TempDir::new().expect("temp dir should be created"); - // Use a stable reusable-path-shaped value inside the tempdir so the - // lock file lives somewhere we can clean up automatically. let reusable = tmp.path().join("fallow-audit-base-cache-deadbeef-0000"); let lock_path = reusable_worktree_lock_path(&reusable); @@ -4163,17 +3899,7 @@ mod tests { ReusableWorktreeLock::try_acquire(&reusable).is_none(), "second acquire must fail while the first is held", ); - // Don't assert that a same-process re-acquire-after-drop succeeds: - // macOS flock(2) can keep the lock visible to other open file - // descriptions in the same process for a brief window after close, - // and this test would flake under parallel `cargo test` execution. - // The cross-process release path is exercised by every real `fallow - // audit` invocation; the in-process exclusion above is the actual - // invariant we need to guarantee here. drop(first); - // The lock file inode persists after the holder drops; only the - // kernel lock state is released. Anchor that so future maintainers - // don't conflate "release" with "delete". assert!( lock_path.exists(), "lock file must persist after drop (only the kernel lock is released)", @@ -4793,9 +4519,6 @@ mod tests { let timings = health.timings.expect("performance timings should be kept"); assert!(timings.discover_ms.abs() < f64::EPSILON); assert!(timings.parse_ms.abs() < f64::EPSILON); - // Same production settings, so dupes should also have piggy-backed on - // the dead-code file list (no separate verifiable signal in DupesResult, - // but the run must still produce a non-None result). assert!( result.dupes.is_some(), "dupes should run when changed files exist" @@ -4804,9 +4527,6 @@ mod tests { #[test] fn audit_dupes_falls_back_to_own_discovery_when_health_off() { - // When health and dupes have different production settings, dupes must - // not borrow files from dead-code (the file sets can differ). The two - // execution paths should still produce a result. let tmp = tempfile::TempDir::new().expect("temp dir should be created"); let root = tmp.path(); fs::create_dir_all(root.join("src")).expect("src dir should be created"); @@ -4876,23 +4596,11 @@ mod tests { #[cfg(unix)] #[test] fn remap_focus_files_does_not_canonicalize_through_symlinks() { - // Function-level contract: `remap_focus_files` must NOT canonicalize - // `to_root`. The base worktree path comes from `std::env::temp_dir()` - // un-canonicalized, and `discover_files` walks the worktree using that - // exact prefix; resolving symlinks here would silently shift the prefix - // on systems where the tempdir traverses one (`/tmp` -> `/private/tmp`, - // `/var` -> `/private/var` on macOS) and miss every discovered file at - // base. Pin the contract via a synthetic `from_root` and a real - // symlinked `to_root`; the matching end-to-end behavior is covered by - // `audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files`. let tmp = tempfile::TempDir::new().expect("temp dir"); let real = tmp.path().join("real"); let link = tmp.path().join("link"); fs::create_dir_all(&real).expect("real dir"); std::os::unix::fs::symlink(&real, &link).expect("symlink"); - // Sanity: `link` and `link.canonicalize()` differ. If the OS canonicalized - // them to the same path, the test premise doesn't hold and the assertion - // below is meaningless. let canonical = link.canonicalize().expect("canonicalize symlink"); assert_ne!(link, canonical, "symlink should not equal its target"); @@ -4912,9 +4620,6 @@ mod tests { #[test] fn remap_focus_files_skips_paths_outside_from_root() { - // A file outside `from_root` (e.g., a sibling workspace touched in the - // same diff) must not collapse the entire focus set. The optimization - // should stay active for the in-scope subset. let from_root = PathBuf::from("/repo/apps/web"); let to_root = PathBuf::from("/wt/apps/web"); let mut focus = FxHashSet::default(); @@ -4944,25 +4649,7 @@ mod tests { #[test] fn audit_gate_new_only_inherits_pre_existing_duplicates_in_focused_files() { - // Regression test for the dupe-focus optimization: when changed files - // contain duplicates that ALSO existed at base (HEAD~1), the audit gate - // must classify them as `inherited`, not `introduced`. The original - // implementation canonicalized `to_root` in `remap_focus_files`, which - // on macOS shifted the prefix from `/var/folders/...` to - // `/private/var/folders/...`. `discover_files` in the base worktree - // walked the un-canonical path, so set membership at base missed every - // remapped focus path. `find_duplicates_touching_files` returned 0 - // groups at base, base_keys was empty, and every current finding - // misclassified as `introduced`. let tmp = tempfile::TempDir::new().expect("temp dir should be created"); - // Mirror production: `validate_root` canonicalizes user-supplied roots - // before they reach `execute_audit`. This test exercises the *base - // worktree* side of the bug, where the worktree path comes from - // `std::env::temp_dir()` and is canonical-vs-un-canonical INDEPENDENT - // of what `opts.root` looks like. On macOS, `std::env::temp_dir()` - // returns `/var/folders/...` and `canonicalize` resolves it to - // `/private/var/folders/...`, so a buggy remap loses every focus path - // even when `opts.root` is already canonical. let root_buf = tmp .path() .canonicalize() @@ -4990,8 +4677,6 @@ mod tests { root, &["-c", "commit.gpgsign=false", "commit", "-m", "initial"], ); - // Append a comment-only line so the file is "changed" without altering - // the duplicated token sequence. fs::write( root.join("src/changed.ts"), format!("{dup_block}// touched\n"), diff --git a/crates/cli/src/baseline.rs b/crates/cli/src/baseline.rs index a4b8ddb03..2158b62fd 100644 --- a/crates/cli/src/baseline.rs +++ b/crates/cli/src/baseline.rs @@ -683,7 +683,6 @@ pub fn filter_new_clone_groups( !baseline_keys.contains(key.as_str()) }); - // Re-generate families from the filtered groups report.clone_families = fallow_core::duplicates::families::group_into_families(&report.clone_groups, root); report.mirrored_directories = fallow_core::duplicates::families::detect_mirrored_directories( @@ -691,7 +690,6 @@ pub fn filter_new_clone_groups( root, ); - // Re-compute stats for the filtered groups report.stats = recompute_stats(&report); report @@ -739,8 +737,6 @@ pub fn recompute_stats(report: &DuplicationReport) -> fallow_core::duplicates::D } } -// ── Health baseline ───────────────────────────────────────────────── - /// Baseline data for health (complexity) comparison. /// /// New baselines store count-per-category-per-file data in `finding_counts` so @@ -970,8 +966,6 @@ fn overflowing_severities(current: [usize; 3], baseline: [usize; 3]) -> [bool; 3 let mut available = baseline; let mut overflow = [false; 3]; - // Match lower severities first with the least-flexible compatible baseline - // slots so ambiguous cases still leave worse current severities visible. for severity_idx in 0..3 { let compatible = available[severity_idx..].iter().sum::(); overflow[severity_idx] = compatible < current[severity_idx]; @@ -1059,12 +1053,6 @@ fn runtime_coverage_finding_key( finding: &crate::health_types::RuntimeCoverageFinding, _root: &Path, ) -> String { - // Writer key. Prefer the cross-surface join key (`fallow:fn:`) when - // present so the baseline keys on the same identity the cloud and the other - // coverage surfaces use; fall back to the legacy per-finding suppression id - // (`fallow:prod:`) for 0.5-shape findings that carry no identity. Both - // forms hash the function's start line, so a moved function gets a new key - // under either; the grace-window reader accepts both forms. finding .stable_id .clone() @@ -1137,13 +1125,6 @@ pub fn filter_new_runtime_coverage_findings( .map(String::as_str) .collect(); findings.retain(|finding| { - // Grace window: a finding counts as baselined if ANY of three keys - // match: its stable_id (baselines written on this version), its legacy - // `fallow:prod:` id (baselines written before the upgrade), or the - // line-move-tolerant `path\0name\0source_hash` composite (so a - // moved-but-unedited function stays suppressed). Retain (report as new) - // only when NONE matches. A finding with no `source_hash` simply relies - // on the stable_id / legacy keys. let suppressed_by_stable_id = finding .stable_id .as_deref() @@ -1252,8 +1233,6 @@ mod tests { } } - // ── BaselineData round-trip ────────────────────────────────── - #[test] fn baseline_from_results_captures_all_fields() { let results = make_results(); @@ -1379,8 +1358,6 @@ mod tests { ); } - // ── filter_new_issues ──────────────────────────────────────── - #[test] fn filter_removes_baseline_issues() { let results = make_results(); @@ -1542,8 +1519,6 @@ mod tests { assert_eq!(filtered.unused_exports[0].export.export_name, "helperB"); } - // ── DuplicationBaselineData ────────────────────────────────── - fn make_clone_group(instances: Vec<(&str, usize, usize)>) -> CloneGroup { CloneGroup { instances: instances @@ -1597,7 +1572,6 @@ mod tests { #[test] fn clone_group_key_is_sorted() { let root = Path::new("/project"); - // Order of instances in group shouldn't matter for the key let group_ab = make_clone_group(vec![ ("/project/src/a.ts", 1, 10), ("/project/src/b.ts", 5, 15), @@ -1710,8 +1684,6 @@ mod tests { assert!((stats.duplication_percentage - 0.0).abs() < f64::EPSILON); } - // ── HealthBaselineData ────────────────────────────────────────── - fn make_health_finding( root: &Path, name: &str, @@ -1968,8 +1940,6 @@ mod tests { assert_eq!(filtered.len(), 1); } - // ── circular_dep_key sort stability ───────────────────────── - #[test] fn circular_dep_key_is_order_independent() { use fallow_core::results::CircularDependency; @@ -2051,8 +2021,6 @@ mod tests { ); } - // ── filter_new_issues: extended issue types ──────────────── - fn make_full_results() -> AnalysisResults { use fallow_core::extract::MemberKind; use fallow_core::results::*; @@ -2210,7 +2178,6 @@ mod tests { ..BaselineData::from_results(&AnalysisResults::default(), Path::new("")) }; let mut results = AnalysisResults::default(); - // One in baseline, one new results .circular_dependencies .push(CircularDependencyFinding::with_actions( @@ -2271,8 +2238,6 @@ mod tests { assert_eq!(filtered.boundary_violations.len(), 1); } - // ── filter_new_health_targets ────────────────────────────── - #[test] fn health_targets_baseline_filters_known() { let root = PathBuf::from("/project"); @@ -2306,8 +2271,6 @@ mod tests { assert_eq!(filtered[0].path, root.join("src/new-issue.ts")); } - // ── duplicate_export_key ─────────────────────────────────── - #[test] fn duplicate_export_key_is_sorted() { use fallow_core::results::{DuplicateExport, DuplicateLocation}; @@ -2347,8 +2310,6 @@ mod tests { ); } - // ── boundary_violation_key ───────────────────────────────── - #[test] fn boundary_violation_key_format() { use fallow_core::results::BoundaryViolation; @@ -2365,8 +2326,6 @@ mod tests { assert_eq!(key, "src/ui/btn.ts->src/db/query.ts"); } - // ── cross-machine baseline portability (#87) ────────────── - /// Build results with absolute paths rooted at the given prefix. fn make_absolute_results(root: &str) -> AnalysisResults { use fallow_core::extract::MemberKind; @@ -2462,7 +2421,6 @@ mod tests { let results = make_absolute_results("/Users/dev/project"); let baseline = BaselineData::from_results(&results, local_root); - // Keys should be relative assert_eq!(baseline.unused_files, vec!["src/old.ts"]); assert_eq!(baseline.unused_exports, vec!["src/utils.ts:helper"]); assert_eq!( @@ -2485,7 +2443,6 @@ mod tests { assert_eq!(baseline.unresolved_imports, vec!["src/app.ts:./missing"]); assert_eq!(baseline.duplicate_exports, vec!["Config|src/a.ts|src/b.ts"]); - // Simulate loading baseline on CI (different absolute root, same relative structure) let ci_root = Path::new("/home/runner/work/project/project"); let ci_results = make_absolute_results("/home/runner/work/project/project"); @@ -2534,9 +2491,6 @@ mod tests { #[test] fn legacy_prod_baseline_still_suppresses_finding() { - // A baseline written before the v2 upgrade holds only `fallow:prod:` - // ids. A finding carrying a stable_id MUST still be suppressed by its - // legacy id during the grace window. let baseline = HealthBaselineData { runtime_coverage_findings: vec!["fallow:prod:deadbeef".to_owned()], ..HealthBaselineData::default() @@ -2554,12 +2508,6 @@ mod tests { #[test] fn source_hash_baseline_survives_line_move() { - // A baseline written on this version holds the line-move-tolerant - // `path\0name\0source_hash` composite. After the function moves lines, - // a real producer emits a DIFFERENT `fallow:prod:` id AND a different - // `fallow:fn:` stable_id (both hash the start line), but the SAME - // content digest (`source_hash`). The finding MUST stay suppressed via - // the source_hash match alone. let root = Path::new("/repo"); let baselined = runtime_finding( "fallow:prod:deadbeef", @@ -2568,10 +2516,8 @@ mod tests { Some("0123456789abcdef"), ); let baseline = HealthBaselineData::from_findings(&[], &[baselined], &[], root); - // Sanity: the composite key was written. assert_eq!(baseline.runtime_coverage_source_hashes.len(), 1); - // Moved from line 14 to 40: new prod id, new stable_id, SAME source_hash. let findings = vec![runtime_finding( "fallow:prod:99999999", Some("fallow:fn:cafe0002"), diff --git a/crates/cli/src/bin/schema_emit.rs b/crates/cli/src/bin/schema_emit.rs index a964ef3a3..7dc9f6886 100644 --- a/crates/cli/src/bin/schema_emit.rs +++ b/crates/cli/src/bin/schema_emit.rs @@ -6,25 +6,9 @@ //! Regenerate `docs/output-schema.json` from the Rust source of truth. //! -//! Built only when the `schema-emit` cargo feature is active. Pulls -//! `schemars::JsonSchema` derives off the result and duplication types and -//! prints a draft-07 JSON Schema document to stdout. -//! -//! Usage: -//! ```bash -//! cargo run -p fallow-cli --features schema-emit --bin fallow-schema-emit \ -//! > docs/output-schema.json -//! ``` -//! -//! Today this emits only the `definitions` block that can be derived from the -//! in-scope structs (`AnalysisResults`, all per-finding types in -//! `crates/types/src/results.rs`, `DuplicationReport` and friends in -//! `crates/core/src/duplicates/types.rs`). Hand-written sections of -//! `docs/output-schema.json` (the top-level `oneOf`, envelopes such as -//! `CheckOutput` / `DupesOutput` / `HealthOutput`, audit/explain/coverage/ -//! codeclimate/review envelopes, and the health subtree) are merged in from -//! the committed file so the emitted document stays a drop-in replacement -//! while subsequent migration phases tackle them. +//! When the `schema-emit` feature is enabled, this binary derives the Rust-owned +//! definitions, merges them with the committed schema, and prints draft-07 JSON +//! Schema to stdout. #[cfg(not(test))] use std::path::PathBuf; @@ -104,24 +88,15 @@ use fallow_types::results::{ UnusedDependency, UnusedDependencyOverride, UnusedExport, UnusedFile, UnusedMember, }; -/// Workspace-relative path to the committed schema. Read at runtime against -/// the workspace root so the published `fallow-cli` crate does not need to -/// bundle `docs/output-schema.json` (which lives outside the cli crate's -/// own directory). Only used by the production code path; tests use the -/// embedded copy below. +/// Workspace-relative path to the committed schema. #[cfg(not(test))] const COMMITTED_SCHEMA_REL_PATH: &str = "docs/output-schema.json"; -/// Embedded copy used by `#[cfg(test)] mod drift_tests`. Tests run with -/// `CARGO_MANIFEST_DIR = crates/cli`, so the runtime resolver below would -/// have to walk the workspace; the embedded copy is simpler and only ships -/// in test builds. +/// Embedded copy used by tests. #[cfg(test)] const COMMITTED_SCHEMA: &str = include_str!("../../../../docs/output-schema.json"); -/// Locate `docs/output-schema.json` by walking up from `CARGO_MANIFEST_DIR` -/// (or the current working directory) until a parent contains the file. -/// Returns the full file contents. +/// Locate `docs/output-schema.json` by walking up from `CARGO_MANIFEST_DIR`. #[cfg(not(test))] fn read_committed_schema() -> Result { let start = std::env::var_os("CARGO_MANIFEST_DIR") @@ -141,10 +116,7 @@ fn read_committed_schema() -> Result { )) } -/// Test-only helper that uses the embedded schema rather than the -/// filesystem, keeping the drift tests fast and independent of working -/// directory. The `Result` wrap mirrors the non-test signature so callers -/// stay agnostic of which path is active. +/// Test-only helper that uses the embedded schema. #[cfg(test)] #[expect( clippy::unnecessary_wraps, @@ -178,28 +150,9 @@ fn run() -> Result<(), String> { Ok(()) } -/// Names of the definitions that this binary owns (regenerated from Rust). -/// Anything not in this set is copied verbatim from the committed schema. -/// -/// As migration phases land (health subtree, envelopes), entries move from -/// the committed-only set into this list, until eventually `merge_with_committed` -/// can be replaced by a pure derive-and-emit flow. +/// Definitions owned by this binary; everything else is copied from the committed schema. pub(crate) fn derived_definition_names() -> &'static [&'static str] { - // The list below is intentionally narrower than the full set of types with - // `JsonSchema` derives. It contains only types that have a SEPARATE, - // matching definition in `docs/output-schema.json#/definitions/` today. - // - // Types whose Rust definition is inlined into a parent's schema (enums - // like `DependencyLocation`, `MemberKind`, `RefactoringKind`, - // `SuppressionOrigin`, ...) are intentionally excluded because there is - // nothing to drift-check against. A follow-up that extracts inline enums - // into separate `definitions/` entries can grow this list. - // - // Types that are LSP-internal (`ExportUsage`, `ReferenceLocation`) or - // shipped via a separate output (feature flags) are also excluded; they - // are not part of the public JSON output contract today. &[ - // crates/types/src/results.rs - per-finding structs "BoundaryViolation", "CircularDependency", "DuplicateExport", @@ -220,26 +173,15 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "UnusedExport", "UnusedFile", "UnusedMember", - // crates/core/src/duplicates/types.rs - per-finding clone structs "CloneFamily", "CloneGroup", "CloneInstance", "MirroredDirectory", - // crates/types/src/output.rs - JSON-layer augmentations "AddToConfigAction", "FixAction", "IssueAction", "SuppressFileAction", "SuppressLineAction", - // crates/cli/src/health_types/ - health output subtree. - // `HealthFinding` is the typed wrapper introduced in #384 B2 that - // flattens `ComplexityViolation` and carries the typed `actions` - // list plus the optional audit-mode `introduced` flag natively. - // `ComplexityViolation` is the inner payload; both definitions - // ship in `docs/output-schema.json` since the wrapper's - // `#[serde(flatten)]` keeps the on-the-wire shape compatible with - // pre-wrapper consumers that read the inner fields at the top - // level of each `findings[]` item. "ComplexityViolation", "ContributorEntry", "CoverageGapSummary", @@ -268,18 +210,11 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "UntestedFileFinding", "VitalSigns", "VitalSignsCounts", - // crates/types/src/output_health.rs - per-finding action wrappers "HealthFindingAction", "HotspotAction", "RefactoringTargetAction", "UntestedExportAction", "UntestedFileAction", - // crates/types/src/envelope.rs - shared envelope / utility shapes. - // Scalar utility newtypes (SchemaVersion / ToolVersion / ElapsedMs / - // AuditIntroduced) have no properties to drift-check; they are - // registered so refs from envelopes resolve and so future shape - // tightening (range constraints, enum variants) flows through the - // gate. "AuditIntroduced", "BaselineDeltas", "BaselineMatch", @@ -290,10 +225,6 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "RegressionResult", "SchemaVersion", "ToolVersion", - // crates/cli/src/health_types/runtime_coverage.rs - per-finding - // helpers + enums emitted as separate definitions in the - // committed schema. The full subtree is drift-checked so a - // future Rust field change in a helper fires the gate. "RuntimeCoverageAction", "RuntimeCoverageBlastRadiusEntry", "RuntimeCoverageCaptureQuality", @@ -309,14 +240,8 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "RuntimeCoverageSummary", "RuntimeCoverageVerdict", "RuntimeCoverageWatermark", - // Bare body shapes referenced from CombinedOutput / AuditOutput - // for the sub-results where the wire emits the body without - // envelope-header wrapping. Drift-checking them here forces the - // committed `$ref`s on the parent envelopes to resolve against the - // same shape the wire produces. "DuplicationReport", "HealthReport", - // crates/cli/src/output_envelope.rs - per-command envelope structs. "AuditOutput", "CheckGroupedEntry", "CheckGroupedOutput", @@ -338,36 +263,18 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "ReviewEnvelopeOutput", "ReviewEnvelopeSummary", "ReviewReconcileOutput", - // crates/cli/src/output_envelope.rs - typed document root that - // wraps the 11 object-shaped envelopes via `#[serde(untagged)]`. - // Drives the schema's document-root `oneOf` (see - // `rewrite_document_root_one_of` in `merge_with_committed`); the - // committed schema's root therefore becomes a derived artifact. "FallowOutput", - // crates/cli/src/output_envelope.rs - list --boundaries envelope - // and building blocks (issue #373). "BoundariesListLogicalGroup", "BoundariesListRule", "BoundariesListZone", "BoundariesListing", "ListBoundariesOutput", - // crates/config/src/config/boundaries.rs - referenced by - // BoundariesListLogicalGroup and also surfaced on the resolved - // boundary config for in-process consumers. "AuthoredRule", "LogicalGroup", "LogicalGroupStatus", - // crates/cli/src/report/dupes_grouping.rs - per-group duplication - // attribution payload (`fallow dupes --group-by`). "AttributedCloneGroup", "AttributedInstance", "DuplicationGroup", - // crates/cli/src/output_dupes.rs - typed duplication wrappers - // introduced in #409 (PR C of the #384 ladder). Each wraps the - // matching bare finding via `#[serde(flatten)]` and carries the - // typed `actions[]` array (plus optional `introduced` audit flag - // on the top-level CloneGroupFinding) natively, retiring the - // legacy `inject_dupes_actions` post-pass. "AttributedCloneGroupFinding", "CloneFamilyAction", "CloneFamilyActionType", @@ -376,16 +283,8 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { "CloneGroupActionType", "CloneGroupFinding", "DupesReportPayload", - // crates/cli/src/output_envelope.rs - typed CoverageAnalyzeOutput - // root envelope introduced in #410 (PR D of the #384 ladder). - // Replaces the hand-built `serde_json::json!` macro in - // `crates/cli/src/coverage/analyze.rs::print_runtime_json` and - // joins `FallowOutput` as a sibling object variant. "CoverageAnalyzeOutput", "CoverageAnalyzeSchemaVersion", - // crates/cli/src/impact.rs - `fallow impact --format json` value - // report. Joins FallowOutput as an object variant; its nested counts, - // trend, and containment shapes are separate definitions. "ContainmentEvent", "ImpactCounts", "ImpactReport", @@ -396,45 +295,14 @@ pub(crate) fn derived_definition_names() -> &'static [&'static str] { ] } -/// Names of finding-type definitions that the JSON output layer wraps with -/// the `actions` array plus the optional `introduced` flag. The schema gets -/// these properties appended after derivation so the public contract stays -/// in lock-step with what `crates/cli/src/report/json.rs` actually emits. -/// -/// New finding types added in `crates/types/src/results.rs` must also be -/// added here, otherwise the emitted schema will under-document the JSON -/// output and the drift test will flag the missing entry. -/// -/// `augment_finding_definition` unconditionally pushes `"actions"` into the -/// per-finding `required` array. The runtime always emits `actions: [...]` -/// (possibly empty) on every finding, so requiring the field on the wire is -/// honest. The previous "augmentation is non-opinionated" stance was a -/// pre-Phase-8 escape hatch that documented some finding types as having -/// optional `actions` while emitting them; it is retired. +/// Finding definitions that get `actions` and optional `introduced` grafts. fn finding_definition_names() -> &'static [&'static str] { - // Every finding family has now been migrated to typed `*Finding` wrappers - // (in `crates/types/src/output_dead_code.rs`, `crates/cli/src/health_types/finding.rs`, - // or `crates/cli/src/output_dupes.rs`); the wrappers flatten the bare - // finding via `#[serde(flatten)]` and carry the typed `actions[]` (plus - // optional `introduced`) array natively via schemars. No definition - // requires the legacy `augment_finding_definition` post-graft anymore. - // - // Kept as a function returning an empty slice (rather than a const) so - // adding a future hand-augmented finding requires the same one-liner - // edit, and the in-test scaffolding (`augment_finding_definition`, - // `FindingAugmentation`, `finding_augmentation`) stays in place ready - // for the rare case it is needed again. &[] } /// Per-finding override for `augment_finding_definition`. /// -/// The default augmentation attaches `actions: array` and an -/// `introduced` audit-mode flag. Health findings (`HealthFinding`, -/// `HotspotFinding`, `RefactoringTargetFinding`) are no longer augmented -/// because they became typed wrappers in #384 B2 and B3 that flatten -/// their respective inner payloads and carry typed `actions` (plus -/// `introduced` for `HealthFinding` only) natively via schemars. +/// Default augmentation for dead-code findings. #[derive(Debug, Clone, Copy)] struct FindingAugmentation { /// Schema `$ref` for the items in the `actions` array. @@ -450,20 +318,12 @@ const DEFAULT_FINDING_AUGMENTATION: FindingAugmentation = FindingAugmentation { include_introduced: true, }; -/// Pick the augmentation for a specific finding. Every finding family has -/// migrated to typed `*Finding` wrappers (most recently the duplication -/// family in #409 and the standalone coverage envelope in #410); no -/// definition currently routes through here. The function stays in place -/// so a future hand-augmented finding can be wired with a single arm. +/// Pick the augmentation for a specific finding. fn finding_augmentation(_name: &str) -> FindingAugmentation { DEFAULT_FINDING_AUGMENTATION } /// Build derived schemas for every in-scope type using one shared generator. -/// -/// Registering each type as a subschema (rather than a root schema) collects -/// every transitively-referenced definition into a single map keyed by the -/// Rust type name, which we then merge into the schema's `definitions`. #[allow( clippy::too_many_lines, reason = "this function is fundamentally a registration list: one `subschema_for::()` call per type in the public output contract. Splitting by module obscures the registration set; the linear list is the cleanest representation." @@ -471,18 +331,9 @@ fn finding_augmentation(_name: &str) -> FindingAugmentation { fn derived_definitions() -> Map { let mut generator = SchemaSettings::draft07().into_generator(); - // Trigger registration of every in-scope type. Return values are discarded - // because we only want the side effect of populating the generator's - // definitions table. AnalysisResults pulls in every per-finding type - // transitively, and DuplicationReport pulls in every clone-detection - // type, so a small set of top-level subschema calls covers all leaves. let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Belt-and-braces: register every type by name to guarantee its presence - // even if a future refactor stops referencing it from the top-level - // containers. Cheap (no-op for already-registered types) and keeps the - // derived set predictable for the drift test. let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -521,17 +372,11 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Per-group duplication attribution (crates/cli/src/report/dupes_grouping.rs). let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Typed duplication wrappers (crates/cli/src/output_dupes.rs). - // Each wraps a bare clone finding via `#[serde(flatten)]` and carries - // a typed `actions[]` array natively, retiring the legacy - // `inject_dupes_actions` post-pass in `crates/cli/src/report/json.rs` - // (#409 / PR C of the #384 ladder). let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -541,7 +386,6 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // JSON-output augmentation types from `crates/types/src/output.rs`. let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -555,10 +399,6 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Typed dead-code finding wrappers from - // `crates/types/src/output_dead_code.rs`. Each wraps a bare finding via - // `#[serde(flatten)]` and carries a typed `actions` array natively, - // retiring the per-finding `augment_finding_definition` graft. let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -575,7 +415,6 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Health output subtree (crates/cli/src/health_types/). let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -610,7 +449,6 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Envelope and utility shapes (crates/types/src/envelope.rs). let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -629,9 +467,6 @@ fn derived_definitions() -> Map { register_per_command_envelope_definitions(&mut generator); - // Fallow Impact value report (crates/cli/src/impact.rs). The - // `fallow impact --format json` shape; registered as definitions so the - // FallowOutput root references them rather than inlining. let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -640,14 +475,10 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // Typed document root. Must be registered AFTER every variant struct so - // schemars resolves each variant against the already-registered - // definition rather than inlining. let _ = generator.subschema_for::(); register_list_boundaries_definitions(&mut generator); - // Per-finding action wrapper types (crates/types/src/output_health.rs). let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -660,18 +491,10 @@ fn derived_definitions() -> Map { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); - // `apply_transforms = true` runs any registered schema transforms (e.g. - // inline-subschemas) before returning, matching what `into_root_schema_for` - // would have produced. We do not register custom transforms, so this is a - // no-op today; passing `true` keeps the output stable if a future settings - // change adds one. generator.take_definitions(true) } -/// Register per-command envelope structs from `crates/cli/src/output_envelope.rs`. -/// Extracted from [`derived_definitions`] to keep the orchestrator under the -/// SIG unit-size threshold (the per-envelope list grew past the 150-line cap -/// when `FallowOutput` was added in #384 item 6). +/// Register per-command envelope structs. fn register_per_command_envelope_definitions(generator: &mut schemars::SchemaGenerator) { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -718,11 +541,7 @@ fn register_per_command_envelope_definitions(generator: &mut schemars::SchemaGen let _ = generator.subschema_for::(); } -/// Register the `fallow list --boundaries --format json` envelope and its -/// building blocks. Extracted from [`derived_definitions`] to keep the -/// orchestrator under the SIG unit-size threshold; the pre-expansion -/// logical-group types live in `fallow_config` (issue #373) and ride along -/// via `JsonSchema` so the committed schema's `$ref`s resolve. +/// Register the `fallow list --boundaries --format json` envelope. fn register_list_boundaries_definitions(generator: &mut schemars::SchemaGenerator) { let _ = generator.subschema_for::(); let _ = generator.subschema_for::(); @@ -735,15 +554,6 @@ fn register_list_boundaries_definitions(generator: &mut schemars::SchemaGenerato } /// Merge derived definitions back into the hand-written schema document. -/// -/// The committed `docs/output-schema.json` carries: -/// - top-level metadata (`$schema`, `title`, `description`, `oneOf`), -/// - hand-written envelopes and out-of-scope subtrees inside `definitions`. -/// -/// We replace every entry in `definitions` whose key appears in -/// `derived_definition_names()` with the derived schema, and leave the rest -/// untouched. The diff between this output and the committed file is the -/// drift gate's signal. fn merge_with_committed(derived: &Map) -> Result { let source = committed_schema_source()?; let mut document: Value = serde_json::from_str(&source) @@ -773,18 +583,6 @@ fn merge_with_committed(derived: &Map) -> Result { definitions.insert((*name).to_string(), value); } - // Schemars produces transitively-referenced helper definitions for every - // typed enum / payload subtype on the in-scope structs (`FixActionType`, - // the kebab-case kind enums, `DependencyLocation`, `MemberKind`, - // `CoverageSetupFramework`, etc.). After Phase 8 every - // helper that appears in `docs/output-schema.json` is a derived artifact, - // so always overwrite the committed entry rather than preserving it. The - // previous "skip if already present" guard silently froze the helper - // shape on the first regen; any subsequent change to a serde rename or - // schemars attribute would be invisible until the helper was manually - // deleted from the committed file. The explicit `derived_definition_names()` - // list above is the drift-checked surface; this loop fills in every - // transitively-referenced helper so the `$ref` graph resolves. let in_scope: rustc_hash::FxHashSet<&'static str> = derived_definition_names().iter().copied().collect(); for (name, value) in derived { @@ -801,28 +599,10 @@ fn merge_with_committed(derived: &Map) -> Result { Ok(document) } -/// Hand-maintained root-level envelope definitions that are NOT yet typed -/// via Rust + schemars but DO appear as top-level `--format json` outputs. -/// Each entry is referenced from the document-root `oneOf` so the typed -/// surface (`FallowOutput`) plus the bare-array CodeClimate spec plus these -/// hand-maintained envelopes together document every shape fallow can emit. -/// -/// Entries here MUST also appear as a `$ref` from the document-root `oneOf` -/// (the drift test `hand_maintained_root_envelopes_appear_in_root_one_of` -/// asserts this). Removing an entry means the migration has landed and the -/// envelope is now a variant of `FallowOutput`; in that case the -/// corresponding `definitions[]` block must also be removed (or -/// remain only as a transitive helper) so the test -/// `every_registered_name_resolves_to_a_derived_schema` still passes. +/// Hand-maintained root envelopes that still need top-level `oneOf` entries. const HAND_MAINTAINED_ROOT_ENVELOPES: &[&str] = &[]; -/// Drive the document-root `oneOf` from the typed `FallowOutput` enum plus -/// the two non-object branches (`CodeClimateOutput`, hand-maintained -/// envelopes). Replaces the previously hand-maintained block. -/// -/// Also rewrites the root `description` to point readers at the discriminator -/// rules (untagged + unique-field-presence) rather than the per-command -/// enumeration the old prose carried. +/// Drive the document-root `oneOf` from the typed `FallowOutput` enum. fn rewrite_document_root_one_of(document: &mut Value) -> Result<(), String> { let root = document .as_object_mut() @@ -830,11 +610,6 @@ fn rewrite_document_root_one_of(document: &mut Value) -> Result<(), String> { let mut one_of: Vec = Vec::with_capacity(2 + HAND_MAINTAINED_ROOT_ENVELOPES.len()); one_of.push(serde_json::json!({ "$ref": "#/definitions/FallowOutput" })); - // CodeClimateOutput serializes as `Vec` via - // `#[serde(transparent)]`. `#[serde(tag = ...)]` cannot internally tag - // a non-object variant and wrapping the array would break the Code - // Climate / GitLab Code Quality spec, so it stays as a sibling root - // branch outside `FallowOutput`. one_of.push(serde_json::json!({ "$ref": "#/definitions/CodeClimateOutput" })); for name in HAND_MAINTAINED_ROOT_ENVELOPES { one_of.push(serde_json::json!({ "$ref": format!("#/definitions/{name}") })); @@ -867,23 +642,7 @@ fn rewrite_document_root_one_of(document: &mut Value) -> Result<(), String> { Ok(()) } -/// Add the `actions` array and optional `introduced` flag to a derived -/// finding schema. These two fields are injected by the JSON output layer -/// (`crates/cli/src/report/json.rs`) on every issue object but are not on the -/// Rust source struct, so the schema needs them grafted in to match what -/// downstream consumers actually receive. -/// -/// The augmentation is idempotent: if the derived schema already carries an -/// `actions` property (e.g. because a future PR refactors the JSON layer to -/// serialize through typed wrappers), the augmentation step skips and the -/// derived shape wins. -/// -/// `augmentation` selects the `actions[]` `$ref` and whether `introduced` is -/// attached. Dead-code findings use [`DEFAULT_FINDING_AUGMENTATION`] (actions -/// of type `IssueAction`, `introduced` attached); health findings use the -/// matching per-finding wrapper (`HealthFindingAction` / `HotspotAction` / -/// `RefactoringTargetAction`) and skip `introduced` when the finding does not -/// flow through `fallow audit`. +/// Add the `actions` array and optional `introduced` flag to a finding schema. fn augment_finding_definition( value: &mut Value, augmentation: FindingAugmentation, @@ -928,39 +687,11 @@ fn augment_finding_definition( Ok(()) } -/// Apply post-processing to derived schemas so they match the conventions of -/// the hand-written `docs/output-schema.json`. -/// -/// Production normalization (this function, applied to the emitted document): -/// -/// - Drop the `$schema` keyword that schemars writes on each subschema; only -/// the top-level document carries it. -/// - Schemars 1 prefers `$ref` -> `#/$defs/Foo`, but the committed file uses -/// `#/definitions/Foo`. Rewrite refs so they line up with the merged -/// document layout. -/// -/// Drift-comparison normalization (the `normalize_one` helper inside -/// `#[cfg(test)] mod drift_tests`, applied ONLY before structural equality -/// checks): drops `format`/`minimum`/`maximum`/`description` keywords, -/// collapses `type: ["X", "null"]` to `type: "X"`, collapses single-element -/// `allOf: [{$ref: X}]` wrappers to the bare `$ref`, and canonicalizes -/// `oneOf`/`anyOf`. Those rewrites do NOT run on the emitted document; -/// they exist so the drift gate can compare structures while tolerating -/// schemars' integer-format hints, nullable-union output, and doc-comment -/// prose churn that the committed schema does not encode the same way. -/// Editing this function's behavior should usually be mirrored in -/// `normalize_one`, and vice versa. +/// Normalize derived schemas to match the committed schema layout. fn normalize_schema(value: &mut Value) { match value { Value::Object(map) => { map.remove("$schema"); - // Strip schemars cosmetic output that the committed schema does not - // encode: `default` from `#[serde(default)]`, integer-width formats - // and bounds from `u8`/`u32`/`usize`/etc, and per-property example - // hints. These survive into the regenerated document otherwise and - // would force every consumer to handle schemars-version churn. The - // test-side normalizer at `normalize_one` mirrors these strips so - // the strict drift gate stays symmetric. map.remove("default"); map.remove("examples"); map.remove("format"); @@ -973,11 +704,6 @@ fn normalize_schema(value: &mut Value) { { *reference = format!("#/definitions/{rest}"); } - // Schemars wraps `$ref` in a single-arm `allOf` when the field also - // carries a `description` (so the description does not lose its - // owner). Collapse to a bare `$ref` alongside the description; the - // committed schema uses the flat form and downstream tools handle - // both interchangeably. if let Some(Value::Array(all_of)) = map.get("allOf") && all_of.len() == 1 && let Some(Value::Object(only)) = all_of.first() @@ -989,15 +715,6 @@ fn normalize_schema(value: &mut Value) { map.insert("$ref".to_string(), reference); } for (key, child) in map.iter_mut() { - // Keys inside `properties` / `definitions` / `$defs` / - // `patternProperties` maps are user-facing names (struct field - // names, type names), not schema keywords. Recurse into each - // VALUE (each is itself a schema) without applying the - // keyword strip to the surrounding map's keys; otherwise a - // struct field literally named `format` / `default` / - // `minimum` / etc. would be silently dropped from the - // emitted schema. Issue #394 fired this for - // `ContributorEntry.format: ContributorIdentifierFormat`. if matches!( key.as_str(), "properties" | "definitions" | "$defs" | "patternProperties" @@ -1024,32 +741,11 @@ fn normalize_schema(value: &mut Value) { mod drift_tests { //! Drift gate for the Rust → `docs/output-schema.json` chain. //! - //! The structural gate walks every definition schemars produces (not just - //! the explicit `derived_definition_names()` allow-list) and compares it - //! against the matching entry in the committed schema after - //! canonicalization. Transitive helpers (`AnalysisResults`, `MemberKind`, - //! `FixActionType`, every kebab-case enum, every utility newtype) are - //! drift-checked alongside the explicitly-registered envelopes. - //! - //! Canonicalization erases documented cosmetic differences (doc-comment - //! prose, schemars-style `nullable` integer formats, `oneOf` vs `anyOf`, - //! single-arm `allOf` wrappers) so the comparison fires only on real - //! structural drift. - //! - //! `derived_definition_names()` survives as the allow-list for the - //! post-derivation augmentation (`actions` / `introduced` graft on - //! findings); the drift tests below iterate the full derived map. - //! - //! Real drift fires loudly: a renamed Rust field, a new struct field, or - //! a type change shows up as a property/required/type mismatch on the - //! relevant definition. Pure prose changes do not fire; those are tracked - //! by the prose-migration phase that moves descriptions into `///` doc - //! comments. + //! The drift gate compares normalized derived schemas to the committed file. use super::*; - /// Run a single normalization pass on a JSON value, recursively. Returns - /// the canonical form used by the drift comparison. + /// Recursively normalize a JSON value for drift comparison. fn canonicalize(mut value: Value) -> Value { normalize_one(&mut value); value @@ -1058,21 +754,12 @@ mod drift_tests { fn normalize_one(value: &mut Value) { match value { Value::Object(map) => { - // Drop description prose entirely. Phase 8 will sync prose - // back from Rust doc comments; until then the drift gate - // tolerates description divergence by design. map.remove("description"); - // Schemars derives integer constraints from the underlying - // Rust width. The committed schema does not encode width - // today, so strip the integer-format hints before comparing. map.remove("format"); map.remove("minimum"); map.remove("maximum"); map.remove("exclusiveMinimum"); map.remove("exclusiveMaximum"); - // Schemars 1 emits `Option` as `type: ["X", "null"]`. The - // committed schema marks optionals via `skip_serializing_if` - // alone, so collapse the nullable union to a scalar `type`. if let Some(Value::Array(arr)) = map.get_mut("type") { arr.retain(|v| v.as_str() != Some("null")); if arr.len() == 1 { @@ -1080,8 +767,6 @@ mod drift_tests { map.insert("type".to_string(), only); } } - // Single-element `allOf: [{$ref: X}]` -> bare `{$ref: X}`. - // Schemars emits the wrapper when a variant carries doc text. if let Some(Value::Array(all_of)) = map.get("allOf") && all_of.len() == 1 && let Some(Value::Object(only)) = all_of.first() @@ -1092,14 +777,9 @@ mod drift_tests { map.remove("allOf"); map.insert("$ref".to_string(), reference); } - // Treat `oneOf` and `anyOf` as equivalent for discriminated - // unions: canonicalize to `oneOf`. Both validate the same - // instances for mutually-exclusive variants in practice. if let Some(any_of) = map.remove("anyOf") { map.insert("oneOf".to_string(), any_of); } - // Sort `required` and `enum` arrays so order differences do - // not fire the gate. if let Some(Value::Array(items)) = map.get_mut("required") { items.sort_by(|a, b| { a.as_str() @@ -1115,14 +795,6 @@ mod drift_tests { }); } for (key, child) in map.iter_mut() { - // Mirror the production-side guard in `normalize_schema`: - // do not apply the keyword strip to keys inside - // `properties` / `definitions` / `$defs` / - // `patternProperties` maps because those keys are - // property/type names, not schema keywords. Without this - // guard a struct field named `format` (or `default` / - // `minimum` / etc) is dropped before the drift gate - // compares structures, masking a real schema regression. if matches!( key.as_str(), "properties" | "definitions" | "$defs" | "patternProperties" @@ -1155,13 +827,7 @@ mod drift_tests { .expect("committed docs/output-schema.json must carry `definitions`") } - /// Build the full set of derived definitions for drift comparison: every - /// key schemars emits, normalized; augmented only for entries in - /// `derived_definition_names()` (the post-pass `actions`/`introduced` graft - /// applies to findings). Transitive helpers (e.g., `AnalysisResults`, - /// `MemberKind`, `FixActionType`, every kebab-case enum) are included - /// without augmentation so the strict gate covers every committed - /// definition, not just the explicit allow-list. + /// Build the normalized derived schema map used by the drift checks. fn derived_definitions_for_drift() -> Map { let raw = derived_definitions(); let mut out = Map::new(); @@ -1181,10 +847,7 @@ mod drift_tests { out } - /// Catch new derives that landed in Rust without being registered in - /// `derived_definition_names()`. Without this assertion a contributor - /// could add `JsonSchema` to a new struct, forget the registration step, - /// and the drift gate would silently skip the new type forever. + /// Ensure every registered name resolves to a derived schema. #[test] fn every_registered_name_resolves_to_a_derived_schema() { let derived = derived_definitions(); @@ -1196,28 +859,9 @@ mod drift_tests { } } - /// Every variant of [`FallowOutput`] must have its inner type registered - /// in [`derived_definitions`]. Without registration, schemars inlines the - /// variant's schema in the root `FallowOutput` `oneOf` rather than - /// emitting a `$ref`; the document-root union then drifts from the - /// `definitions/` map and the drift gate may or may not catch it - /// depending on whether the variant's inner type is transitively - /// referenced from another registered type. - /// - /// The `VARIANTS` list is hand-maintained because Rust does not provide - /// reflection over enum variants. The nested `_variant_count_is_locked` - /// match produces a `non-exhaustive patterns` compile error if a - /// contributor adds a variant to [`FallowOutput`] without updating this - /// test, so the list cannot silently drift. - /// - /// Regression for issue #417: mechanizes the `#[allow(dead_code)]` - /// social contract on the enum into a `cargo test`-time assertion. + /// Ensure every `FallowOutput` variant stays registered. #[test] fn every_fallow_output_variant_is_registered_in_derived_definitions() { - // (variant tag for the diagnostic, inner type name as schemars - // emits it). Keep in sync with `enum FallowOutput` in - // `crates/cli/src/output_envelope.rs`. The exhaustive match below - // enforces this at compile time. const VARIANTS: &[(&str, &str)] = &[ ("Audit", "AuditOutput"), ("Explain", "ExplainOutput"), @@ -1234,19 +878,11 @@ mod drift_tests { ("Combined", "CombinedOutput"), ]; - // Compile-time exhaustiveness check. Adding a new variant to - // `FallowOutput` without extending `VARIANTS` above fails this - // match with `non-exhaustive patterns`. The function is never - // called; it exists solely to lock the variant count. #[expect( dead_code, reason = "compile-time exhaustiveness guard for the VARIANTS list above; never called at runtime" )] fn variant_count_is_locked(value: &FallowOutput) -> &'static str { - // The leading `variant_` (not `_variant_`) is intentional: - // rustc auto-silences `dead_code` on identifiers starting - // with `_`, which would make `#[expect(dead_code)]` - // unfulfilled and trigger `unfulfilled_lint_expectations`. match value { FallowOutput::Audit(_) => "Audit", FallowOutput::Explain(_) => "Explain", @@ -1281,8 +917,7 @@ mod drift_tests { ); } - /// Each finding type listed in `finding_definition_names()` must exist in - /// the registered set, otherwise the augmentation pass silently skips it. + /// Ensure every finding type is registered before augmentation runs. #[test] fn finding_names_are_subset_of_registered_names() { let registered: rustc_hash::FxHashSet<&'static str> = @@ -1295,12 +930,7 @@ mod drift_tests { } } - /// Augmentation attaches the `actions` array to every finding type, and - /// the `introduced` flag to every audit-aware finding (see - /// `finding_augmentation`: hotspot and refactoring target are not - /// audit-aware today, so their derived schemas must NOT carry - /// `introduced`). The required-flag for `actions` is decided by the - /// committed schema per-type; the augmentation step is non-opinionated. + /// Verify augmentation adds the expected `actions` / `introduced` fields. #[test] fn augmentation_attaches_actions_and_introduced_to_each_finding() { let derived = derived_definitions_for_drift(); @@ -1331,35 +961,11 @@ mod drift_tests { } } - /// Field-level drift gate: for every in-scope definition, every property - /// in the derived schema must exist in the committed schema (and vice - /// versa, modulo known JSON-layer augmentations `actions` / `introduced`). - /// Required-field sets must match exactly modulo the same augmentations. - /// - /// Catches the high-value drift classes: - /// - Rust struct field added → committed schema is missing the property - /// - Rust struct field renamed → committed has the old name only - /// - Rust struct field removed → committed has a stale property - /// - `Option` flipped to `T` (or vice versa) → required mismatch - /// - /// Does NOT catch property-value drift (e.g., `u32` → `String`). - /// Tightening that check is deferred until the prose-migration phase - /// lets the canonicalizer be strict about schemars-vs-handwritten shape - /// differences. + /// Verify derived and committed property/required sets stay in sync. #[test] fn committed_definitions_match_derived_property_keys() { let committed = committed_definitions(); let derived = derived_definitions_for_drift(); - // Augmentation keys live only in the committed schema for finding - // types because they get grafted on by `augment_finding_definition`. - // `actions_meta` was previously here for the `HealthOutput` post-pass - // injection, but Phase 8 modelled it as `Option` on - // `HealthReport` (flattened into `HealthOutput`) so schemars emits the - // field natively. As of #384 B2 the typed `HealthFinding` wrapper - // also carries `actions` + `introduced` natively, so those keys do - // not need an augmentation graft for `HealthFinding`. Permit - // `actions` / `introduced` to differ between sides without firing - // the gate; everything else must match. const AUGMENTATION_KEYS: &[&str] = &["actions", "introduced"]; let mut failures: Vec = Vec::new(); @@ -1438,25 +1044,7 @@ mod drift_tests { ); } - /// Targeted property-`$ref` drift gate. For every property on every - /// in-scope definition, if BOTH sides have a `$ref` at the same key, - /// the ref targets must match. Catches the specific failure mode where - /// the committed schema documents a sub-key as pointing at one - /// definition (e.g. `CombinedOutput.dupes` -> `DupesOutput`) while the - /// derived Rust source actually produces a different shape on the wire - /// (bare `DuplicationReport`). The property-key gate above misses this - /// because the property exists on both sides under the same name; only - /// the `$ref` VALUE differs. - /// - /// Canonicalisation reuses [`normalize_one`] so schemars's - /// `allOf: [{$ref: X}]` wrapper around doc-bearing fields and - /// `anyOf: [{$ref: X}, {type: null}]` wrapper around `Option` - /// fields both collapse to bare `$ref` before comparison. - /// Per-array `items.$ref` is intentionally NOT compared: arrays whose - /// element type changes already fire the property-key gate via - /// transitive schemas, and adding items-level checks here would - /// require deeper structural unification that belongs in the - /// `#[ignore]`d strict gate. + /// Verify property-level `$ref` targets stay aligned. #[test] fn committed_property_refs_match_derived_property_refs() { let committed = committed_definitions(); @@ -1501,10 +1089,7 @@ mod drift_tests { ); } - /// Extract the canonical `$ref` target from a property value, peeling - /// schemars' `allOf` / `anyOf` / `oneOf` wrappers. Returns `None` for - /// properties that do not reference another definition at the top - /// level (primitive types, arrays, free-form objects). + /// Extract the canonical `$ref` target from a property value. fn canonical_ref(value: &Value) -> Option { let mut canonical = value.clone(); normalize_one(&mut canonical); @@ -1521,14 +1106,7 @@ mod drift_tests { None } - /// The emitted schema's `$ref` graph must close: every `#/definitions/X` - /// reference must point at a definition that exists in the merged - /// document. A dangling ref means the schema is invalid for AJV-strict - /// consumers and would fail downstream validation. Schemars produces - /// helper definitions for typed enum / payload subtypes - /// (`FixActionType`, `DependencyLocation`, - /// `MemberKind`, ...) on the in-scope structs; if `merge_with_committed` - /// drops any of them, this test fires. + /// Verify every emitted `$ref` resolves in the merged schema. #[test] fn emitted_schema_has_no_dangling_refs() { let derived = derived_definitions(); @@ -1581,14 +1159,7 @@ mod drift_tests { ); } - /// Strict drift gate: full structural comparison of every in-scope - /// definition against the committed schema, after canonicalization. - /// - /// Runs on every `cargo test` invocation now that the committed schema is - /// regenerated from Rust as the source of truth. The canonicalization - /// step erases the documented cosmetic differences (doc-comment prose, - /// `oneOf` vs `anyOf`, single-arm `allOf` wrappers, schemars integer- - /// width hints, `Option` nullable-union forms). Anything else fires. + /// Verify the canonicalized committed schema matches the derived schema. #[test] fn committed_definitions_match_derived_structurally() { let committed = committed_definitions(); @@ -1613,15 +1184,6 @@ mod drift_tests { )); } } - // Catch orphans in the committed file: any definition listed in - // `docs/output-schema.json` that schemars no longer emits is a stale - // hand-edit waiting to drift. After Phase 8 every helper is - // overwritten on regen, so an orphan can only land via a manual edit. - // - // The allow-list below holds definitions that are legitimately - // hand-maintained pending other #384 items. Each entry MUST link to - // the issue item that will retire it; this is not a permanent - // escape hatch. const HAND_MAINTAINED_ALLOW_LIST: &[(&str, &str)] = &[]; let allow_list: rustc_hash::FxHashSet<&'static str> = HAND_MAINTAINED_ALLOW_LIST .iter() @@ -1643,16 +1205,7 @@ mod drift_tests { ); } - /// Regression for issue #394: `normalize_schema` recursively walks every - /// JSON object and strips schema-keyword names (`format`, `default`, - /// `minimum`, `maximum`, `examples`, `exclusiveMinimum`, - /// `exclusiveMaximum`). Before the fix it also stripped those keys when - /// they appeared as struct-field names inside a `properties` map, - /// silently dropping `ContributorEntry.format` from the emitted schema - /// and triggering ajv `strictRequired` because `format` stayed in the - /// `required` array. The guard skips the strip inside `properties` / - /// `definitions` / `$defs` / `patternProperties` so a property named - /// `format` (or any other keyword name) survives. + /// Regression for issue #394: property names like `format` must survive normalization. #[test] fn normalize_schema_preserves_property_named_format() { let mut value = serde_json::json!({ @@ -1692,11 +1245,7 @@ mod drift_tests { ); } - /// Mirror of `normalize_schema_preserves_property_named_format` for the - /// drift-test side `normalize_one`. Without the same guard the - /// canonicalized committed schema would lose its `format` property - /// before the comparison and the drift gate would silently accept a - /// Rust-side rename or removal of that field. + /// Mirror the `format`-property regression on the drift-test side. #[test] fn normalize_one_preserves_property_named_format() { let mut value = serde_json::json!({ @@ -1731,13 +1280,7 @@ mod drift_tests { ); } - /// Every entry in `HAND_MAINTAINED_ROOT_ENVELOPES` MUST appear as a - /// `$ref` in the document-root `oneOf`. Without this gate, a future - /// migration that types `CoverageAnalyzeOutput` and removes its - /// `definitions` entry could silently drop it from the documented - /// union if the implementer forgot to add the variant to - /// `FallowOutput`. The drift test fires so the regression surfaces - /// at `cargo test` time rather than at downstream-consumer time. + /// Ensure hand-maintained root envelopes remain referenced from the root union. #[test] fn hand_maintained_root_envelopes_appear_in_root_one_of() { let document: Value = serde_json::from_str(COMMITTED_SCHEMA) @@ -1771,10 +1314,7 @@ mod drift_tests { } } - /// The document-root `oneOf` MUST always reference `FallowOutput` as - /// its first entry plus the bare-array `CodeClimateOutput` branch. - /// Catches accidental removal of either reference by a future - /// `rewrite_document_root_one_of` edit. + /// Ensure the root union keeps `FallowOutput` and `CodeClimateOutput`. #[test] fn root_one_of_carries_fallow_output_and_codeclimate() { let document: Value = serde_json::from_str(COMMITTED_SCHEMA) diff --git a/crates/cli/src/bin/stub_sidecar.rs b/crates/cli/src/bin/stub_sidecar.rs index 2a11f73b7..705c5216c 100644 --- a/crates/cli/src/bin/stub_sidecar.rs +++ b/crates/cli/src/bin/stub_sidecar.rs @@ -35,9 +35,6 @@ use fallow_cov_protocol::{ }; fn main() -> ExitCode { - // Drain stdin so the parent CLI's writer does not get EPIPE on close. - // Parsing the Request is best-effort; the stub does not depend on its - // contents, but consuming the bytes matters. let mut buf = Vec::new(); let _ = std::io::stdin().read_to_end(&mut buf); let parsed: Option = serde_json::from_slice(&buf).ok(); diff --git a/crates/cli/src/check/filtering.rs b/crates/cli/src/check/filtering.rs index 5a4b82f60..d2ac917da 100644 --- a/crates/cli/src/check/filtering.rs +++ b/crates/cli/src/check/filtering.rs @@ -7,8 +7,6 @@ use rustc_hash::FxHashSet; use crate::error::emit_error; -// ── Workspace filtering ────────────────────────────────────────── - /// Scope results to the union of the given workspace roots. /// /// The full cross-workspace graph is still built (so cross-package imports @@ -24,7 +22,6 @@ pub fn filter_to_workspaces( let pkg_jsons: Vec = ws_roots.iter().map(|r| r.join("package.json")).collect(); let in_pkg_jsons = |p: &Path| pkg_jsons.iter().any(|pkg| p == pkg); - // File-scoped issues: retain only those under any workspace root results.unused_files.retain(|f| any_under(&f.file.path)); results.unused_exports.retain(|e| any_under(&e.export.path)); results.unused_types.retain(|e| any_under(&e.export.path)); @@ -41,7 +38,6 @@ pub fn filter_to_workspaces( .unresolved_imports .retain(|i| any_under(&i.import.path)); - // Dependency issues: scope to matching workspaces' package.json files results .unused_dependencies .retain(|d| in_pkg_jsons(&d.dep.path)); @@ -58,12 +54,10 @@ pub fn filter_to_workspaces( .test_only_dependencies .retain(|d| in_pkg_jsons(&d.dep.path)); - // Unlisted deps: keep only if any importing file is in a matched workspace results .unlisted_dependencies .retain(|d| d.dep.imported_from.iter().any(|s| any_under(&s.path))); - // Duplicate exports: filter locations to workspace, drop groups with < 2 for dup in &mut results.duplicate_exports { dup.export.locations.retain(|loc| any_under(&loc.path)); } @@ -71,37 +65,25 @@ pub fn filter_to_workspaces( .duplicate_exports .retain(|d| d.export.locations.len() >= 2); - // Circular deps: keep cycles where at least one file is in a matched workspace results .circular_dependencies .retain(|c| c.cycle.files.iter().any(|f| any_under(f))); - // Re-export cycles: same workspace-scoping shape as circular deps. results .re_export_cycles .retain(|c| c.cycle.files.iter().any(|f| any_under(f))); - // Boundary violations: keep if the importing file is in a matched workspace results .boundary_violations .retain(|v| any_under(&v.violation.from_path)); - // Stale suppressions: keep if the file is in a matched workspace results.stale_suppressions.retain(|s| any_under(&s.path)); - // Catalog entries live in the project-root pnpm-workspace.yaml, not per-workspace. - // Workspace scoping is asking "show me findings for this subset of packages"; - // catalog hygiene is a whole-project concern, so drop it when --workspace narrows. results.unused_catalog_entries.clear(); results.empty_catalog_groups.clear(); - // Unresolved catalog references are anchored at consumer package.json paths, - // so they ARE workspace-scoped: retain only findings under the active set. results .unresolved_catalog_references .retain(|r| any_under(&r.reference.path)); - // Dependency overrides live in the project-root pnpm-workspace.yaml or - // root package.json's pnpm.overrides, not per-workspace. Same reasoning as - // unused-catalog-entries: drop when --workspace narrows. results.unused_dependency_overrides.clear(); results.misconfigured_dependency_overrides.clear(); } @@ -300,20 +282,11 @@ fn find_matches( Ok(hits) } -// ── Changed-file filtering ─────────────────────────────────────── - -// `filter_changed_files`, `try_get_changed_files`, `get_changed_files`, and -// `ChangedFilesError` were promoted to `fallow_core::changed_files` so the LSP -// (which depends on `fallow-core` but not `fallow-cli`) can reuse the exact -// same filter and git-resolution logic. Re-exported below for the existing -// internal call sites in this crate. pub use fallow_core::changed_files::{ filter_results_by_changed_files as filter_changed_files, get_changed_files, try_get_changed_files, }; -// ── Diff-line filtering (issue #424) ───────────────────────────── - /// Drop findings whose source line is not inside an added hunk of the /// supplied unified diff. Range-shaped findings (clone instances live in /// dupes, not here; complexity hotspots live in health, not here) are @@ -356,10 +329,8 @@ pub fn filter_results_by_diff( } }; - // File-only findings: keep when the file appears anywhere in the diff. results.unused_files.retain(|f| touches_file(&f.file.path)); - // Point findings: keep when the source line is an added line. results .unused_exports .retain(|e| line_in_diff(&e.export.path, e.export.line)); @@ -379,11 +350,6 @@ pub fn filter_results_by_diff( .unresolved_imports .retain(|i| line_in_diff(&i.import.path, i.import.line)); - // Unlisted dependencies: keep if any importing site is in the diff. - // The package-name finding wraps an aggregate of import sites; we - // narrow the sites to the in-diff subset first so a future renderer - // can show only the relevant ones, then drop the finding entirely if - // nothing remains. for unlisted in &mut results.unlisted_dependencies { unlisted .dep @@ -394,21 +360,6 @@ pub fn filter_results_by_diff( .unlisted_dependencies .retain(|d| !d.dep.imported_from.is_empty()); - // Duplicate exports: group-level retention without narrowing the - // locations list. A PR that adds ONE new duplicate against an - // existing off-diff location is exactly the case this filter must - // surface: the PR caused the duplicate, so the finding belongs in - // the review comment even though only one location is in the diff. - // Keep the finding if ANY location is in the diff, and KEEP ALL - // locations so the renderer can show the conflict pair (in-diff - // location + off-diff sibling) for context and so the - // `add-to-config` action has the full list to suppress. - // - // Diverges from `filter_to_workspaces` (which DOES narrow + drop - // below 2) because workspace scoping asks "show me only THIS - // workspace's duplicates", whereas the diff filter asks "show me - // duplicates THIS PR caused or touched", which inherently spans - // diff and non-diff locations. results.duplicate_exports.retain(|d| { d.export .locations @@ -416,52 +367,23 @@ pub fn filter_results_by_diff( .any(|loc| line_in_diff(&loc.path, loc.line)) }); - // Circular dependencies: keep cycle if any file in the cycle is in - // the diff. File-level rather than line-level because the cycle's - // line/col anchors at the import site of the first file only, but - // the cycle itself spans every file in `files[]`. results .circular_dependencies .retain(|c| c.cycle.files.iter().any(|f| touches_file(f))); - // Re-export cycles: same file-level treatment as circular deps; the - // diagnostic anchors at line 1 col 0 of each member so line-level - // diff matching would over-prune. results .re_export_cycles .retain(|c| c.cycle.files.iter().any(|f| touches_file(f))); - // Boundary violations: drop when the importing source line is not in - // the diff. The violation anchors at the offending `import` statement - // in `from_path`, so use that. results .boundary_violations .retain(|v| line_in_diff(&v.violation.from_path, v.violation.line)); - // Stale suppressions: drop when the suppression's source line is not - // in the diff. A stale `// fallow-ignore-next-line` is still real - // even when the PR doesn't touch it, but the diff filter is opt-in - // noise reduction, so consistent line-level treatment is the choice. results .stale_suppressions .retain(|s| line_in_diff(&s.path, s.line)); - - // Project-level findings (deps, catalog, override) bypass the filter. - // These anchor at fixed lines inside `package.json` / - // `pnpm-workspace.yaml` that a PR rarely touches even when the PR - // semantically caused the finding (e.g., removing the last consumer - // of a dep). See `pr_comment::PROJECT_LEVEL_RULE_IDS` for the - // canonical list and rationale. - // unused_dependencies, unused_dev_dependencies, - // unused_optional_dependencies, type_only_dependencies, - // test_only_dependencies, unused_catalog_entries, - // empty_catalog_groups, unresolved_catalog_references, - // unused_dependency_overrides, misconfigured_dependency_overrides - // are NOT touched here. } -// ── Changed workspaces ─────────────────────────────────────────── - /// Given a list of discovered workspaces and a set of changed file paths, /// return the indices of workspaces that contain any changed file. /// @@ -704,8 +626,6 @@ mod tests { let ws_root = PathBuf::from("/project/packages/ui"); filter_to_workspace(&mut results, &ws_root); - // "helper" had only 1 location in workspace — dropped - // "utils" had 2 locations in workspace — kept assert_eq!(results.duplicate_exports.len(), 1); assert_eq!(results.duplicate_exports[0].export.export_name, "utils"); } @@ -828,8 +748,6 @@ mod tests { assert_eq!(results.unused_class_members[0].member.member_name, "init"); } - // ── filter_changed_files ──────────────────────────────────────── - #[test] fn filter_changed_files_keeps_only_changed() { let mut results = AnalysisResults::default(); @@ -882,7 +800,6 @@ mod tests { filter_changed_files(&mut results, &changed); - // Dependency-level issues are NOT filtered by changed files assert_eq!(results.unused_dependencies.len(), 1); assert_eq!(results.unused_dev_dependencies.len(), 1); } @@ -948,7 +865,6 @@ mod tests { filter_changed_files(&mut results, &changed); - // Only one location is in changed files -> group dropped assert!(results.duplicate_exports.is_empty()); } @@ -1052,8 +968,6 @@ mod tests { assert!(results.unlisted_dependencies.is_empty()); } - // ── filter_to_workspace: additional coverage ─────────────────── - #[test] fn filter_to_workspace_scopes_optional_dependencies() { let mut results = AnalysisResults::default(); @@ -1182,7 +1096,6 @@ mod tests { let ws_root = PathBuf::from("/project/packages/ui"); filter_to_workspace(&mut results, &ws_root); - // Kept because at least one file is in the workspace assert_eq!(results.circular_dependencies.len(), 1); } @@ -1223,8 +1136,6 @@ mod tests { assert_eq!(results.total_issues(), 0); } - // ── filter_changed_files: additional coverage ────────────────── - #[test] fn filter_changed_files_filters_types_by_path() { let mut results = AnalysisResults::default(); @@ -1363,7 +1274,6 @@ mod tests { filter_changed_files(&mut results, &changed); - // Dependency-level issues are NOT filtered by changed files assert_eq!(results.unused_optional_dependencies.len(), 1); assert_eq!(results.type_only_dependencies.len(), 1); assert_eq!(results.test_only_dependencies.len(), 1); @@ -1545,8 +1455,6 @@ mod tests { assert_eq!(results.unresolved_imports[0].import.specifier, "./missing"); } - // ── multi-workspace resolution ────────────────────────────────── - fn ws(name: &str, rel: &str) -> fallow_config::WorkspaceInfo { fallow_config::WorkspaceInfo { root: PathBuf::from("/project").join(rel), @@ -1579,8 +1487,6 @@ mod tests { #[test] fn find_matches_exact_name_short_circuits_glob_metachars() { - // Package named `web-[staging]` contains glob metachars. Exact-name - // short-circuit must match it without attempting to compile as a glob. let workspaces = vec![ws("web-[staging]", "apps/web-staging")]; let rels = rel(&workspaces); let hits = find_matches( @@ -1602,7 +1508,6 @@ mod tests { ]; let rels = rel(&workspaces); - // Glob matching via name let hits = find_matches( "@scope/*", &workspaces, @@ -1612,7 +1517,6 @@ mod tests { .unwrap(); assert_eq!(hits, vec![0]); - // Glob matching via relative path let hits = find_matches( "apps/*", &workspaces, @@ -1627,7 +1531,6 @@ mod tests { fn find_matches_invalid_glob_after_no_literal_match_errors() { let workspaces = vec![ws("web", "apps/web")]; let rels = rel(&workspaces); - // `[` without closing is invalid glob syntax AND not a literal name. assert!( find_matches( "web-[bad", @@ -1744,8 +1647,6 @@ mod tests { assert_eq!(results.unused_files.len(), 0); } - // ── workspaces_containing_any (pure mapping) ──────────────────── - #[test] fn workspaces_containing_any_returns_only_hits() { let workspaces = vec![ @@ -1763,8 +1664,6 @@ mod tests { #[test] fn workspaces_containing_any_ignores_root_only_changes() { - // Root-level changes (lockfiles, CI config, top package.json) must not - // implicitly scope to "every workspace": they map to zero workspaces. let workspaces = vec![ws("ui", "packages/ui"), ws("api", "packages/api")]; let mut changed = FxHashSet::default(); changed.insert(PathBuf::from("/project/package.json")); @@ -1797,8 +1696,6 @@ mod tests { assert_eq!(hits, vec![1]); } - // ── resolve_workspace_scope ───────────────────────────────────── - #[test] fn resolve_workspace_scope_neither_flag_returns_none() { let root = Path::new("/project"); @@ -1817,10 +1714,6 @@ mod tests { ); } - // ChangedFilesError::describe is tested in fallow_core::changed_files - - // ── filter_results_by_diff (issue #424) ──────────────────────── - fn build_diff(text: &str) -> crate::report::ci::diff_filter::DiffIndex { crate::report::ci::diff_filter::DiffIndex::from_unified_diff(text) } @@ -1837,7 +1730,6 @@ mod tests { ); let root = Path::new("/project"); let mut results = AnalysisResults::default(); - // Touched line 11 -> kept; untouched line 30 -> dropped. results .unused_exports .push(UnusedExportFinding::with_actions(UnusedExport { @@ -1871,10 +1763,6 @@ mod tests { #[test] fn filter_by_diff_keeps_project_level_deps_even_when_diff_misses_package_json() { - // The bug Mira flagged: deleting `import 'lodash'` from a source - // file makes `lodash` an unused-dep, but the PR doesn't touch - // `package.json` so a naive line filter would silently drop the - // finding. Project-level findings MUST bypass the line filter. let diff = build_diff( "diff --git a/src/a.ts b/src/a.ts\n\ --- a/src/a.ts\n\ @@ -1991,15 +1879,6 @@ mod tests { #[test] fn filter_by_diff_keeps_duplicate_export_when_pr_adds_one_against_off_diff_existing() { - // The bug an external reviewer caught: a PR adds a new duplicate - // export in `src/a.ts:1` against an existing off-diff location in - // `src/b.ts:5`. The PR semantically CAUSED the duplicate, but the - // prior implementation narrowed `locations` to only the in-diff - // entry, then dropped the finding for falling below the 2-location - // floor. Result: zero findings reported even though the diff - // introduced a real duplicate. The fix keeps the finding when ANY - // location overlaps the diff AND preserves both locations so the - // renderer can show the conflict pair. let diff = build_diff( "diff --git a/src/a.ts b/src/a.ts\n\ --- a/src/a.ts\n\ @@ -2079,7 +1958,6 @@ mod tests { )); filter_results_by_diff(&mut results, &diff, root); assert_eq!(results.unlisted_dependencies.len(), 1); - // Only the in-diff import site survives the inner retain. assert_eq!(results.unlisted_dependencies[0].dep.imported_from.len(), 1); assert_eq!( results.unlisted_dependencies[0].dep.imported_from[0].path, diff --git a/crates/cli/src/check/mod.rs b/crates/cli/src/check/mod.rs index 6cd1fe15c..c0baa0522 100644 --- a/crates/cli/src/check/mod.rs +++ b/crates/cli/src/check/mod.rs @@ -18,8 +18,6 @@ pub use filtering::get_changed_files; pub use filtering::resolve_workspace_scope; pub use rules::has_error_severity_issues; -// ── Issue type filters ────────────────────────────────────────── - #[derive(Default)] pub struct IssueFilters { pub unused_files: bool, @@ -141,8 +139,6 @@ impl IssueFilters { } } -// ── Trace options ─────────────────────────────────────────────── - pub struct TraceOptions { pub trace_export: Option, pub trace_file: Option, @@ -159,8 +155,6 @@ impl TraceOptions { } } -// ── Check command ──────────────────────────────────────────────── - pub struct CheckOptions<'a> { pub root: &'a std::path::Path, pub config_path: &'a Option, @@ -236,14 +230,12 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { fallow_config::ProductionAnalysis::DeadCode, )?; - // Thread --include-entry-exports flag into config for analysis layer if opts.include_entry_exports { config.include_entry_exports = true; } opts.filters.activate_explicit_opt_ins(&mut config.rules); - // Workspace filter resolution (either --workspace or --changed-workspaces) let ws_roots = filtering::resolve_workspace_scope( opts.root, opts.workspace, @@ -251,12 +243,10 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { opts.output, )?; - // Changed-files resolution let changed_files: Option> = opts .changed_since .and_then(|git_ref| filtering::get_changed_files(opts.root, git_ref)); - // Core analysis let use_trace = opts.trace_opts.any_active(); #[expect( deprecated, @@ -298,12 +288,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { } } } else { - // `fallow_core::analyze` returns only `AnalysisResults`, not the wider - // `AnalysisOutput`, so `script_used_packages` is intentionally empty here. - // No code on this path reads it: trace dispatch is gated on `trace_graph` - // (which is also `None` here), and `SharedParseData` is only constructed - // when `retain_modules_for_health` is set (which routes through - // `analyze_retaining_modules`, populating the real set). match fallow_core::analyze(&config) { Ok(r) => (r, None, None, None, None, rustc_hash::FxHashSet::default()), Err(e) => { @@ -313,7 +297,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { }; let elapsed = start.elapsed(); - // Performance output if let Some(ref timings) = trace_timings && opts.trace_opts.performance && !opts.defer_performance @@ -321,7 +304,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { report::print_performance(timings, config.output); } - // Trace early-return if let Some(ref graph) = trace_graph && let Some(code) = output::handle_trace_output( graph, @@ -334,20 +316,14 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { return Err(code); } - // Workspace scoping if let Some(ref ws_roots) = ws_roots { filtering::filter_to_workspaces(&mut results, ws_roots); } - // Changed-file filtering if let Some(ref changed) = changed_files { filtering::filter_changed_files(&mut results, changed); } - // Diff-line filtering (issue #424). CLI calls use the startup cache so - // combined runs do not re-read stdin or re-parse the same file three - // times; programmatic/NAPI calls pass an explicit per-call index so - // concurrent requests cannot inherit another request's diff scope. if let Some(diff_index) = match opts.diff_index { Some(index) => Some(index), None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(), @@ -356,7 +332,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { filtering::filter_results_by_diff(&mut results, diff_index, opts.root); } - // Single-file filtering (--file) if !opts.file.is_empty() { let file_set: rustc_hash::FxHashSet = opts .file @@ -369,7 +344,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { } }) .collect(); - // Warn about paths that don't exist on disk (show resolved path for clarity) for (original, resolved) in opts.file.iter().zip(file_set.iter()) { if !resolved.exists() { eprintln!( @@ -380,8 +354,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { } } filtering::filter_changed_files(&mut results, &file_set); - // Suppress project-wide dependency issues in single-file mode. - // Users expect --file to scope ALL output to the specified file(s). results.unused_dependencies.clear(); results.unused_dev_dependencies.clear(); results.unused_optional_dependencies.clear(); @@ -389,13 +361,10 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { results.test_only_dependencies.clear(); } - // Rules application rules::apply_rules(&mut results, &config); - // CLI issue-type filters opts.filters.apply(&mut results); - // Baseline handling let baseline_matched = handle_baseline( &mut results, opts.save_baseline, @@ -405,7 +374,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { opts.output, )?; - // Warn if saving a baseline from scoped results (would produce misleading counts) if !matches!( opts.regression_opts.save_target, regression::SaveRegressionTarget::None @@ -418,9 +386,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { ); } - // Save regression baseline if requested. - // Track the just-saved counts so that if --fail-on-regression is also active, - // the same-run comparison uses the fresh baseline (not the pre-save config state). let just_saved_baseline = match opts.regression_opts.save_target { regression::SaveRegressionTarget::File(save_path) => { let counts = regression::CheckCounts::from_results(&results); @@ -448,7 +413,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { regression::SaveRegressionTarget::None => None, }; - // Regression detection — use just-saved baseline if available, then config, then file let config_baseline_ref = just_saved_baseline .as_ref() .map(regression::CheckCounts::to_config_baseline); @@ -458,7 +422,6 @@ pub fn execute_check(opts: &CheckOptions<'_>) -> Result { let regression_outcome = regression::compare_check_regression(&results, &opts.regression_opts, config_baseline)?; - // SARIF file write if let Some(sarif_path) = opts.sarif_file { output::write_sarif_file(&results, &config, sarif_path, opts.quiet); } @@ -549,7 +512,6 @@ pub fn print_check_result(result: &CheckResult, opts: PrintCheckOptions) -> Exit return report_code; } - // Print regression outcome to stderr if let Some(ref outcome) = result.regression { if !opts.quiet { regression::print_regression_outcome(outcome); @@ -572,7 +534,6 @@ pub fn run_check(opts: &CheckOptions<'_>) -> ExitCode { Err(code) => return code, }; - // Entry-point summary (standalone check mode; combined mode uses orientation header) if !opts.quiet && matches!(opts.output, OutputFormat::Human) { crate::combined::print_entry_point_summary(&result.results); } @@ -600,8 +561,6 @@ pub fn run_check(opts: &CheckOptions<'_>) -> ExitCode { }, ); - // Cross-reference: run duplication analysis on the full results - // (the combined command handles this separately) if opts.include_dupes && result.config.duplicates.enabled { output::run_cross_reference(&result.config, &result.results, opts.quiet); } @@ -609,8 +568,6 @@ pub fn run_check(opts: &CheckOptions<'_>) -> ExitCode { exit } -// ── Baseline helpers ──────────────────────────────────────────── - /// Save baseline and/or compare against an existing baseline. /// /// Returns `Some(ExitCode)` on fatal errors (serialization/IO failure), @@ -624,7 +581,6 @@ fn handle_baseline( quiet: bool, output: OutputFormat, ) -> Result, ExitCode> { - // Save baseline if requested if let Some(baseline_path) = save_path { let baseline_data = BaselineData::from_results(results, root); match serde_json::to_string_pretty(&baseline_data) { @@ -661,7 +617,6 @@ fn handle_baseline( } } - // Compare against baseline if provided if let Some(baseline_path) = load_path { match std::fs::read_to_string(baseline_path) { Ok(content) => match serde_json::from_str::(&content) { @@ -867,8 +822,6 @@ mod tests { assert!(!saved.ends_with("\n\n")); } - // ── IssueFilters::any_active ───────────────────────────────── - #[test] fn no_filters_means_none_active() { assert!(!no_filters().any_active()); @@ -904,8 +857,6 @@ mod tests { } } - // ── IssueFilters::apply ────────────────────────────────────── - #[test] fn apply_no_active_filters_preserves_all_results() { let mut results = make_results(); @@ -964,7 +915,6 @@ mod tests { #[test] fn apply_circular_deps_filter_keeps_only_circular_deps() { let mut results = make_results(); - // Add circular dependency to results results.circular_dependencies.push( fallow_types::output_dead_code::CircularDependencyFinding::with_actions( fallow_core::results::CircularDependency { @@ -989,8 +939,6 @@ mod tests { assert!(results.unused_dependencies.is_empty()); } - // ── TraceOptions::any_active ───────────────────────────────── - #[test] fn no_trace_options_means_none_active() { let t = TraceOptions { @@ -1046,8 +994,6 @@ mod tests { assert!(t.any_active()); } - // ── Boundary violations filter ────────────────────────────── - #[test] fn apply_boundary_violations_filter() { let mut results = make_results(); @@ -1075,8 +1021,6 @@ mod tests { assert!(results.circular_dependencies.is_empty()); } - // ── Combined filter for multiple types ────────────────────── - #[test] fn apply_all_filter_types_simultaneously() { let mut results = make_results(); @@ -1108,7 +1052,6 @@ mod tests { ), ); - // Enable all filters let f = IssueFilters { unused_files: true, unused_exports: true, @@ -1132,12 +1075,9 @@ mod tests { }; let total_before = results.total_issues(); f.apply(&mut results); - // With all filters enabled, all issues should be preserved assert_eq!(results.total_issues(), total_before); } - // ── Optional and type-only dependency filters ─────────────── - #[test] fn apply_unused_deps_clears_optional_and_type_only() { let mut results = make_results(); diff --git a/crates/cli/src/check/output.rs b/crates/cli/src/check/output.rs index c5bb53d9a..be21e38b6 100644 --- a/crates/cli/src/check/output.rs +++ b/crates/cli/src/check/output.rs @@ -7,16 +7,7 @@ use rustc_hash::FxHashSet; use super::TraceOptions; use crate::{error::emit_error, report}; -// ── Trace output ───────────────────────────────────────────────── - -/// Handle `--trace`, `--trace-file`, `--trace-dependency` early returns. -/// -/// `script_used_packages` is the set of binary names referenced from package.json -/// scripts and CI configs; `trace_dependency` consults it so script-only tooling -/// (microbundle, vitest, eslint) shows as used instead of being false-flagged. -/// -/// Returns `Some(code)` if a trace was handled (caller should return), -/// `None` if no trace was active and control should continue. +/// Handle `--trace`, `--trace-file`, and `--trace-dependency` early returns. pub(super) fn handle_trace_output( graph: &ModuleGraph, trace_opts: &TraceOptions, @@ -73,8 +64,6 @@ pub(super) fn handle_trace_output( None } -// ── SARIF output ───────────────────────────────────────────────── - /// Write SARIF output to a file if `--sarif-file` was specified. pub fn write_sarif_file( results: &fallow_core::results::AnalysisResults, @@ -85,7 +74,6 @@ pub fn write_sarif_file( let sarif = report::build_sarif(results, &config.root, &config.rules); match serde_json::to_string_pretty(&sarif) { Ok(json) => { - // Ensure parent directories exist if let Some(parent) = sarif_path.parent() && !parent.as_os_str().is_empty() && let Err(e) = std::fs::create_dir_all(parent) @@ -110,8 +98,6 @@ pub fn write_sarif_file( } } -// ── Cross-reference output ─────────────────────────────────────── - /// Run duplication cross-reference and print combined findings. pub fn run_cross_reference( config: &ResolvedConfig, @@ -141,8 +127,6 @@ pub(super) fn parse_trace_spec(spec: &str) -> Option<(&str, &str)> { mod tests { use super::*; - // ── parse_trace_spec ──────────────────────────────────────── - #[test] fn parse_trace_spec_simple() { let result = parse_trace_spec("src/utils.ts:foo"); @@ -175,7 +159,6 @@ mod tests { #[test] fn parse_trace_spec_multiple_colons_uses_last() { - // Handles Windows-style paths like C:\src\utils.ts:foo let result = parse_trace_spec("C:\\src\\utils.ts:foo"); assert_eq!(result, Some(("C:\\src\\utils.ts", "foo"))); } @@ -186,8 +169,6 @@ mod tests { assert_eq!(result, Some(("packages/core:src/index.ts", "myExport"))); } - // ── handle_trace_output with no trace active ──────────────── - #[test] fn handle_trace_output_returns_none_when_no_trace_active() { let trace_opts = TraceOptions { @@ -196,15 +177,9 @@ mod tests { trace_dependency: None, performance: false, }; - // We can't construct a ModuleGraph easily, but when no trace option - // is active, the function short-circuits to None without touching - // the graph. Verify by checking that the function signature accepts - // the empty trace opts correctly. assert!(!trace_opts.any_active()); } - // ── write_sarif_file ──────────────────────────────────────── - fn make_resolved_config() -> fallow_config::ResolvedConfig { fallow_config::ResolvedConfig { root: std::path::PathBuf::from("/project"), @@ -260,7 +235,6 @@ mod tests { let content = std::fs::read_to_string(&sarif_path).expect("read sarif"); let parsed: serde_json::Value = serde_json::from_str(&content).expect("parse sarif as json"); - // SARIF output should have a "$schema" or "version" field assert!(parsed.get("$schema").is_some() || parsed.get("version").is_some()); } diff --git a/crates/cli/src/check/rules.rs b/crates/cli/src/check/rules.rs index 18e78df1e..d1d7a1e8e 100644 --- a/crates/cli/src/check/rules.rs +++ b/crates/cli/src/check/rules.rs @@ -1,7 +1,5 @@ use fallow_config::{ResolvedConfig, RulesConfig, Severity}; -// ── Rules helpers ──────────────────────────────────────────────── - /// Remove issues whose effective severity is `Off` from the results. /// /// When overrides are configured, per-file rule resolution is used for @@ -12,7 +10,6 @@ pub fn apply_rules(results: &mut fallow_core::results::AnalysisResults, config: let rules = &config.rules; let has_overrides = !config.overrides.is_empty(); - // File-scoped issue types: filter per-file when overrides exist if has_overrides { results .unused_files @@ -106,7 +103,6 @@ pub fn apply_rules(results: &mut fallow_core::results::AnalysisResults, config: } } - // Non-file-scoped issue types: always use base rules if rules.unused_dependencies == Severity::Off { results.unused_dependencies.clear(); } @@ -166,7 +162,6 @@ pub fn has_error_severity_issues( ) -> bool { let has_overrides = config.is_some_and(|c| !c.overrides.is_empty()); - // File-scoped issue types: check per-file when overrides exist let file_scoped_errors = if has_overrides { let config = config.unwrap(); @@ -233,7 +228,6 @@ pub fn has_error_severity_issues( && !results.empty_catalog_groups.is_empty()) }; - // Non-file-scoped issue types: always use base rules file_scoped_errors || (rules.unused_dependencies == Severity::Error && !results.unused_dependencies.is_empty()) || (rules.unused_dev_dependencies == Severity::Error @@ -250,13 +244,6 @@ pub fn has_error_severity_issues( || (!has_overrides && rules.circular_dependencies == Severity::Error && !results.circular_dependencies.is_empty()) - // Note: re-export-cycle is intentionally NOT guarded by `!has_overrides`. - // Per-file `overrides.rules.re-export-cycle` is a no-op (the cycle spans - // multiple files; see `crates/config/src/config/resolution.rs` load-time - // warn). The file-scoped block above does not consult re_export_cycle, - // so adding the guard would silently mute re_export_cycle errors any - // time overrides exist for an unrelated rule. Keep the project-wide - // check unconditional. || (rules.re_export_cycle == Severity::Error && !results.re_export_cycles.is_empty()) || (rules.boundary_violation == Severity::Error && !results.boundary_violations.is_empty()) || (rules.unused_catalog_entries == Severity::Error @@ -355,8 +342,6 @@ mod tests { use fallow_core::results::*; use std::path::PathBuf; - // ── Helper: build populated AnalysisResults ────────────────── - fn make_results() -> AnalysisResults { let mut r = AnalysisResults::default(); r.unused_files @@ -502,8 +487,6 @@ mod tests { ) } - // ── apply_rules ────────────────────────────────────────────── - #[test] fn apply_rules_default_error_preserves_all() { let mut results = make_results(); @@ -523,7 +506,6 @@ mod tests { let config = config_with_rules(rules); apply_rules(&mut results, &config); assert!(results.unused_files.is_empty()); - // Other types are preserved assert!(!results.unused_exports.is_empty()); } @@ -576,7 +558,6 @@ mod tests { #[test] fn apply_rules_off_each_type_individually() { - // Verify every rule field maps to its corresponding results field let field_setters: Vec<(RuleFieldSetter, ResultFieldCheck)> = vec![ ( |r| r.unused_files = Severity::Off, @@ -637,8 +618,6 @@ mod tests { } } - // ── has_error_severity_issues ──────────────────────────────── - #[test] fn empty_results_no_error_issues() { let results = AnalysisResults::default(); @@ -721,10 +700,8 @@ mod tests { unused_dependency_overrides: Severity::Warn, misconfigured_dependency_overrides: Severity::Error, }; - // Only unused_files present, but set to Warn — should not trigger assert!(!has_error_severity_issues(&results, &rules, None)); - // Promote unused_files to Error — should now trigger rules.unused_files = Severity::Error; assert!(has_error_severity_issues(&results, &rules, None)); } @@ -745,12 +722,9 @@ mod tests { unresolved_imports: Severity::Off, ..RulesConfig::default() }; - // Other fields are default (Error) but have no issues assert!(!has_error_severity_issues(&results, &rules, None)); } - // ── Override-aware tests ───────────────────────────────────── - /// Build a ResolvedConfig with overrides that turn off unused_exports for test files. fn config_with_test_override() -> ResolvedConfig { fallow_config::FallowConfig { @@ -869,7 +843,6 @@ mod tests { #[test] fn apply_rules_with_override_filters_matching_files() { let mut results = AnalysisResults::default(); - // Test file export — should be removed by override results .unused_exports .push(UnusedExportFinding::with_actions(UnusedExport { @@ -881,7 +854,6 @@ mod tests { span_start: 0, is_re_export: false, })); - // Non-test file export — should be preserved results .unused_exports .push(UnusedExportFinding::with_actions(UnusedExport { @@ -913,7 +885,6 @@ mod tests { let config = config_with_test_override(); apply_rules(&mut results, &config); - // Override only affects unused_exports, unused_files should be untouched assert_eq!(results.unused_files.len(), 1); } @@ -948,7 +919,6 @@ mod tests { #[test] fn has_error_with_override_per_file_resolution() { let mut results = AnalysisResults::default(); - // Only a test file has unused exports — override turns that off results .unused_exports .push(UnusedExportFinding::with_actions(UnusedExport { @@ -964,7 +934,6 @@ mod tests { let config = config_with_test_override(); let rules = &config.rules; - // With overrides: the test file's effective severity is Off, so no Error issues assert!( !has_error_severity_issues(&results, rules, Some(&config)), "test file override should suppress error" @@ -974,7 +943,6 @@ mod tests { #[test] fn has_error_with_override_non_matching_file_still_error() { let mut results = AnalysisResults::default(); - // Non-test file — override doesn't match, base rules (Error) apply results .unused_exports .push(UnusedExportFinding::with_actions(UnusedExport { @@ -1030,8 +998,6 @@ mod tests { ); } - // ── promote_warns_to_errors ───────────────────────────────────── - #[test] fn promote_warns_to_errors_promotes_all_warns() { let mut rules = RulesConfig { @@ -1113,7 +1079,6 @@ mod tests { }; promote_warns_to_errors(&mut rules); - // Off should remain Off assert_eq!(rules.unused_files, Severity::Off); assert_eq!(rules.unused_exports, Severity::Off); assert_eq!(rules.unused_types, Severity::Off); @@ -1127,7 +1092,6 @@ mod tests { let mut rules = RulesConfig::default(); // all Error promote_warns_to_errors(&mut rules); - // Error should remain Error assert_eq!(rules.unused_files, Severity::Error); assert_eq!(rules.unused_exports, Severity::Error); } @@ -1147,8 +1111,6 @@ mod tests { assert_eq!(rules.unused_types, Severity::Off); } - // ── has_error_severity_issues: non-file-scoped types ──────────── - #[test] fn has_error_circular_deps_detected() { let mut results = AnalysisResults::default(); @@ -1192,7 +1154,6 @@ mod tests { re_export_cycle: Severity::Warn, ..RulesConfig::default() }; - // No other issues, circular is Warn -> no error assert!(!has_error_severity_issues(&results, &rules, None)); } @@ -1211,7 +1172,6 @@ mod tests { }, )); let rules = RulesConfig::default(); - // unused_optional_dependencies defaults to Warn, so no error assert!(!has_error_severity_issues(&results, &rules, None)); } @@ -1249,7 +1209,6 @@ mod tests { }, )); let rules = RulesConfig::default(); - // type_only_dependencies defaults to Warn, not Error assert!(!has_error_severity_issues(&results, &rules, None)); } diff --git a/crates/cli/src/ci.rs b/crates/cli/src/ci.rs index 4d92b01af..bf4b44a22 100644 --- a/crates/cli/src/ci.rs +++ b/crates/cli/src/ci.rs @@ -336,10 +336,6 @@ fn load_github_state( .push(id); } } - // Only honour resolved-fingerprint markers when the comment was - // posted by a bot. A human commenter who pastes the marker into - // their own comment could otherwise trick the apply step into - // skipping a real "Resolved in ``" reply on a stale finding. if is_github_bot_comment(comment) && let Some(fingerprint) = extract_marker(body, "fallow-resolved-fingerprint:") { @@ -548,11 +544,6 @@ fn stage_github_operations( ) -> Vec { let mut operations = Vec::new(); for fingerprint in &plan.plan.stale { - // Idempotency: check the (fingerprint, sha) marker, not the bare - // fingerprint. Re-runs on the same commit must not post duplicate - // "Resolved in ``" replies; legacy markers without a SHA suffix - // still match on bare fingerprint to keep first-run-after-upgrade - // clean. let marker_key = resolved_marker_key(fingerprint, sha); let already_resolved = plan.state.github_resolved_markers.contains(&marker_key) || plan.state.github_resolved_markers.contains(fingerprint); @@ -756,10 +747,6 @@ fn load_gitlab_state( .or_default() .push(discussion_id.to_owned()); } - // Same authorship gate as GitHub: only honour resolved - // markers from bot-authored notes so a human cannot suppress - // legitimate "Resolved in ``" replies by impersonating the - // marker in their own comment. if is_gitlab_bot_note(note) && let Some(fingerprint) = extract_marker(body, "fallow-resolved-fingerprint:") { @@ -876,8 +863,6 @@ fn stage_gitlab_operations( ) -> Vec { let mut operations = Vec::new(); for fingerprint in &plan.plan.stale { - // Idempotency: same approach as GitHub apply. (fingerprint, sha) - // marker, with bare-fingerprint legacy fallback. let marker_key = resolved_marker_key(fingerprint, sha); let already_resolved = plan.state.gitlab_resolved_markers.contains(&marker_key) || plan.state.gitlab_resolved_markers.contains(fingerprint); @@ -1344,7 +1329,6 @@ mod tests { #[test] fn extracts_fingerprint_from_v2_marker() { - // v2 marker shape introduced in issue #528. assert_eq!( extract_fallow_fingerprint( "**error**\n\n" @@ -1352,7 +1336,6 @@ mod tests { .as_deref(), Some("abc1234567890def") ); - // merged: shape on hashed-composite merged comments. assert_eq!( extract_fallow_fingerprint( "**error**\n\n" @@ -1364,9 +1347,6 @@ mod tests { #[test] fn extract_fallow_fingerprint_falls_back_to_v1_shape() { - // v1 historical marker. Reconcile-review must still recognize it - // during the migration window so consumers can re-process backlogs - // posted by older fallow versions. assert_eq!( extract_fallow_fingerprint("**error**\n\n") .as_deref(), @@ -1377,14 +1357,6 @@ mod tests { #[test] fn extract_fallow_fingerprint_does_not_match_unrelated_body() { assert_eq!(extract_fallow_fingerprint("plain comment body"), None); - // A body that contains the literal "fallow-fingerprint:v2:" but no - // closing marker shape still returns the trimmed token, which is - // intentional: extract_marker is forgiving by design and the - // reconcile path treats any non-empty extraction as a potential - // match (consumers cross-check against the typed `fingerprint` - // field on their side to filter false positives). The dedicated - // anti-spoofing layer is `marker_regex` running on the consumer - // side, not this internal helper. assert_eq!( extract_fallow_fingerprint("fallow-fingerprint:v2: deadbeef").as_deref(), Some("deadbeef") @@ -1421,8 +1393,6 @@ mod tests { #[test] fn github_bot_check_rejects_human_user_type() { - // Critical security test: a human pasting a resolved-fingerprint - // marker into their own comment must not be honoured. let comment = serde_json::json!({ "user": { "type": "User", "login": "alice" }, "body": "", @@ -1436,12 +1406,10 @@ mod tests { let comment = serde_json::json!({ "user": { "type": "User", "login": "fallow-bot-account" }, }); - // SAFETY: tests run sequentially within the bin target. unsafe { std::env::set_var("FALLOW_BOT_LOGIN", "fallow-bot-account"); } assert!(is_github_bot_comment(&comment)); - // SAFETY: see above. unsafe { std::env::remove_var("FALLOW_BOT_LOGIN"); } @@ -1460,7 +1428,6 @@ mod tests { #[test] fn gitlab_bot_check_rejects_human_author() { - // Same security premise as GitHub. let human = serde_json::json!({ "system": false, "author": { "bot": false, "username": "alice" }, @@ -1480,8 +1447,6 @@ mod tests { #[test] fn compute_retry_wait_clamps_huge_retry_after() { - // A malicious or misconfigured server returning a day-long - // Retry-After must NOT strand the runner. let headers = headers_with_retry_after("86400"); assert_eq!( compute_retry_wait(&headers, 2, "GitHub"), @@ -1491,24 +1456,18 @@ mod tests { #[test] fn compute_retry_wait_clamps_zero_retry_after() { - // A zero Retry-After (no wait) is a server bug; floor at 1s so we - // don't tight-loop. let headers = headers_with_retry_after("0"); assert_eq!(compute_retry_wait(&headers, 5, "GitLab"), 1); } #[test] fn compute_retry_wait_falls_back_to_floor_for_http_date() { - // HTTP-date Retry-After values aren't parsed; we fall back to the - // floor with a stderr warning (asserted via the public delay value). let headers = headers_with_retry_after("Wed, 21 Oct 2026 07:28:00 GMT"); assert_eq!(compute_retry_wait(&headers, 7, "GitHub"), 7); } #[test] fn parse_retry_after_returns_none_for_http_date() { - // Per RFC 9110 the header may carry an HTTP-date; we don't parse - // those, the caller falls back to the floor delay. assert_eq!( parse_retry_after(&headers_with_retry_after("Wed, 21 Oct 2026 07:28:00 GMT")), None @@ -1517,10 +1476,6 @@ mod tests { #[test] fn should_retry_status_covers_429_and_transient_5xx() { - // 429 (rate-limit) and 502/503/504 (transient gateway errors) are the - // statuses both bash gh_api_retry / curl_retry helpers and this - // function retry on. Reverting the 5xx branch to 429-only would fail - // the 502/503/504 assertions. assert!(should_retry_status(429)); assert!(should_retry_status(502)); assert!(should_retry_status(503)); @@ -1529,9 +1484,6 @@ mod tests { #[test] fn should_retry_status_skips_persistent_5xx_and_4xx() { - // Persistent server faults (500, 501) and all 4xx other than 429 - // surface immediately so a real bug doesn't burn the full retry - // budget on the runner. assert!(!should_retry_status(500)); assert!(!should_retry_status(501)); assert!(!should_retry_status(505)); @@ -1545,9 +1497,6 @@ mod tests { #[test] fn resolved_marker_key_includes_short_sha() { - // (fingerprint, sha) marker keeps re-runs idempotent on the same - // commit while letting a force-push to a new SHA produce a fresh - // resolution comment. assert_eq!( resolved_marker_key("abc", Some("1234567890")), "abc@1234567" @@ -1563,9 +1512,6 @@ mod tests { fn resolved_body_includes_short_sha_and_per_sha_marker() { let body = resolved_body("abc", Some("1234567890")); assert!(body.contains("`1234567`")); - // Marker now encodes both fingerprint AND short SHA so re-runs on - // the same commit can detect prior posts; force-push to new SHA - // produces a new marker. assert!(body.contains("fallow-resolved-fingerprint: abc@1234567")); } } diff --git a/crates/cli/src/ci_template.rs b/crates/cli/src/ci_template.rs index 8e6d765c0..1d0b21aa6 100644 --- a/crates/cli/src/ci_template.rs +++ b/crates/cli/src/ci_template.rs @@ -8,11 +8,6 @@ struct VendoredFile { executable: bool, } -// `include_str!` paths must resolve inside the crates.io tarball, which only -// contains `crates/cli/`. In the source tree these template paths are symlinks -// to the canonical workspace `ci/` files, so contributors edit one source of -// truth. `cargo package` dereferences those symlinks into regular files, so the -// published crate still contains self-contained templates. const GITLAB_TEMPLATE: &str = include_str!("../templates/ci/gitlab-ci.yml"); const GITLAB_FILES: &[VendoredFile] = &[ @@ -140,9 +135,6 @@ mod tests { assert_eq!(std::fs::read_to_string(path).expect("read"), "custom"); } - // gitlab-ci.yml hardcodes the same filenames in `for f in ...` cp loops - // that GITLAB_FILES bundles via include_str!. Drift between the two only - // surfaces when a real GitLab pipeline runs against the vendored bundle. #[test] fn gitlab_ci_template_for_loops_match_vendored_files() { let prefixes = ["ci/scripts/"]; @@ -156,8 +148,6 @@ mod tests { continue; }; let filenames: Vec<&str> = spec.split_whitespace().collect(); - // Match the prefix used in the body of THIS loop by scanning the - // next handful of lines for the cp/curl path string. let prefix = lines .iter() .skip(idx + 1) diff --git a/crates/cli/src/codeowners.rs b/crates/cli/src/codeowners.rs index 0c4aa7758..5023bf229 100644 --- a/crates/cli/src/codeowners.rs +++ b/crates/cli/src/codeowners.rs @@ -132,9 +132,6 @@ impl CodeOwners { continue; } - // GitLab section header: `[Name]`, `^[Name]`, `[Name][N]`, optionally - // followed by section default owners. Update the running defaults - // and move on; section headers never produce a rule. if let Some((name, defaults)) = parse_section_header(line) { current_section = Some(name); current_section_owners = defaults; @@ -142,7 +139,6 @@ impl CodeOwners { continue; } - // GitLab exclusion pattern: `!path` clears ownership for matching files. let (negate, rest) = if let Some(after) = line.strip_prefix('!') { (true, after.trim_start()) } else { @@ -156,8 +152,6 @@ impl CodeOwners { let inline_owners = parts.collect::>(); let (effective_owner, owner_count): (&str, u32) = if negate { - // Negations clear ownership on match, so an owner token is - // irrelevant. GitLab doesn't require one anyway. ("", 0) } else if let Some(owner) = inline_owners.first() { ( @@ -170,7 +164,6 @@ impl CodeOwners { u32::try_from(current_section_owners.len()).unwrap_or(u32::MAX), ) } else { - // Pattern without owners and no section default, skip. continue; }; @@ -214,7 +207,6 @@ impl CodeOwners { /// GitLab-style exclusion (`!path`). pub fn owner_of(&self, relative_path: &Path) -> Option<&str> { let matches = self.globs.matches(relative_path); - // Last match wins: highest index = last rule in file order matches.iter().max().and_then(|&idx| { if self.is_negation[idx] { None @@ -354,7 +346,6 @@ fn parse_section_header(line: &str) -> Option<(String, Vec)> { } let mut after = &rest[close + 1..]; - // Optional `[N]` approval count. if let Some(inner) = after.strip_prefix('[') { let n_close = inner.find(']')?; let count = &inner[..n_close]; @@ -364,8 +355,6 @@ fn parse_section_header(line: &str) -> Option<(String, Vec)> { after = &inner[n_close + 1..]; } - // The remainder must be empty or start with whitespace. Otherwise this - // line isn't a section header, e.g. `[abc]def @owner` stays a rule. if !after.is_empty() && !after.starts_with(char::is_whitespace) { return None; } @@ -384,21 +373,18 @@ fn parse_section_header(line: &str) -> Option<(String, Vec)> { /// - No `/` in pattern: matches in any directory (`*.js` → `**/*.js`) /// - Contains `/` (non-trailing): root-relative as-is fn translate_pattern(pattern: &str) -> String { - // Strip leading `/` — globset matches from root by default let (anchored, rest) = if let Some(p) = pattern.strip_prefix('/') { (true, p) } else { (false, pattern) }; - // Trailing `/` means directory contents let expanded = if let Some(p) = rest.strip_suffix('/') { format!("{p}/**") } else { rest.to_string() }; - // If not anchored and no directory separator, match in any directory if !anchored && !expanded.contains('/') { format!("**/{expanded}") } else { @@ -412,9 +398,7 @@ fn translate_pattern(pattern: &str) -> String { /// For monorepo structures (`packages/auth/...`), returns `packages`. pub fn directory_group(relative_path: &Path) -> &str { let s = relative_path.to_str().unwrap_or(""); - // Use forward-slash normalized path let s = if s.contains('\\') { - // Windows paths: handled by caller normalizing, but be safe return s.split(['/', '\\']).next().unwrap_or(s); } else { s @@ -431,8 +415,6 @@ mod tests { use super::*; use std::path::PathBuf; - // ── translate_pattern ────────────────────────────────────────── - #[test] fn translate_bare_glob() { assert_eq!(translate_pattern("*.js"), "**/*.js"); @@ -460,7 +442,6 @@ mod tests { #[test] fn translate_double_star() { - // Pattern already contains `/`, so it's root-relative — no extra prefix assert_eq!(translate_pattern("**/test_*.py"), "**/test_*.py"); } @@ -469,8 +450,6 @@ mod tests { assert_eq!(translate_pattern("Makefile"), "**/Makefile"); } - // ── parse ────────────────────────────────────────────────────── - #[test] fn parse_simple_codeowners() { let content = "* @global-owner\n/src/ @frontend\n*.rs @rust-team\n"; @@ -506,8 +485,6 @@ mod tests { assert_eq!(co.owner_of(Path::new("anything.ts")), None); } - // ── owner_of ─────────────────────────────────────────────────── - #[test] fn owner_of_last_match_wins() { let content = "* @default\n/src/ @frontend\n"; @@ -556,7 +533,6 @@ mod tests { #[test] fn owner_of_specific_overrides_general() { - // Later, more specific rule wins let content = "\ * @default\n\ /src/ @frontend\n\ @@ -570,8 +546,6 @@ mod tests { assert_eq!(co.owner_of(Path::new("src/app.ts")), Some("@frontend")); } - // ── owner_and_rule_of ────────────────────────────────────────── - #[test] fn owner_and_rule_of_returns_owner_and_pattern() { let content = "* @default\n/src/ @frontend\n*.rs @rust-team\n"; @@ -597,8 +571,6 @@ mod tests { assert_eq!(co.owner_and_rule_of(Path::new("README.md")), None); } - // ── directory_group ──────────────────────────────────────────── - #[test] fn directory_group_simple() { assert_eq!(directory_group(Path::new("src/utils/index.ts")), "src"); @@ -617,8 +589,6 @@ mod tests { ); } - // ── discover ─────────────────────────────────────────────────── - #[test] fn discover_nonexistent_root() { let result = CodeOwners::discover(Path::new("/nonexistent/path")); @@ -628,8 +598,6 @@ mod tests { assert!(err.contains("--group-by directory")); } - // ── from_file ────────────────────────────────────────────────── - #[test] fn from_file_nonexistent() { let result = CodeOwners::from_file(Path::new("/nonexistent/CODEOWNERS")); @@ -638,7 +606,6 @@ mod tests { #[test] fn from_file_real_codeowners() { - // Use the project's own CODEOWNERS file let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() @@ -648,7 +615,6 @@ mod tests { let path = root.join(".github/CODEOWNERS"); if path.exists() { let co = CodeOwners::from_file(&path).unwrap(); - // Our CODEOWNERS has `* @bartwaardenburg` assert_eq!( co.owner_of(Path::new("src/anything.ts")), Some("@bartwaardenburg") @@ -656,8 +622,6 @@ mod tests { } } - // ── edge cases ───────────────────────────────────────────────── - #[test] fn email_owner() { let content = "*.js user@example.com\n"; @@ -672,11 +636,8 @@ mod tests { assert_eq!(co.owner_of(Path::new("app.ts")), Some("@org/frontend-team")); } - // ── GitLab section headers ───────────────────────────────────── - #[test] fn gitlab_section_header_skipped_as_rule() { - // Previously produced: `invalid CODEOWNERS pattern '[Section'`. let content = "[Section Name]\n*.ts @owner\n"; let co = CodeOwners::parse(content).unwrap(); assert_eq!(co.owners.len(), 1); @@ -745,8 +706,6 @@ mod tests { #[test] fn gitlab_section_defaults_reset_between_sections() { - // Section1 declares @team-a. Section2 declares no defaults. A bare - // pattern inside Section2 inherits nothing and is dropped. let content = "\ [Section1] @team-a\n\ foo/\n\ @@ -768,8 +727,6 @@ mod tests { #[test] fn gitlab_rules_before_first_section_retain_inline_owners() { - // Matches the reproduction in issue #127: rules before the first - // section header use their own inline owners. let content = "\ * @default-owner\n\ [Utilities] @utils-team\n\ @@ -785,7 +742,6 @@ mod tests { #[test] fn gitlab_issue_127_reproduction() { - // Verbatim CODEOWNERS from issue #127. let content = "\ # Default section (no header, rules before first section) * @default-owner @@ -808,8 +764,6 @@ src/components/ ); } - // ── GitLab exclusion patterns (negation) ─────────────────────── - #[test] fn gitlab_negation_last_match_clears_ownership() { let content = "\ @@ -823,7 +777,6 @@ src/components/ #[test] fn gitlab_negation_only_clears_when_last_match() { - // A more specific positive rule after the negation wins again. let content = "\ * @default\n\ !src/\n\ @@ -845,8 +798,6 @@ src/components/ assert_eq!(co.owner_and_rule_of(Path::new("src/vendor/lib.js")), None); } - // ── section header parser ────────────────────────────────────── - #[test] fn parse_section_header_variants() { assert_eq!( @@ -877,7 +828,6 @@ src/components/ #[test] fn parse_section_header_rejects_malformed() { - // Not a section header; should parse as a rule elsewhere. assert_eq!(parse_section_header("[unclosed"), None); assert_eq!(parse_section_header("[]"), None); assert_eq!(parse_section_header("[abc]def @owner"), None); @@ -885,8 +835,6 @@ src/components/ assert_eq!(parse_section_header("[Section][abc] @owner"), None); } - // ── section_of / section_and_owners_of / has_sections ───────── - #[test] fn has_sections_false_without_headers() { let co = CodeOwners::parse("* @default\n/src/ @frontend\n").unwrap(); @@ -920,7 +868,6 @@ src/components/ #[test] fn section_of_returns_some_none_for_pre_section_rule() { - // `* @default` sits before any section header. let content = "\ * @default\n\ [Billing] @billing-team\n\ @@ -975,8 +922,6 @@ src/components/ #[test] fn section_and_owners_of_same_owners_distinct_sections() { - // Issue #133: billing and notifications share @core-reviewers, but are - // distinct sections and must produce distinct groups. let content = "\ [billing] @core-reviewers @alice @bob\n\ src/billing/\n\ @@ -1028,8 +973,6 @@ src/components/ #[test] fn non_section_bracket_pattern_parses_as_rule() { - // `[abc]def` is not a section header (non-whitespace after `]`), - // so it falls through to regular glob parsing as a character class. let content = "[abc]def @owner\n"; let co = CodeOwners::parse(content).unwrap(); assert_eq!(co.owners.len(), 1); diff --git a/crates/cli/src/combined.rs b/crates/cli/src/combined.rs index 8d2bef12b..e7e0f224f 100644 --- a/crates/cli/src/combined.rs +++ b/crates/cli/src/combined.rs @@ -22,10 +22,6 @@ pub struct CombinedOptions<'a> { pub fail_on_issues: bool, pub sarif_file: Option<&'a std::path::Path>, pub changed_since: Option<&'a str>, - // `diff_file` was removed: the combined pipeline (and every - // subsystem it dispatches) reads the parsed diff index from the - // process-wide cache in `crate::report::ci::diff_filter::shared_diff_index()`, - // populated by `main()`. pub baseline: Option<&'a std::path::Path>, pub save_baseline: Option<&'a std::path::Path>, pub production: bool, @@ -77,8 +73,6 @@ pub fn run_combined(opts: &CombinedOptions<'_>) -> ExitCode { let mut dupes_result: Option = None; let mut health_result: Option = None; - // Build CheckOptions up front. References to `filters` and `trace_opts` need - // to outlive both the sequential and parallel branches below. let filters = IssueFilters::default(); let trace_opts = TraceOptions { trace_export: None, @@ -122,16 +116,6 @@ pub fn run_combined(opts: &CombinedOptions<'_>) -> ExitCode { None }; - // When both check and dupes are requested, run them concurrently. They share - // no mutable state: each writes to a distinct cache subdir (parse-vN vs - // dupes-tokens-vN), each returns a buffered result printed centrally below, - // and each sorts its own outputs internally so rayon's work-stealing order - // does not leak into the rendered output. - // - // Trade-off: the opportunistic share_files_with_dupes path (which let dupes - // skip discover_files when health was also running and production flags - // matched) is forfeited here. That saved ~8ms warm; the parallel join saves - // ~100ms by overlapping the dupes suffix array with check's analyze pass. if let (Some(check_opts), true) = (check_opts.as_ref(), opts.run_dupes) { let (check_res, dupes_res) = rayon::join( || crate::check::execute_check(check_opts), @@ -170,9 +154,6 @@ pub fn run_combined(opts: &CombinedOptions<'_>) -> ExitCode { report::print_performance(timings, opts.output); } - // Run health (complexity analysis) - // When check already ran, reuse its parsed modules (with complexity data) to avoid - // re-parsing all files. Saves ~1.9s on 21K-file projects like next.js. if opts.run_health { let health_opts = build_health_opts(opts); let check_production = opts.production_dead_code.unwrap_or(opts.production); @@ -217,9 +198,6 @@ pub fn run_combined(opts: &CombinedOptions<'_>) -> ExitCode { health_result.as_ref(), ); - // Best-effort: record this run into the local Impact store's whole-project - // track. No-op unless this is a genuine whole-project run (see the gate in - // `record_combined_impact`); never affects exit code or output. record_combined_impact( opts, check_result.as_ref(), @@ -251,9 +229,6 @@ fn is_whole_project_run(opts: &CombinedOptions<'_>) -> bool { && opts.production_dead_code != Some(true) && opts.production_health != Some(true) && opts.production_dupes != Some(true); - // MAINTENANCE: any NEW scope-narrowing knob added to `CombinedOptions` (a new - // subset/filter/scope flag) must be disqualified here too, or it silently - // pollutes the whole-project trend with a non-whole-project denominator. all_analyses && no_scope_narrowing && no_diff_filter && no_production } @@ -328,8 +303,6 @@ fn print_combined_report( health_result: Option<&HealthResult>, total_elapsed: std::time::Duration, ) -> Result { - // Build ownership resolver once for human/compact/markdown rendering. - // Structured formats (JSON/SARIF/CodeClimate) have their own envelope and skip grouping. let codeowners_cfg = check_result .map(|r| &r.config) .or_else(|| health_result.map(|r| &r.config)) @@ -434,7 +407,6 @@ fn print_human_sections( let mut max_exit: u8 = 0; let show_headers = matches!(opts.output, OutputFormat::Human) && !opts.quiet; - // Orientation header: vital signs + analysis scope + start-here nudge if show_headers { if let Some(result) = health_result { print_orientation_header(result, check_result, opts.root); @@ -510,9 +482,6 @@ fn print_human_sections( opts.summary, !show_headers, false, - // Combined-mode orientation header already rendered the score / - // trend; suppress here to avoid the duplicate `Health score:` line - // (issue #557). true, ); max_exit = max_exit.max(exit_code_to_u8(code)); @@ -530,7 +499,6 @@ fn handle_regression_and_summary( dupes_result: Option<&DupesResult>, health_result: Option<&HealthResult>, ) { - // Regression exit code (applies regardless of output format) if let Some(result) = check_result && let Some(ref outcome) = result.regression { @@ -542,7 +510,6 @@ fn handle_regression_and_summary( } } - // Summary on failure if *max_exit > 0 && !quiet { print_failure_summary(root, check_result, dupes_result, health_result); } @@ -584,13 +551,9 @@ fn print_failure_summary( } } if !parts.is_empty() { - // Repeat start-here nudge so it's visible at the bottom of scrolled output. - // Render workspace-relative paths (issue #547): bare basenames like - // `index.ts` are ambiguous in Nx / Angular / Rust-workspace layouts. let nudge = health_result .filter(|r| !r.report.targets.is_empty()) .map(|r| { - // Prefer non-test/fixture target; skip nudge if all targets are noise if let Some(top) = r.report.targets.iter().find(|t| !is_test_path(&t.path)) { let name = report::format_display_path(&top.path, root); format!(" \u{2014} start with {name}") @@ -617,12 +580,6 @@ fn print_combined_json( explain: bool, config_fixable: bool, ) -> ExitCode { - // Build the envelope shell as a typed `CombinedOutput`, then convert - // to a `serde_json::Value` so the remaining sub-result post-processing - // (path stripping, action injection, regression / baseline / - // baseline_deltas insertion) can continue to run as `Value` mutations. - // The sub-result Options stay `None` here and the per-pass branches - // below splice the populated values in. let envelope = crate::output_envelope::CombinedOutput { schema_version: fallow_types::envelope::SchemaVersion(crate::report::SCHEMA_VERSION), version: fallow_types::envelope::ToolVersion(env!("CARGO_PKG_VERSION").to_string()), @@ -766,7 +723,6 @@ fn print_combined_sarif( } } - // Duplication SARIF builder is pub(super) — serialize the report as a simple run if let Some(result) = dupes.filter(|r| !r.report.clone_groups.is_empty()) { let run = serde_json::json!({ "tool": { @@ -894,10 +850,6 @@ fn run_combined_dupes( no_cache: opts.no_cache, threads: opts.threads, quiet: opts.quiet, - // Combined mode has already resolved CLI overrides against - // `dupes_cfg`; pass each as an explicit `Some(...)` so - // `build_dupes_config` treats them as authoritative instead of - // re-merging with the toml values a second time. mode: Some( opts.dupes_mode .unwrap_or_else(|| DupesMode::from(dupes_cfg.mode)), @@ -925,9 +877,6 @@ fn run_combined_dupes( explain_skipped: opts.explain_skipped, summary: opts.summary, group_by: opts.group_by, - // Combined mode renders the bare-`fallow` pipeline panel which already - // shows the duplication stage; the standalone dupes panel is suppressed - // here to avoid double-printing. performance: false, }; @@ -1014,9 +963,6 @@ fn print_orientation_header( check: Option<&CheckResult>, root: &std::path::Path, ) { - // Health score + trend (combined-mode fix for issue #557). The helpers - // early-return when the score / trend is absent, so this is a no-op for - // bare `fallow` without `--score` or `--trend`. let mut score_lines: Vec = Vec::new(); report::render_health_score(&mut score_lines, &health.report); report::render_health_trend(&mut score_lines, &health.report); @@ -1025,7 +971,6 @@ fn print_orientation_header( eprintln!("{line}"); } - // Vital signs line (skip when trend table is active — it replaces vital signs) if let Some(ref vs) = health.report.vital_signs && health.report.health_trend.is_none() { @@ -1081,8 +1026,6 @@ fn print_orientation_header( )); } if !parts.is_empty() { - // The score / trend block above already emits a trailing blank - // line; skip the leading separator here to avoid a double blank. if !rendered_score { eprintln!(); } @@ -1095,7 +1038,6 @@ fn print_orientation_header( } } - // Analysis scope: file count + active plugins let files = health.report.summary.files_analyzed; let config = check.map_or(&health.config, |c| &c.config); let plugin_count = config.external_plugins.len(); @@ -1122,18 +1064,15 @@ fn print_orientation_header( eprintln!("{}", scope.dimmed()); } - // Entry-point detection summary if let Some(result) = check { print_entry_point_summary(&result.results); } - // "Start here" nudge: point to top refactoring target if !health.report.targets.is_empty() { let target_count = health.report.targets.len(); let total_issues = check.map_or(0, |c| c.results.total_issues()); if total_issues > 500 { - // Scale-aware: suggest scoping instead of a specific file eprintln!( "{}", format!( @@ -1143,9 +1082,6 @@ fn print_orientation_header( .dimmed() ); } else { - // Prefer non-test target; skip nudge if all targets are noise. - // Render workspace-relative paths (issue #547): bare basenames - // like `index.ts` are ambiguous in monorepo layouts. if let Some(top) = health .report .targets @@ -1179,7 +1115,6 @@ fn print_orientation_header( /// Check if a path is a test, fixture, or generated file that shouldn't be /// recommended as a refactoring starting point. fn is_test_path(path: &std::path::Path) -> bool { - // Check directory components for test/fixture/example directories if path.components().any(|c| { let s = c.as_os_str().to_string_lossy(); matches!( @@ -1207,7 +1142,6 @@ fn is_test_path(path: &std::path::Path) -> bool { }) { return true; } - // Check file name patterns if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if name.contains(".test.") || name.contains(".spec.") @@ -1219,7 +1153,6 @@ fn is_test_path(path: &std::path::Path) -> bool { { return true; } - // Generated file heuristic: single letter + digits (a0.js, b1.mjs) let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if stem.len() <= 3 && stem.starts_with(|c: char| c.is_ascii_lowercase()) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index a83f735c0..4a5a25892 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -44,12 +44,6 @@ pub fn run_config( match result { Ok(Some((config, path))) => { - // Mirror the contract the analysis path enforces: an invalid - // boundary configuration (unknown zone reference, redundant - // root-prefix) exits 2 at config load. Without this, `fallow config` - // happily prints a "loaded fine" view of a config that `fallow - // check` immediately rejects, producing a false signal during - // debug sessions. Surfaced by review of #468. if let Err(errors) = config.validate_resolved_boundaries(root) { let joined = errors .iter() @@ -76,7 +70,6 @@ pub fn run_config( if !path_only { println!("no config file found, using defaults"); } - // Empty stdout when --path is set; non-zero exit so scripts can detect. ExitCode::from(EXIT_NO_CONFIG) } Err(e) => emit_error(&e, 2, output), @@ -91,7 +84,6 @@ mod tests { fn run_config_no_file_returns_exit_3() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap(); - // No config file in the directory. let exit = run_config(dir.path(), None, false, OutputFormat::Human); assert_eq!( format!("{exit:?}"), @@ -134,8 +126,6 @@ mod tests { #[test] fn run_config_explicit_config_path_is_used_over_discovery() { - // Confirm `--config` overrides directory walk (the discovered config - // would be `discovered.json`, but we pass `explicit.json`). let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap(); let discovered = dir.path().join(".fallowrc.json"); @@ -152,17 +142,11 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("does-not-exist.json"); let exit = run_config(dir.path(), Some(&missing), false, OutputFormat::Human); - // Failure to load explicit config returns exit 2 (error), not exit 3 (no config). assert_eq!(format!("{exit:?}"), format!("{:?}", ExitCode::from(2))); } #[test] fn run_config_rejects_unknown_boundary_zone_reference() { - // The CLI's `fallow config` subcommand must enforce the same - // hard-error contract as the analysis paths: a typo'd zone in - // `boundaries.rules[]` exits 2 instead of printing a "loaded fine" - // view of a config that `fallow check` then rejects. Surfaced by - // review of #468. let dir = tempfile::tempdir().unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap(); std::fs::write( diff --git a/crates/cli/src/coverage/analyze.rs b/crates/cli/src/coverage/analyze.rs index 517e3f164..d9324c1a4 100644 --- a/crates/cli/src/coverage/analyze.rs +++ b/crates/cli/src/coverage/analyze.rs @@ -47,8 +47,6 @@ pub struct AnalyzeArgs { pub importance: bool, } -// Manual `Debug` so `CoverageSubcommand::Analyze` formatting cannot expose a -// CLI-provided API key through future trace/debug output. impl fmt::Debug for AnalyzeArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("AnalyzeArgs") @@ -184,8 +182,6 @@ fn run_local(path: &Path, args: &AnalyzeArgs, ctx: &RunContext<'_>) -> ExitCode min_severity: None, report_only: false, runtime_coverage: Some(runtime_coverage), - // `coverage analyze` is a focused runtime-only command; PR-scope - // line filtering belongs on `fallow audit` and `fallow health`. }) { Ok(result) => result, Err(code) => return code, @@ -434,8 +430,6 @@ fn build_index_from_analysis( && !unused_export_lines .get(*path) .is_some_and(|lines| lines.contains(&function.line)); - // Computed over the repo-relative `rel` so it agrees with the - // static-inventory producer's stable_id for the same function. let stable_id = function_identity_id(&rel, &function.name, function.line); let info = StaticFunctionInfo { path: PathBuf::from(&rel), @@ -824,10 +818,6 @@ fn cloud_warnings( }, }) .collect::>(); - // Only synthesize the empty-window warning if the server did not already - // emit one. The server's `no_runtime_data` message includes the projectId - // when present, so dedup-by-(code,message) cannot catch this case; the - // CLI defers to the server's variant unconditionally when both apply. let server_emitted_no_runtime_data = warnings .iter() .any(|warning| warning.code == "no_runtime_data"); @@ -900,9 +890,6 @@ fn match_cloud_function( function: &CloudRuntimeFunction, static_index: &StaticIndex, ) -> Option { - // Tier 1: cross-surface stable-id join. Strongest match, survives line - // moves. Only fires when the cloud has been migrated to emit `stableId` - // (fallow-cloud#63); `None` for the current cloud. if let Some(stable_id) = function.stable_id.as_deref() && let Some(info) = static_index.by_stable_id.get(stable_id) { @@ -910,17 +897,11 @@ fn match_cloud_function( } let path = normalize_runtime_path(Path::new(&function.file_path)); let line = function.start_line.or(function.line_number)?; - // Tier 2: exact (path, name, line). if let Some(info) = static_index .by_key .get(&(path.clone(), function.function_name.clone(), line)) { - // Diagnostic: the cloud carried a stable_id that did NOT join in tier 1 - // yet (path, name, line) did. This means the producer and consumer - // disagree on the identity hash (typically a path-space divergence). - // Surface it under RUST_LOG so the silent fuzzy-tier masking is - // field-observable instead of invisible. if let Some(stable_id) = function.stable_id.as_deref() && stable_id != info.stable_id { @@ -934,7 +915,6 @@ fn match_cloud_function( } return Some(info.clone()); } - // Tier 3: fuzzy nearest candidate within a line tolerance. static_index .by_path_name .get(&(path, function.function_name.clone())) @@ -1064,10 +1044,6 @@ fn print_runtime_json( use crate::output_envelope::{CoverageAnalyzeOutput, CoverageAnalyzeSchemaVersion}; use fallow_types::envelope::{ElapsedMs, ToolVersion}; - // Schema-derived constant: the schema-version enum has a single variant - // serialized as `"1"`; the legacy `RUNTIME_COVERAGE_SCHEMA_VERSION` - // constant is retained for the cloud client surface but the wire-shape - // source of truth is now the typed enum. debug_assert_eq!( RUNTIME_COVERAGE_SCHEMA_VERSION, "1", "the schema-version enum has one variant serialized as \"1\"; bump CoverageAnalyzeSchemaVersion if the constant moves" @@ -1357,11 +1333,6 @@ mod tests { #[test] fn cloud_warnings_dedupe_server_and_cli_no_runtime_data() { - // Empty window: server adds no_runtime_data; CLI's empty-summary - // branch must defer to the server's variant unconditionally so the - // user never sees the same code twice. Caught live against - // api.fallow.cloud during the v2.57.0 smoke (both --repo nonexistent - // and --project-id apps/dashboard returned duplicates). let snapshot = CloudRuntimeContext { repo: "nonexistent-repo".to_owned(), window: crate::coverage::cloud_client::CloudRuntimeWindow { period_days: 30 }, @@ -1399,10 +1370,6 @@ mod tests { #[test] fn cloud_warnings_dedupe_when_server_message_includes_project_id() { - // Regression: with --project-id set, the server's no_runtime_data - // message embeds the projectId ("... apps/dashboard in fallow-cloud - // ...") while the CLI's variant does not, so dedup-by-(code,message) - // does not catch the duplicate. Defer to code-only check. let snapshot = CloudRuntimeContext { repo: "fallow-cloud".to_owned(), window: crate::coverage::cloud_client::CloudRuntimeWindow { period_days: 30 }, @@ -1494,9 +1461,6 @@ mod tests { #[test] fn stable_runtime_id_emits_eight_hex_chars() { - // Schema regex: ^fallow:prod:[0-9a-f]{8}$. Local sidecar already - // emits 8 chars; cloud merge must match. Caught live during the - // v2.57.0 jsonschema validation pass against the published schema. let path = PathBuf::from("src/foo.ts"); let id = stable_runtime_id("prod", &path, "doThing", 42); let suffix = id diff --git a/crates/cli/src/coverage/cloud_client.rs b/crates/cli/src/coverage/cloud_client.rs index ba2529dd6..2789484f5 100644 --- a/crates/cli/src/coverage/cloud_client.rs +++ b/crates/cli/src/coverage/cloud_client.rs @@ -27,9 +27,6 @@ pub struct CloudRequest { pub commit_sha: Option, } -// Manual `Debug` so any future `tracing::debug!`, `dbg!`, or `unwrap`-on-Err -// with the `Debug` formatter does not leak the bearer token through -// stderr. The derive would do; explicit redaction is unmissable. impl fmt::Debug for CloudRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CloudRequest") @@ -420,10 +417,6 @@ mod tests { #[test] fn cloud_request_debug_masks_api_key() { - // Future `tracing::debug!(?req)`, `dbg!(req)`, or unwrap-on-Err with - // the Debug formatter would surface the api_key in stderr. Mask at - // the type level so the next contributor cannot reintroduce it by - // accident. let req = CloudRequest { api_key: "fallow_live_secret_token_value".to_owned(), api_endpoint: Some("https://api.fallow.cloud".to_owned()), @@ -442,17 +435,12 @@ mod tests { formatted.contains("api_key: \"***\""), "expected explicit redaction marker, got: {formatted}" ); - // Non-secret fields must still be inspectable so Debug stays useful. assert!(formatted.contains("repo: \"acme/web\"")); assert!(formatted.contains("period_days: 30")); } #[test] fn cloud_error_exit_code_for_validation_is_two() { - // Regression: HTTP 400 with code=validation_error must surface as - // CloudError::Validation (exit 2), not CloudError::Server (exit 7). - // Caught live against api.fallow.cloud during the v2.57.0 smoke when - // --environment was rejected with HTTP 400. assert_eq!(CloudError::Validation("any".to_owned()).exit_code(), 2); } } diff --git a/crates/cli/src/coverage/mod.rs b/crates/cli/src/coverage/mod.rs index 043e6a0a4..519a6c0bb 100644 --- a/crates/cli/src/coverage/mod.rs +++ b/crates/cli/src/coverage/mod.rs @@ -794,9 +794,6 @@ fn runtime_target_from_str(target: &str) -> crate::output_envelope::CoverageSetu use crate::output_envelope::CoverageSetupRuntimeTarget as T; match target { "browser" => T::Browser, - // Node is the conservative default; the upstream - // `FrameworkKind::runtime_targets()` only ever yields `"node"` or - // `"browser"`. _ => T::Node, } } @@ -2122,10 +2119,6 @@ mod tests { node_entry_path: "src/server.ts".to_owned(), }; let recipe = recipe_contents(&context); - // Without this line the trial user finishes setup, wires the beacon, - // and has no idea the dashboard's Untracked filter needs a second - // CI step. Regression test for BLOCK 2 from the public-readiness - // panel (2026-04-22). assert!( recipe.contains("fallow coverage upload-inventory"), "recipe missing upload-inventory CI instruction:\n{recipe}" diff --git a/crates/cli/src/coverage/upload_inventory.rs b/crates/cli/src/coverage/upload_inventory.rs index 68e16f06b..c318ae8b3 100644 --- a/crates/cli/src/coverage/upload_inventory.rs +++ b/crates/cli/src/coverage/upload_inventory.rs @@ -99,8 +99,6 @@ pub struct UploadInventoryArgs { pub ignore_upload_errors: bool, } -// Manual `Debug` so `tracing::debug!(?args)` / `dbg!(args)` / unwrap-on-Err -// formatting cannot bleed the API key into stderr. impl fmt::Debug for UploadInventoryArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("UploadInventoryArgs") @@ -159,10 +157,6 @@ impl UploadError { "error".red().bold() }; eprintln!("{LOG_PREFIX}: {severity}: {body}"); - // Validation, payload, and auth errors are always fatal; the user - // needs to fix their inputs or credentials. The - // --ignore-upload-errors opt-out only applies to transient transport - // and server failures. if soft_fail { eprintln!(" -> --ignore-upload-errors set, continuing with exit 0"); return ExitCode::SUCCESS; @@ -225,8 +219,6 @@ fn run_inner(args: &UploadInventoryArgs, root: &Path) -> Result<(), UploadError> ) } -// ── Project ID resolution ──────────────────────────────────────────── - fn resolve_project_id(args: &UploadInventoryArgs, root: &Path) -> Result { if let Some(explicit) = args.project_id.as_deref() { return validate_project_id(explicit.trim()).map(str::to_owned); @@ -292,13 +284,11 @@ fn git_origin_project_id(root: &Path) -> Option { /// (`git@github.com:owner/repo(.git)?`), and `ssh://` / `git://` variants. fn parse_git_remote_to_project_id(url: &str) -> Option { let stripped_suffix = url.trim().trim_end_matches(".git"); - // Shape 1: `git@host:owner/repo` if let Some((_, path)) = stripped_suffix.split_once(':') && let Some(project_id) = take_last_two_segments(path) { return Some(project_id); } - // Shape 2: `scheme://host/owner/repo` if let Some(path_part) = stripped_suffix.split("://").nth(1) && let Some((_, tail)) = path_part.split_once('/') && let Some(project_id) = take_last_two_segments(tail) @@ -318,8 +308,6 @@ fn take_last_two_segments(path: &str) -> Option { Some(format!("{owner}/{repo}")) } -// ── Git SHA resolution ─────────────────────────────────────────────── - fn resolve_git_sha(args: &UploadInventoryArgs, root: &Path) -> Result { let sha = if let Some(explicit) = args.git_sha.as_deref() { explicit.trim().to_owned() @@ -391,8 +379,6 @@ fn dirty_worktree(root: &Path) -> bool { output.stdout.iter().any(|b| !b.is_ascii_whitespace()) } -// ── Config + discovery ─────────────────────────────────────────────── - fn load_resolved_config(root: &Path) -> Result { let user_config = match FallowConfig::find_and_load(root) { Ok(Some((config, _path))) => Some(config), @@ -504,11 +490,6 @@ fn normalize_path_prefix(raw: Option<&str>) -> Result, UploadErro "--path-prefix '{trimmed}' contains backslashes. Use POSIX separators (forward slashes) even on Windows, because the runtime beacon emits POSIX paths." ))); } - // Leading slash requirement: runtime paths are always absolute inside - // containers (V8 reports `/app/src/*`, `/workspace/src/*`). A - // relative-looking prefix (`app`) would silently join to - // `app/src/foo.ts` and miss every runtime row. Keep the guard strict - // so typos surface immediately. if !trimmed.starts_with('/') { return Err(UploadError::Validation(format!( "--path-prefix '{trimmed}' must start with '/'. Runtime paths are absolute inside containers; a relative prefix won't match. Example: --path-prefix /app" @@ -518,9 +499,6 @@ fn normalize_path_prefix(raw: Option<&str>) -> Result, UploadErro } fn extension_supported(path: &Path) -> bool { - // Skip TypeScript declaration files. Their "functions" are ambient type - // signatures, not runtime code - including them would make every signature - // appear as `untracked` in the dashboard. if is_typescript_declaration(path) { return false; } @@ -553,13 +531,9 @@ fn exclude_matcher_matches(matcher: &GlobSet, rel_path: &Path) -> bool { } fn to_posix_string(path: &Path) -> String { - // Windows walker paths carry `\` separators; the server and the beacon - // both key on POSIX slashes, so normalize before sending. path.to_string_lossy().replace('\\', "/") } -// ── Payload + HTTP ─────────────────────────────────────────────────── - #[derive(Debug, Clone, Serialize)] struct InventoryFunction { #[serde(rename = "filePath")] @@ -592,11 +566,6 @@ impl InventoryFunction { start_column: Some(entry.start_column), end_line: Some(entry.end_line), end_column: Some(entry.end_column), - // Content digest of the function's full-span source slice, computed - // by the inventory walker. Optional cross-producer tiebreaker that - // lets runtime-coverage baselines survive line moves; excluded from - // `stable_id`. Resolution stays `Resolved` because byte-accurate - // UTF-16 columns satisfy that contract independently of `source_hash`. source_hash: Some(entry.source_hash.clone()), resolution: IdentityResolution::Resolved, stable_id, @@ -712,8 +681,6 @@ fn url_encode_path_segment(value: &str) -> String { /// rolls up lazy-parsed functions. fn print_overlap_warning_if_needed(overlap: &PathOverlap) { if overlap.sampled == 0 { - // No runtime data for this SHA yet. The success message already - // tells the user to wait for the beacon; don't add noise here. return; } if overlap.matched.saturating_mul(2) >= overlap.sampled { @@ -747,8 +714,6 @@ fn upload( payload: &InventoryRequest<'_>, ) -> Result<(), UploadError> { let url = endpoint_url(endpoint_override, project_id); - // Informational progress output goes to stdout alongside the dry-run - // summary for symmetry. Only errors and warnings use stderr. println!( "{LOG_PREFIX}: uploading {} functions for {project_id} @ {}", format_count(payload.functions.len()), @@ -778,11 +743,6 @@ fn upload( format_count(func_count), format_bytes(data.data.blob_size), ); - // Intentional wording: the Untracked filter needs BOTH the static - // inventory (this upload) AND runtime coverage from the beacon for - // the same SHA. Users who upload first on a new SHA will see a - // "waiting for runtime data" state; do not promise immediate results - // or the first-run UX looks broken. println!( " -> Inventory stored. The Untracked filter lights up once runtime coverage arrives for this SHA. Dashboard: https://fallow.cloud/{project_id}" ); @@ -792,10 +752,6 @@ fn upload( return Ok(()); } - // Parse the body once so we can dispatch by the machine-readable `code` - // field and also render a human-friendly message. We deliberately do NOT - // route through `http_status_message`; it collapses code + message into - // one formatted string, which forces callers to string-scan to classify. let body = response.read_to_string().unwrap_or_default(); let envelope = parse_error_envelope(&body); let code = envelope.code(); @@ -862,8 +818,6 @@ fn format_bytes(bytes: u64) -> String { } } -// ── Dry-run output ─────────────────────────────────────────────────── - fn print_dry_run_summary( project_id: &str, git_sha: &str, @@ -938,8 +892,6 @@ mod tests { #[test] fn upload_inventory_args_debug_masks_api_key() { - // Future `tracing::debug!(?args)` or `dbg!(args)` calls must not leak - // the bearer token through stderr. let args = UploadInventoryArgs { api_key: Some("fallow_live_secret_token_value".to_owned()), api_endpoint: Some("https://api.fallow.cloud".to_owned()), @@ -955,7 +907,6 @@ mod tests { formatted.contains("api_key: Some(\"***\")"), "expected explicit redaction marker, got: {formatted}" ); - // None case must remain distinguishable from "set but redacted". let bare = UploadInventoryArgs::default(); let formatted_bare = format!("{bare:?}"); assert!( @@ -998,9 +949,6 @@ mod tests { #[test] fn parse_git_remote_nested_group_uses_last_two_segments() { - // GitLab supports nested groups. Auto-detection keeps the familiar - // trailing `owner/repo` pair; repos that want the full namespace can - // pass --project-id explicitly. assert_eq!( parse_git_remote_to_project_id("https://gitlab.com/acme/team/widgets.git"), Some("team/widgets".to_owned()) @@ -1020,8 +968,6 @@ mod tests { #[test] fn validate_project_id_accepts_bare_name() { - // Dogfood projects use bare repo names (`fallow-cloud-api`), not - // `owner/repo`. Both shapes are legitimate on the server side. assert!(validate_project_id("fallow-cloud-api").is_ok()); } @@ -1199,8 +1145,6 @@ mod tests { #[test] fn extension_supported_still_accepts_non_declaration_ts() { - // Regression guard: the .d.ts skip must not accidentally reject - // files whose names contain ".d." but are not declarations. for name in ["vite.config.ts", "file.weird.d.name.ts"] { let path = PathBuf::from(name); assert!(extension_supported(&path), "{name} should still be walked"); @@ -1373,11 +1317,6 @@ mod tests { #[test] fn identity_stable_id_is_repo_relative_not_prefixed() { - // The legacy filePath carries the container prefix; the identity must - // be computed over the repo-relative path so it matches the protocol's - // project-root-relative contract AND fallow's own consumer-side - // identity. Hashing the prefixed path here would silently break the - // cross-surface join. let func = InventoryFunction::from_entry("/app/src/render.tsx", "src/render.tsx", sample_entry()); assert_eq!(func.file_path, "/app/src/render.tsx"); @@ -1390,8 +1329,6 @@ mod tests { #[test] fn identity_stable_id_unchanged_by_path_prefix() { - // With and without --path-prefix, the identity (and stable_id) stay - // pinned to the repo-relative path; only filePath moves. let with_prefix = InventoryFunction::from_entry("/app/src/render.tsx", "src/render.tsx", sample_entry()); let without_prefix = @@ -1406,8 +1343,6 @@ mod tests { #[test] fn identity_matches_protocol_conformance_fixture() { - // Pin the cross-producer join key to the protocol's anchor fixture so - // a divergent helper change is caught in fallow CI, not at join time. assert_eq!( function_identity_id("src/render.tsx", "render", 42), "fallow:fn:cb4482d6aef7c79a" diff --git a/crates/cli/src/coverage/upload_source_maps.rs b/crates/cli/src/coverage/upload_source_maps.rs index 8d2c3972b..b7a9e5e38 100644 --- a/crates/cli/src/coverage/upload_source_maps.rs +++ b/crates/cli/src/coverage/upload_source_maps.rs @@ -946,9 +946,6 @@ mod tests { #[test] fn map_path_is_repo_root_relative_when_build_dir_is_a_subdirectory() { - // CI runs `upload-source-maps --dir dashboard/dist` from the repo root, - // so the repo root is the parent of `dashboard/dist`. mapPath must carry - // the full `dashboard/dist/...` prefix even though fileName is stripped. let repo_root = tempdir().expect("tempdir"); let build_dir = repo_root.path().join("dashboard/dist"); std::fs::create_dir_all(build_dir.join("assets")).expect("assets dir"); @@ -960,10 +957,7 @@ mod tests { collect_source_maps(repo_root.path(), &build_dir, &include, &exclude, true).unwrap(); assert_eq!(maps.len(), 1); - // fileName is the stripped basename (storage identity), unchanged. assert_eq!(maps[0].file_name, "app-a1b2.js.map"); - // mapPath carries the repo-relative path so the cloud can resolve the - // map's `sources[]` against `dashboard/dist/assets`. assert_eq!( maps[0].map_path.as_deref(), Some("dashboard/dist/assets/app-a1b2.js.map") diff --git a/crates/cli/src/coverage/upload_static_findings.rs b/crates/cli/src/coverage/upload_static_findings.rs index 99b69db33..5248bb0fd 100644 --- a/crates/cli/src/coverage/upload_static_findings.rs +++ b/crates/cli/src/coverage/upload_static_findings.rs @@ -95,8 +95,7 @@ pub struct UploadStaticFindingsArgs { pub ignore_upload_errors: bool, } -// Manual `Debug` so `tracing::debug!(?args)` / `dbg!(args)` / unwrap-on-Err -// formatting cannot bleed the API key into stderr. +// Manual `Debug` to keep the API key out of stderr. impl fmt::Debug for UploadStaticFindingsArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("UploadStaticFindingsArgs") @@ -154,10 +153,6 @@ impl UploadError { "error".red().bold() }; eprintln!("{LOG_PREFIX}: {severity}: {body}"); - // Validation, payload, and auth errors are always fatal; the user - // needs to fix their inputs or credentials. The - // --ignore-upload-errors opt-out only applies to transient transport - // and server failures. if soft_fail { eprintln!(" -> --ignore-upload-errors set, continuing with exit 0"); return ExitCode::SUCCESS; @@ -180,11 +175,6 @@ fn run_inner(args: &UploadStaticFindingsArgs, root: &Path) -> Result<(), UploadE .map_err(|err| UploadError::Validation(format!("analysis failed: {err}")))?; let findings = collect_findings(&config, &results); - // An empty finding set is a valid replace-by-SHA clearing of the prior set - // (the codebase is clean, or the prior upload was for a different commit). - // Unlike upload-inventory, do NOT reject an empty result; let it through so - // a newly-clean repo clears stale findings on the dashboard. - if findings.len() > STATIC_FINDINGS_MAX { return Err(UploadError::PayloadTooLarge(format!( "static analysis produced {} findings, exceeds the server limit of {}. \ @@ -219,8 +209,6 @@ fn run_inner(args: &UploadStaticFindingsArgs, root: &Path) -> Result<(), UploadE ) } -// ── Project ID resolution ──────────────────────────────────────────── - fn resolve_project_id(args: &UploadStaticFindingsArgs, root: &Path) -> Result { if let Some(explicit) = args.project_id.as_deref() { return validate_project_id(explicit.trim()).map(str::to_owned); @@ -285,13 +273,11 @@ fn git_origin_project_id(root: &Path) -> Option { /// (`git@github.com:owner/repo(.git)?`), and `ssh://` / `git://` variants. fn parse_git_remote_to_project_id(url: &str) -> Option { let stripped_suffix = url.trim().trim_end_matches(".git"); - // Shape 1: `git@host:owner/repo` if let Some((_, path)) = stripped_suffix.split_once(':') && let Some(project_id) = take_last_two_segments(path) { return Some(project_id); } - // Shape 2: `scheme://host/owner/repo` if let Some(path_part) = stripped_suffix.split("://").nth(1) && let Some((_, tail)) = path_part.split_once('/') && let Some(project_id) = take_last_two_segments(tail) @@ -311,8 +297,6 @@ fn take_last_two_segments(path: &str) -> Option { Some(format!("{owner}/{repo}")) } -// ── Git SHA resolution ─────────────────────────────────────────────── - fn resolve_git_sha(args: &UploadStaticFindingsArgs, root: &Path) -> Result { let sha = if let Some(explicit) = args.git_sha.as_deref() { explicit.trim().to_owned() @@ -384,8 +368,6 @@ fn dirty_worktree(root: &Path) -> bool { output.stdout.iter().any(|b| !b.is_ascii_whitespace()) } -// ── Config + analysis ──────────────────────────────────────────────── - fn load_resolved_config(root: &Path) -> Result { let user_config = match FallowConfig::find_and_load(root) { Ok(Some((config, _path))) => Some(config), @@ -456,13 +438,9 @@ fn repo_relative_posix(config: &ResolvedConfig, path: &Path) -> String { } fn to_posix_string(path: &Path) -> String { - // Windows walker paths carry `\` separators; the server and the source-map - // resolver both key on POSIX slashes, so normalize before sending. path.to_string_lossy().replace('\\', "/") } -// ── Payload + HTTP ─────────────────────────────────────────────────── - #[derive(Debug, Clone, Serialize)] struct StaticFinding { kind: &'static str, @@ -552,8 +530,6 @@ fn upload( payload: &StaticFindingsRequest<'_>, ) -> Result<(), UploadError> { let url = endpoint_url(endpoint_override, project_id); - // Informational progress output goes to stdout alongside the dry-run - // summary for symmetry. Only errors and warnings use stderr. println!( "{LOG_PREFIX}: uploading {} findings for {project_id} @ {}", format_count(payload.findings.len()), @@ -588,8 +564,6 @@ fn upload( return Ok(()); } - // Parse the body once so we can dispatch by the machine-readable `code` - // field and also render a human-friendly message. let body = response.read_to_string().unwrap_or_default(); let envelope = parse_error_envelope(&body); let code = envelope.code(); @@ -639,8 +613,6 @@ fn format_count(n: usize) -> String { s } -// ── Dry-run output ─────────────────────────────────────────────────── - fn print_dry_run_summary( project_id: &str, git_sha: &str, @@ -698,8 +670,6 @@ fn display_endpoint_url(override_endpoint: Option<&str>, project_id: &str) -> St format!("{base}/v1/coverage/{project_id}/static-findings") } -// ── Analysis adapter ───────────────────────────────────────────────── - /// A minimal view over [`fallow_core::results::AnalysisResults`] that exposes /// only the two finding categories this command maps. Defined as a trait so /// the mapping in [`collect_findings`] can be unit-tested against an in-memory @@ -772,8 +742,6 @@ mod tests { #[test] fn upload_static_findings_args_debug_masks_api_key() { - // Future `tracing::debug!(?args)` or `dbg!(args)` calls must not leak - // the bearer token through stderr. let args = UploadStaticFindingsArgs { api_key: Some("fallow_live_secret_token_value".to_owned()), api_endpoint: Some("https://api.fallow.cloud".to_owned()), @@ -876,7 +844,6 @@ mod tests { let findings = collect_findings(&config, &results); assert_eq!(findings.len(), 2); - // Sorted by file_path: "src/legacy/old.ts" < "src/utils/format.ts". let dead = &findings[0]; assert_eq!(dead.kind, KIND_DEAD_FILE); assert_eq!(dead.file_path, "src/legacy/old.ts"); @@ -948,7 +915,6 @@ mod tests { let err = classify_upload_error(413, Some("payload_too_large"), "stub".to_owned()) .expect_err("413 must error"); assert!(matches!(err, UploadError::PayloadTooLarge(_))); - // Even without a body code, the status alone routes to PayloadTooLarge. let err = classify_upload_error(413, None, "stub".to_owned()) .expect_err("413 must error without code"); assert!(matches!(err, UploadError::PayloadTooLarge(_))); diff --git a/crates/cli/src/dupes.rs b/crates/cli/src/dupes.rs index 36badc41d..49514e15b 100644 --- a/crates/cli/src/dupes.rs +++ b/crates/cli/src/dupes.rs @@ -170,9 +170,6 @@ fn exceeds_threshold(threshold: f64, duplication_percentage: f64) -> bool { threshold > 0.0 && duplication_percentage > threshold } -// Changed-file filtering for duplication reports lives in -// `fallow_core::changed_files` so the LSP can reuse it. Re-export here under -// the existing local name so call sites in this crate stay readable. use fallow_core::changed_files::filter_duplication_by_changed_files as filter_by_changed_files; /// Filter a duplication report to only retain clone groups where at least one @@ -304,7 +301,6 @@ fn execute_dupes_inner( effective_changed_files, ); - // Handle trace (diagnostic mode, early return). if let Some(trace_spec) = opts.trace { return Err(run_clone_trace( &report, @@ -314,7 +310,6 @@ fn execute_dupes_inner( )); } - // Save baseline if let Some(path) = opts.save_baseline_path { let baseline_data = DuplicationBaselineData::from_report(&report, &config.root); match serde_json::to_string_pretty(&baseline_data) { @@ -350,7 +345,6 @@ fn execute_dupes_inner( } } - // Filter against baseline if let Some(path) = opts.baseline_path { match std::fs::read_to_string(path) { Ok(json) => match serde_json::from_str::(&json) { @@ -390,17 +384,10 @@ fn execute_dupes_inner( } } - // Filter to only changed files. Focused mode in `run_duplication_analysis` - // already prunes groups that don't touch a changed file when - // `effective_changed_files` is set; this pass is a safety net (no-op when - // the focused path was used). if let Some(changed) = effective_changed_files { filter_by_changed_files(&mut report, changed, &config.root); } - // Diff-line filtering (issue #424). CLI calls use the startup cache; - // programmatic/NAPI calls pass an explicit per-call index so concurrent - // requests cannot inherit another request's diff scope. if let Some(diff_index) = match opts.diff_index { Some(index) => Some(index), None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(), @@ -409,11 +396,6 @@ fn execute_dupes_inner( filter_by_diff(&mut report, diff_index, &config.root); } - // Workspace scoping (either --workspace or --changed-workspaces). - // Applied AFTER --changed-since so both can compose: in combined mode - // the user might pass --changed-workspaces origin/main (auto-derived - // workspace set) plus --changed-since origin/main (per-file filter - // within those workspaces). if let Some(ws_roots) = resolve_workspace_scope( opts.root, opts.workspace, @@ -423,10 +405,6 @@ fn execute_dupes_inner( filter_by_workspaces(&mut report, &ws_roots, &config.root); } - // Apply --top. - // Skip when --group-by is active: per-group stats must be computed over - // the full bucket (not a globally-truncated subset), and the human/JSON - // grouped renderers apply their own per-bucket caps at render time. if let Some(n) = opts.top && opts.group_by.is_none() { @@ -440,8 +418,6 @@ fn execute_dupes_inner( default_ignore_skips, config, elapsed, - // Use the merged threshold so the failure gate honors `.fallowrc.jsonc` - // when `--threshold` is omitted on the CLI. threshold: dupes_config.threshold, min_occurrences: dupes_config.min_occurrences, explain_skipped: opts.explain_skipped, @@ -496,11 +472,6 @@ fn apply_top(report: &mut DuplicationReport, n: usize, root: &std::path::Path) { &report.clone_families, root, ); - // Match `stats.clone_groups` and `stats.clone_instances` to the truncated - // array length so consumers iterating `clone_groups[]` see the same count - // as the stats block. `duplication_percentage`, `duplicated_lines`, and - // `duplicated_tokens` stay corpus-wide for trend-line stability (mirrors - // the minOccurrences split documented in `docs/output-schema.json`). report.stats.clone_groups = report.clone_groups.len(); report.stats.clone_instances = report.clone_groups.iter().map(|g| g.instances.len()).sum(); report.sort(); @@ -777,8 +748,6 @@ mod tests { use fallow_core::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats}; use std::path::{Path, PathBuf}; - // ── Helpers ────────────────────────────────────────────────────── - fn instance(file: &str, start: usize, end: usize) -> CloneInstance { CloneInstance { file: PathBuf::from(file), @@ -865,8 +834,6 @@ mod tests { } } - // ── parse_trace_spec ───────────────────────────────────────────── - #[test] fn parse_trace_spec_valid() { let (file, line) = parse_trace_spec("src/utils.ts:42").unwrap(); @@ -876,8 +843,6 @@ mod tests { #[test] fn parse_trace_spec_windows_path_with_drive() { - // The rsplit_once(':') should split on the LAST colon, so - // C:\path\file.ts:10 -> file = "C:\path\file.ts", line = 10 let (file, line) = parse_trace_spec("C:\\path\\file.ts:10").unwrap(); assert_eq!(file, "C:\\path\\file.ts"); assert_eq!(line, 10); @@ -900,7 +865,6 @@ mod tests { #[test] fn parse_trace_spec_negative_line() { - // "-1" cannot parse as usize, so it hits the catch-all error let err = parse_trace_spec("src/utils.ts:-1").unwrap_err(); assert!(err.contains("positive integer")); } @@ -913,7 +877,6 @@ mod tests { #[test] fn parse_trace_spec_empty_line() { - // "src/utils.ts:" -> line_str = "" let err = parse_trace_spec("src/utils.ts:").unwrap_err(); assert!(err.contains("positive integer")); } @@ -927,24 +890,18 @@ mod tests { #[test] fn parse_trace_spec_file_with_colons_in_path() { - // Edge case: file path contains colons (e.g., absolute path on Windows or unusual naming) - // rsplit_once splits at the LAST colon, so "a:b:c:10" -> ("a:b:c", "10") let (file, line) = parse_trace_spec("a:b:c:10").unwrap(); assert_eq!(file, "a:b:c"); assert_eq!(line, 10); } - // ── exceeds_threshold ──────────────────────────────────────────── - #[test] fn threshold_zero_never_fails() { - // When threshold is 0.0 (disabled), even 100% duplication should pass assert!(!exceeds_threshold(0.0, 100.0)); } #[test] fn threshold_negative_never_fails() { - // Negative threshold is nonsensical but should not trigger failure assert!(!exceeds_threshold(-1.0, 50.0)); } @@ -955,7 +912,6 @@ mod tests { #[test] fn threshold_exactly_at_boundary() { - // Duplication == threshold should NOT exceed (the condition is strict >) assert!(!exceeds_threshold(5.0, 5.0)); } @@ -974,14 +930,8 @@ mod tests { assert!(!exceeds_threshold(5.0, 0.0)); } - // ── apply_top ──────────────────────────────────────────────────── - #[test] fn apply_top_keeps_the_most_duplicated_groups() { - // Build 5 groups with decreasing instance counts. The path-sorted - // order (before --top) would have placed `a.ts` first; the - // instance-count-desc order should pick the 33-instance group - // first regardless of file name. let groups = vec![ make_group(vec![instance("z-most.ts", 1, 10); 33], 50, 10), make_group(vec![instance("y-mid.ts", 1, 10); 8], 50, 10), @@ -1016,7 +966,6 @@ mod tests { #[test] fn apply_top_tiebreaks_by_line_count_desc() { - // Same instance count, different line counts; larger lines wins. let groups = vec![ make_group(vec![instance("a.ts", 1, 10); 3], 50, 10), make_group(vec![instance("b.ts", 1, 60); 3], 200, 60), @@ -1037,11 +986,6 @@ mod tests { #[test] fn apply_top_recomputes_clone_groups_and_clone_instances_stats() { - // Build 4 groups; --top 1 must keep one and update the stats block so - // `stats.clone_groups == clone_groups.len()` and - // `stats.clone_instances == sum of surviving instances`. Without the - // recompute the JSON contract documented in docs/output-schema.json - // breaks: array length 1 but stats.clone_groups still reports 4. let groups = vec![ make_group(vec![instance("a.ts", 1, 10); 5], 50, 10), make_group(vec![instance("b.ts", 1, 10); 3], 50, 10), @@ -1070,8 +1014,6 @@ mod tests { ); } - // ── build_dupes_config ─────────────────────────────────────────── - #[test] fn build_config_maps_all_modes() { let root = PathBuf::from("/project"); @@ -1097,7 +1039,6 @@ mod tests { ..DuplicatesConfig::default() }; let config = build_dupes_config(&opts, &toml); - // The dupes command always enables duplication detection assert!(config.enabled); } @@ -1120,7 +1061,6 @@ mod tests { ..DuplicatesConfig::default() }; let config = build_dupes_config(&opts, &toml); - // OR semantics: toml.cross_language || opts.cross_language assert!(config.cross_language); } @@ -1195,11 +1135,6 @@ mod tests { assert!(config.skip_local); } - // ── Config-fallback tests ──────────────────────────────────────── - // These regression tests cover the bug where CLI scalars wiped out - // the values declared in `.fallowrc.jsonc`. With `Option` opts, - // a `None` must fall through to the toml value. - #[test] fn build_config_falls_back_to_toml_min_lines_when_cli_unset() { let root = PathBuf::from("/project"); @@ -1326,8 +1261,6 @@ mod tests { assert!(!config.ignore_imports); } - // ── DuplicationBaselineData integration ────────────────────────── - #[test] fn baseline_save_load_round_trip() { let root = Path::new("/project"); @@ -1342,7 +1275,6 @@ mod tests { let report = make_report(vec![group], 10, 1000); let baseline = DuplicationBaselineData::from_report(&report, root); - // Serialize and deserialize let json = serde_json::to_string_pretty(&baseline).unwrap(); let loaded: DuplicationBaselineData = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.clone_groups, baseline.clone_groups); @@ -1394,7 +1326,6 @@ mod tests { let report = make_report(vec![old_group, new_group], 10, 1000); let filtered = filter_new_clone_groups(report, &baseline, root); assert_eq!(filtered.clone_groups.len(), 1); - // The remaining group should be the new one (c.ts, d.ts) assert_eq!(filtered.clone_groups[0].instances.len(), 2); assert!( filtered.clone_groups[0] @@ -1404,8 +1335,6 @@ mod tests { ); } - // ── recompute_stats ────────────────────────────────────────────── - #[test] fn recompute_stats_empty_report() { let report = DuplicationReport::default(); @@ -1431,14 +1360,12 @@ mod tests { let stats = recompute_stats(&report); assert_eq!(stats.clone_groups, 1); assert_eq!(stats.clone_instances, 2); - // 5 lines in a.ts + 5 lines in b.ts = 10 duplicated lines assert_eq!(stats.duplicated_lines, 10); assert!((stats.duplication_percentage - 10.0).abs() < f64::EPSILON); } #[test] fn recompute_stats_deduplicates_overlapping_lines_in_same_file() { - // Two groups both mark lines 3-7 as cloned in the same file let group1 = make_group( vec![ instance("/project/src/a.ts", 1, 5), @@ -1458,9 +1385,6 @@ mod tests { let mut report = make_report(vec![group1, group2], 10, 100); report.stats.total_lines = 100; let stats = recompute_stats(&report); - // a.ts: lines 1-5 + lines 3-7 = lines 1-7 = 7 unique lines - // b.ts: lines 1-5 = 5 unique lines - // c.ts: lines 10-14 = 5 unique lines assert_eq!(stats.duplicated_lines, 17); assert_eq!(stats.files_with_clones, 3); } @@ -1495,22 +1419,14 @@ mod tests { report.stats.total_lines = 500; report.stats.total_tokens = 10000; let stats = recompute_stats(&report); - // Computed: 2 groups assert_eq!(stats.clone_groups, 2); - // Computed: 4 instances total assert_eq!(stats.clone_instances, 4); - // Computed: a.ts 10 + b.ts 10 + c.ts 6 + d.ts 6 = 32 duplicated lines assert_eq!(stats.duplicated_lines, 32); - // Computed: (50*2) + (30*2) = 160 duplicated tokens assert_eq!(stats.duplicated_tokens, 160); - // Computed: 4 unique files with clones assert_eq!(stats.files_with_clones, 4); - // Computed: 32/500 * 100 = 6.4% assert!((stats.duplication_percentage - 6.4).abs() < f64::EPSILON); } - // ── filter_by_changed_files ───────────────────────────────────── - #[test] fn filter_by_changed_files_retains_groups_with_at_least_one_changed_instance() { let group = make_group( @@ -1550,13 +1466,11 @@ mod tests { #[test] fn filter_by_changed_files_partial_group_retention() { - // Group 1: a.ts <-> b.ts (a.ts is changed) let group1 = make_group( vec![instance("src/a.ts", 1, 10), instance("src/b.ts", 1, 10)], 50, 10, ); - // Group 2: c.ts <-> d.ts (neither is changed) let group2 = make_group( vec![instance("src/c.ts", 1, 10), instance("src/d.ts", 1, 10)], 50, @@ -1569,7 +1483,6 @@ mod tests { filter_by_changed_files(&mut report, &changed, Path::new("")); assert_eq!(report.clone_groups.len(), 1); - // The retained group should be the one containing a.ts assert!( report.clone_groups[0] .instances @@ -1593,18 +1506,12 @@ mod tests { assert!(report.clone_groups.is_empty()); } - // ── filter_by_diff (issue #424) ───────────────────────────────── - fn build_diff(text: &str) -> crate::report::ci::diff_filter::DiffIndex { crate::report::ci::diff_filter::DiffIndex::from_unified_diff(text) } #[test] fn filter_by_diff_keeps_group_when_one_of_four_instances_overlaps() { - // Panel-guided shape: a clone group with 4 instances; only the - // first instance's [1..=10] overlaps the diff line at 5. The - // group MUST survive at the group level even though the other 3 - // instances are off-diff. let group = make_group( vec![ instance("src/a.ts", 1, 10), @@ -1661,8 +1568,6 @@ mod tests { #[test] fn filter_by_diff_drops_group_when_instance_path_matches_but_range_does_not() { - // Same file is in the diff, but the clone's [100..=110] doesn't - // overlap the touched line at 5. The group must drop. let group = make_group( vec![ instance("src/a.ts", 100, 110), @@ -1688,8 +1593,6 @@ mod tests { #[test] fn filter_by_diff_handles_long_instance_with_diff_in_middle() { - // Hotspot-shaped: a 200-line clone with the diff touching line - // 150 in the middle. Must overlap. let group = make_group( vec![ instance("src/big.ts", 50, 250), @@ -1713,8 +1616,6 @@ mod tests { assert_eq!(report.clone_groups.len(), 1); } - // ── filter_by_workspaces ──────────────────────────────────────── - #[test] fn filter_by_workspaces_retains_group_with_instance_under_any_root() { let group = make_group( @@ -1810,15 +1711,11 @@ mod tests { #[test] fn baseline_empty_json_object_uses_defaults() { - // An empty JSON object should deserialize with empty clone_groups - // (this tests that the format is forward-compatible) let result = serde_json::from_str::(r#"{"clone_groups": []}"#); assert!(result.is_ok()); assert!(result.unwrap().clone_groups.is_empty()); } - // ── Families rebuilt after filtering ────────────────────────────── - #[test] fn families_rebuilt_after_baseline_filter() { let root = Path::new("/project"); @@ -1839,22 +1736,17 @@ mod tests { 11, ); - // Baseline only knows about group1 let baseline_report = make_report(vec![group1.clone()], 10, 1000); let baseline = DuplicationBaselineData::from_report(&baseline_report, root); - // Full report has both groups let report = make_report(vec![group1, group2], 10, 1000); let filtered = filter_new_clone_groups(report, &baseline, root); - // Families should be rebuilt from the remaining group(s) assert_eq!(filtered.clone_groups.len(), 1); assert_eq!(filtered.clone_families.len(), 1); assert_eq!(filtered.clone_families[0].groups.len(), 1); } - // ── Stats after changed_since filter ───────────────────────────── - #[test] fn stats_recomputed_after_changed_since_filter() { let group = make_group( @@ -1872,19 +1764,15 @@ mod tests { filter_by_changed_files(&mut report, &changed, Path::new("")); - // All groups filtered out, stats should reflect that assert_eq!(report.stats.clone_groups, 0); assert_eq!(report.stats.clone_instances, 0); assert_eq!(report.stats.duplicated_lines, 0); assert!((report.stats.duplication_percentage - 0.0).abs() < f64::EPSILON); - // Pass-through fields are preserved from the original stats assert_eq!(report.stats.total_lines, 100); assert_eq!(report.stats.total_tokens, 5000); assert_eq!(report.stats.total_files, 10); } - // ── recompute_stats token counting ─────────────────────────────── - #[test] fn recompute_stats_counts_tokens_per_instance() { let group = make_group( @@ -1899,7 +1787,6 @@ mod tests { let mut report = make_report(vec![group], 10, 100); report.stats.total_lines = 100; let stats = recompute_stats(&report); - // 40 tokens * 3 instances = 120 assert_eq!(stats.duplicated_tokens, 120); } } diff --git a/crates/cli/src/explain.rs b/crates/cli/src/explain.rs index 46167964c..93c4a31eb 100644 --- a/crates/cli/src/explain.rs +++ b/crates/cli/src/explain.rs @@ -10,8 +10,6 @@ use colored::Colorize; use fallow_config::OutputFormat; use serde_json::{Value, json}; -// ── Docs base URL ──────────────────────────────────────────────── - const DOCS_BASE: &str = "https://docs.fallow.tools"; /// Docs URL for the dead-code (check) command. @@ -29,8 +27,6 @@ pub const COVERAGE_SETUP_DOCS: &str = "https://docs.fallow.tools/cli/coverage#ag /// Docs URL for `fallow coverage analyze --format json --explain`. pub const COVERAGE_ANALYZE_DOCS: &str = "https://docs.fallow.tools/cli/coverage#analyze"; -// ── Shared field definitions ──────────────────────────────────── - /// `_meta` description for the per-finding `actions[]` array shared across /// `check`, `health`, and `dupes` JSON output. const ACTIONS_FIELD_DEFINITION: &str = "Per-finding fix and suppression suggestions. Each entry carries a `type` discriminant (kebab-case) plus a per-action `auto_fixable` bool. Consumers dispatch on `type` to choose the remediation and filter on `auto_fixable` of each individual entry."; @@ -41,8 +37,6 @@ const ACTIONS_FIELD_DEFINITION: &str = "Per-finding fix and suppression suggesti /// finding's action, not on the action `type` alone. const ACTIONS_AUTO_FIXABLE_FIELD_DEFINITION: &str = "Evaluated PER FINDING, not per action type. The same `type` may carry `auto_fixable: true` on one finding and `auto_fixable: false` on another when per-instance guards in the `fallow fix` applier discriminate. Filter on this bool of each individual action, not on `type` alone. Current per-instance flips: (1) `remove-catalog-entry` is `true` only when the finding's `hardcoded_consumers` array is empty (else fallow fix skips the entry to avoid breaking `pnpm install`); (2) the primary dependency action flips between `remove-dependency` (`auto_fixable: true`) and `move-dependency` (`auto_fixable: false`) based on `used_in_workspaces`; (3) `add-to-config` for `ignoreExports` is `true` when fallow fix can safely apply the action, which means EITHER a fallow config file already exists OR no config exists and the working directory is NOT inside a monorepo subpackage (the applier then creates `.fallowrc.json` using `fallow init`'s framework-aware scaffolding and layers the new rules on top); `false` inside a monorepo subpackage with no workspace-root config because the applier refuses to fragment per-package configs; (4) `update-catalog-reference` is always `false` today (catalog-switching applier not yet wired). All `suppress-line` and `suppress-file` actions are uniformly `false`."; -// ── Check rules ───────────────────────────────────────────────── - /// Rule definition for SARIF `fullDescription` and JSON `_meta`. pub struct RuleDef { pub id: &'static str, @@ -581,8 +575,6 @@ fn print_explain_markdown(rule: &RuleDef, guide: &RuleGuide) -> ExitCode { ExitCode::SUCCESS } -// ── Health SARIF rules ────────────────────────────────────────── - pub const HEALTH_RULES: &[RuleDef] = &[ RuleDef { id: "fallow/high-cyclomatic-complexity", @@ -723,8 +715,6 @@ pub const DUPES_RULES: &[RuleDef] = &[RuleDef { docs_path: "explanations/duplication#clone-groups", }]; -// ── JSON _meta builders ───────────────────────────────────────── - /// Build the `_meta` object for `fallow dead-code --format json --explain`. #[must_use] pub fn check_meta() -> Value { @@ -1095,8 +1085,6 @@ pub fn coverage_analyze_meta() -> Value { mod tests { use super::*; - // ── rule_by_id ─────────────────────────────────────────────────── - #[test] fn rule_by_id_finds_check_rule() { let rule = rule_by_id("fallow/unused-file").unwrap(); @@ -1121,8 +1109,6 @@ mod tests { assert!(rule_by_id("").is_none()); } - // ── rule_docs_url ──────────────────────────────────────────────── - #[test] fn rule_docs_url_format() { let rule = rule_by_id("fallow/unused-export").unwrap(); @@ -1131,8 +1117,6 @@ mod tests { assert!(url.contains("unused-exports")); } - // ── CHECK_RULES completeness ───────────────────────────────────── - #[test] fn check_rules_all_have_fallow_prefix() { for rule in CHECK_RULES { @@ -1163,15 +1147,12 @@ mod tests { } } - // ── check_meta ─────────────────────────────────────────────────── - #[test] fn check_meta_has_docs_and_rules() { let meta = check_meta(); assert!(meta.get("docs").is_some()); assert!(meta.get("rules").is_some()); let rules = meta["rules"].as_object().unwrap(); - // Verify all 13 rule categories are present (stripped fallow/ prefix) assert_eq!(rules.len(), CHECK_RULES.len()); assert!(rules.contains_key("unused-file")); assert!(rules.contains_key("unused-export")); @@ -1241,8 +1222,6 @@ mod tests { } } - // ── health_meta ────────────────────────────────────────────────── - #[test] fn health_meta_has_metrics() { let meta = health_meta(); @@ -1256,8 +1235,6 @@ mod tests { assert!(metrics.contains_key("fan_out")); } - // ── dupes_meta ─────────────────────────────────────────────────── - #[test] fn dupes_meta_has_metrics() { let meta = dupes_meta(); @@ -1269,8 +1246,6 @@ mod tests { assert!(metrics.contains_key("clone_families")); } - // ── coverage_setup_meta ───────────────────────────────────────── - #[test] fn coverage_setup_meta_has_docs_fields_enums_and_warnings() { let meta = coverage_setup_meta(); @@ -1319,8 +1294,6 @@ mod tests { ); } - // ── coverage_analyze_meta ──────────────────────────────────────── - #[test] fn coverage_analyze_meta_documents_data_source_and_action_vocabulary() { let meta = coverage_analyze_meta(); @@ -1342,8 +1315,6 @@ mod tests { assert!(warnings.contains_key("cloud_functions_unmatched")); } - // ── HEALTH_RULES completeness ────────────────────────────────── - #[test] fn health_rules_all_have_fallow_prefix() { for rule in HEALTH_RULES { @@ -1387,8 +1358,6 @@ mod tests { } } - // ── DUPES_RULES completeness ─────────────────────────────────── - #[test] fn dupes_rules_all_have_fallow_prefix() { for rule in DUPES_RULES { @@ -1428,8 +1397,6 @@ mod tests { } } - // ── CHECK_RULES field completeness ───────────────────────────── - #[test] fn check_rules_all_have_non_empty_fields() { for rule in CHECK_RULES { @@ -1447,8 +1414,6 @@ mod tests { } } - // ── rule_docs_url with health/dupes rules ────────────────────── - #[test] fn rule_docs_url_health_rule() { let rule = rule_by_id("fallow/high-cyclomatic-complexity").unwrap(); @@ -1465,8 +1430,6 @@ mod tests { assert!(url.contains("duplication")); } - // ── health_meta metric structure ─────────────────────────────── - #[test] fn health_meta_all_metrics_have_name_and_description() { let meta = health_meta(); @@ -1528,8 +1491,6 @@ mod tests { } } - // ── dupes_meta metric structure ──────────────────────────────── - #[test] fn dupes_meta_all_metrics_have_name_and_description() { let meta = dupes_meta(); @@ -1553,8 +1514,6 @@ mod tests { assert!(metrics.contains_key("line_count")); } - // ── docs URLs ───────────────────────────────────────────────── - #[test] fn check_docs_url_valid() { assert!(CHECK_DOCS.starts_with("https://")); @@ -1573,8 +1532,6 @@ mod tests { assert!(DUPES_DOCS.contains("dupes")); } - // ── check_meta docs URL matches constant ────────────────────── - #[test] fn check_meta_docs_url_matches_constant() { let meta = check_meta(); @@ -1593,8 +1550,6 @@ mod tests { assert_eq!(meta["docs"].as_str().unwrap(), DUPES_DOCS); } - // ── rule_by_id finds all check rules ────────────────────────── - #[test] fn rule_by_id_finds_all_check_rules() { for rule in CHECK_RULES { @@ -1628,8 +1583,6 @@ mod tests { } } - // ── Rule count verification ─────────────────────────────────── - #[test] fn check_rules_count() { assert_eq!(CHECK_RULES.len(), 23); diff --git a/crates/cli/src/fix/catalog.rs b/crates/cli/src/fix/catalog.rs index 4de59c3fd..f4eaa72fa 100644 --- a/crates/cli/src/fix/catalog.rs +++ b/crates/cli/src/fix/catalog.rs @@ -56,9 +56,6 @@ pub(super) fn apply_catalog_entry_fixes( return summary; } - // All entries share the same file (`pnpm-workspace.yaml`), but we group - // defensively so we read+write the file once even if a future detector - // adds entries from multiple files. let mut by_path: rustc_hash::FxHashMap<&Path, Vec<&UnusedCatalogEntry>> = rustc_hash::FxHashMap::default(); for entry in entries { @@ -70,16 +67,9 @@ pub(super) fn apply_catalog_entry_fixes( let absolute = root.join(relative_path); let Some((content, meta)) = read_source_with_hash_check(root, &absolute, hashes, plan) else { - // Skip silently when the workspace file is unreadable or escapes - // the root: matches the existing pattern in enum_members/deps. - // Hash mismatch records itself on `plan.skipped()`; the - // orchestrator surfaces it. continue; }; - // Multi-document YAML defense (panel P1.6). The line scanner cannot - // reliably attribute lines to documents when `---` separators are - // present; refuse to edit and surface the skip. if is_multi_document_yaml(&content) { for entry in &file_entries { summary.skipped += 1; @@ -96,8 +86,6 @@ pub(super) fn apply_catalog_entry_fixes( let lines: Vec<&str> = content.split(meta.line_ending).collect(); - // Compute the line range for each entry and split into "remove" vs - // "skip" buckets. let mut to_remove: Vec<(std::ops::Range, &UnusedCatalogEntry)> = Vec::new(); for entry in &file_entries { if !entry.hardcoded_consumers.is_empty() { @@ -144,19 +132,12 @@ pub(super) fn apply_catalog_entry_fixes( continue; } - // Sort descending by start so removals don't shift later indices. - // Use end as a tiebreaker (longer-range first) so an overlapping - // pair is handled deterministically. to_remove.sort_by(|a, b| { b.0.start .cmp(&a.0.start) .then_with(|| b.0.end.cmp(&a.0.end)) }); - // Dedup overlapping ranges. With at most one entry per source line - // (the detector emits one finding per line), overlap should only - // occur on object-form entries where two findings somehow share a - // span. Keep the first (longer) range in each overlapping pair. let mut deduped: Vec<(std::ops::Range, &UnusedCatalogEntry)> = Vec::new(); for (range, entry) in to_remove { if let Some((last_range, _)) = deduped.last() @@ -185,18 +166,11 @@ pub(super) fn apply_catalog_entry_fixes( continue; } - // Track the parent header line for each deletion so we can detect - // when an entire catalog group becomes empty (e.g. removing the - // last entry from `catalogs.react17` leaves `react17:` with a - // null value, which pnpm rejects with "Cannot convert undefined - // or null to object" at install time). let parent_header_indices: Vec = deduped .iter() .filter_map(|(_, entry)| find_parent_header_line(&lines, entry)) .collect(); - // Apply: drain ranges from a fresh Vec, rewrite emptied - // parent headers to `key: {}`, validate by reparse, then atomic-write. let mut new_lines: Vec = lines.iter().map(ToString::to_string).collect(); for (range, _) in &deduped { new_lines.drain(range.clone()); @@ -208,11 +182,6 @@ pub(super) fn apply_catalog_entry_fixes( new_content.push_str(meta.line_ending); } - // Reparse-validate (panel P1.7). If the post-edit content fails to - // parse, abort the write rather than risk corrupting the file. We - // do not attempt a structural diff: any successful parse is a good - // enough signal here, because the failure modes the validator - // catches are syntactic (indent disasters, key-value disasters). if serde_yaml_ng::from_str::(&new_content).is_err() { summary.write_error = true; eprintln!( @@ -222,10 +191,6 @@ pub(super) fn apply_catalog_entry_fixes( continue; } - // Stage the post-edit YAML for the orchestrator's batch commit. - // Pre-stage YAML reparse-validation above ensures we never queue - // a syntactically broken document; rename-time errors are reported - // per-path by the orchestrator. plan.stage( absolute.clone(), super::io::bytes_with_optional_bom(new_content, &meta), @@ -233,8 +198,6 @@ pub(super) fn apply_catalog_entry_fixes( for (range, entry) in &deduped { let mut record = remove_record(entry, range, true, relative_path); - // Sidechannel so the orchestrator can flip `applied: false` - // post-commit if the rename for this absolute path fails. record["__target"] = serde_json::json!(absolute.display().to_string()); fixes.push(record); let entry_idx = entry.line.saturating_sub(1) as usize; @@ -435,10 +398,6 @@ fn comment_block_start( return None; } - // Per-block escape hatch (`# fallow-keep`): any line in the block bearing - // this marker preserves the entire block regardless of policy. Mirrors - // fallow's existing `fallow-ignore-next-line` / `fallow-ignore-file` - // inline-suppression convention so users discover it without docs. let block = &lines[comment_start..entry_idx]; if block.iter().any(|line| line.contains("fallow-keep")) { return None; @@ -448,11 +407,6 @@ fn comment_block_start( CatalogPrecedingCommentPolicy::Always => Some(comment_start), CatalogPrecedingCommentPolicy::Never => None, CatalogPrecedingCommentPolicy::Auto => { - // Section-banner heuristic: a comment line consisting of `#` - // followed by 3+ repeated separator characters (`=`, `-`, `*`, - // `_`, `~`, `+`, `#`) is treated as a curated banner that - // semantically owns the following section, not the next entry. - // Auto preserves the block when any line in it matches. if block.iter().any(|line| is_section_banner_line(line)) { return None; } @@ -518,10 +472,6 @@ fn find_parent_header_line(lines: &[&str], entry: &UnusedCatalogEntry) -> Option } let entry_indent = leading_spaces(lines[entry_line_idx]); - // Walk backwards from the entry line to find the first line at - // strictly lower indent. For default-catalog entries the parent - // must start with `catalog:`; for named-catalog entries the parent - // is the `:` line at an intermediate indent under `catalogs:`. for idx in (0..entry_line_idx).rev() { let line = lines[idx]; let stripped = line.trim_end(); @@ -536,7 +486,6 @@ fn find_parent_header_line(lines: &[&str], entry: &UnusedCatalogEntry) -> Option if entry.catalog_name == "default" { return content.starts_with("catalog:").then_some(idx); } - // Strip leading quotes for quoted-key catalog names. let key = content .trim_start_matches(['"', '\'']) .split([':', '"', '\'']) @@ -565,11 +514,6 @@ fn rewrite_empty_catalog_parents( parent_indices: &[usize], deleted_ranges: &[(std::ops::Range, &UnusedCatalogEntry)], ) { - // Dedup parents and map their pre-deletion indices into post-deletion - // indices. A parent header line itself is NEVER inside a deletion - // range (deletions cover entry lines plus their multi-line children, - // which all sit BELOW the parent), so the mapping is simply - // `new_idx = pre_idx - (lines deleted strictly before pre_idx)`. let mut unique_parents: Vec = parent_indices.to_vec(); unique_parents.sort_unstable(); unique_parents.dedup(); @@ -581,9 +525,6 @@ fn rewrite_empty_catalog_parents( if range.end <= parent_pre_idx { range.end - range.start } else if range.start <= parent_pre_idx { - // Parent inside a deletion range is impossible by - // construction (deletions start at entry lines, which - // are strictly below the parent). Skip defensively. 0 } else { 0 @@ -597,10 +538,6 @@ fn rewrite_empty_catalog_parents( if has_remaining_children(new_lines, new_idx) { continue; } - // Append ` {}` to the header line, preserving any trailing - // whitespace / line ending semantics. `new_lines` was produced - // by `content.split(line_ending)`, so trailing whitespace is - // already trimmed and the line ending is added back on join. let original = new_lines[new_idx].clone(); let trimmed_end = original.trim_end(); let trailing = &original[trimmed_end.len()..]; @@ -647,14 +584,7 @@ fn skip_record( entry .hardcoded_consumers .iter() - .map(|p| { - // Normalize separators to match the check-side - // `hardcoded_consumers` shape (which uses - // `serde_path::serialize_vec` doing `.replace('\\', "/")`) - // so agents correlating check + fix output see the - // same path strings on Windows. - serde_json::Value::String(p.to_string_lossy().replace('\\', "/")) - }) + .map(|p| serde_json::Value::String(p.to_string_lossy().replace('\\', "/"))) .collect(), )) } else { @@ -691,11 +621,6 @@ fn remove_record( "entry_name": entry.entry_name, "catalog_name": entry.catalog_name, "file": relative_path.to_string_lossy().replace('\\', "/"), - // `line` is the first deleted line (the leading comment block when - // `fix.catalog.deletePrecedingComments` absorbs one). `entry_line` - // is the catalog entry's original line so consumers that keyed on - // the entry position (CI annotators, dedup caches) keep a stable - // anchor. Both are 1-based. "line": range.start + 1, "entry_line": entry.line, "removed_lines": removed_lines, @@ -1064,8 +989,6 @@ mod tests { #[test] fn auto_preserves_block_with_fallow_keep_marker() { - // `# fallow-keep` on any line in the contiguous comment block - // protects the entire block from deletion regardless of policy. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n # fallow-keep: audit trail for CVE-2024-XXXX\n is-even: ^1.0.0\n is-odd: ^1.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1091,9 +1014,6 @@ mod tests { #[test] fn always_preserves_block_with_fallow_keep_marker() { - // `# fallow-keep` is a per-block escape hatch that overrides even - // the `always` policy. The marker is the user's explicit intent - // to keep this specific block. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n # fallow-keep\n is-even: ^1.0.0\n is-odd: ^1.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1115,10 +1035,6 @@ mod tests { #[test] fn auto_preserves_section_banner_block() { - // Section-banner comments (`# === React 18 production pins ===`, - // `# ----`, etc.) semantically own the following section, not - // the next entry. Auto must NOT delete them even when sitting - // directly under the parent header. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n # === React 18 production pins ===\n is-even: ^1.0.0\n is-odd: ^1.0.0\n"; @@ -1145,10 +1061,6 @@ mod tests { #[test] fn always_deletes_section_banner_block() { - // The `always` policy still deletes banner-shaped blocks. The - // banner heuristic is an Auto-only refinement; users who opt - // into `always` get aggressive deletion. To protect a banner - // under `always`, add a `# fallow-keep` marker. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n # ====\n is-even: ^1.0.0\n is-odd: ^1.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1335,7 +1247,6 @@ mod tests { serde_json::json!("multi_document_yaml") ); - // File must not have been modified. let result = std::fs::read_to_string(dir.path().join("pnpm-workspace.yaml")).unwrap(); assert_eq!(result, content); } @@ -1346,7 +1257,6 @@ mod tests { let content = "catalog:\n is-even: ^1.0.0\n"; seed_workspace_file(dir.path(), content); - // line 99 is way past EOF (file has 3 lines including trailing newline) let entries = vec![make_entry("is-even", "default", 99)]; let mut fixes = Vec::new(); let summary = run_catalog_entry_fix( @@ -1389,10 +1299,6 @@ mod tests { #[test] fn rewrites_emptied_default_catalog_to_empty_map() { - // Regression: pnpm rejects `catalog:\n` (null value) with - // "Cannot convert undefined or null to object". When the fix - // empties the default catalog, the header must be rewritten to - // `catalog: {}` so the file stays installable. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n is-even: ^1.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1422,9 +1328,6 @@ mod tests { #[test] fn rewrites_emptied_named_catalog_to_empty_map() { - // Regression: same as above for named catalogs. Reproduces the - // issue-329 fixture's `react17` group after removing both its - // entries. let dir = tempfile::tempdir().unwrap(); let content = "catalogs:\n react17:\n react: ^17.0.2\n react-dom: ^17.0.2\n legacy:\n is-odd: ^3.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1460,8 +1363,6 @@ mod tests { #[test] fn preserves_non_empty_sibling_named_catalogs() { - // When one named catalog is emptied but a sibling stays populated, - // only the emptied one gets the `{}` rewrite. let dir = tempfile::tempdir().unwrap(); let content = "catalogs:\n react17:\n react: ^17.0.2\n vue3:\n vue: ^3.4.0\n"; seed_workspace_file(dir.path(), content); @@ -1486,8 +1387,6 @@ mod tests { #[test] fn leaves_partially_populated_catalog_alone() { - // When only some entries of a catalog are removed and siblings - // remain, no `{}` rewrite is needed. let dir = tempfile::tempdir().unwrap(); let content = "catalog:\n is-odd: ^1.0.0\n is-even: ^1.0.0\n"; seed_workspace_file(dir.path(), content); @@ -1507,8 +1406,6 @@ mod tests { assert_eq!(result, "catalog:\n is-odd: ^1.0.0\n"); } - // -- compute_deletion_range unit tests ---------------------------------- - #[test] fn deletion_range_scalar_form_spans_one_line() { let lines: Vec<&str> = "catalog:\n is-even: ^1.0.0\n is-odd: ^1.0.0\n" @@ -1542,7 +1439,6 @@ mod tests { assert!(is_multi_document_yaml("foo: bar\n---\nbaz: qux\n")); assert!(is_multi_document_yaml("---\nfoo: bar\n")); assert!(!is_multi_document_yaml("catalog:\n is-even: ^1.0.0\n")); - // A `---` inside a quoted value or as a substring is not a separator. assert!(!is_multi_document_yaml("catalog:\n foo: \"---\"\n")); } } diff --git a/crates/cli/src/fix/config.rs b/crates/cli/src/fix/config.rs index 9d681096a..e319cda6a 100644 --- a/crates/cli/src/fix/config.rs +++ b/crates/cli/src/fix/config.rs @@ -294,10 +294,6 @@ fn emit_blocked_monorepo( fixes: &mut Vec, ) { let target_display = display_path(root, &root.join(".fallowrc.json")); - // The JSON field is the analysis-root-relative path (so CI logs and - // shipped JSON snippets don't leak absolute system paths from CI - // runners). The human stderr message keeps the absolute path so the - // user can paste it into `cd` directly without resolving `..` chains. let workspace_relative = display_workspace_path(root, workspace_root); if !matches!(output, OutputFormat::Json) { let absolute = workspace_root.display(); @@ -409,9 +405,6 @@ fn render_unified_diff(path_display: &str, current: &str, proposed: &str) -> Str let mut out = String::new(); let _ = writeln!(out, "--- {path_display} (current)"); let _ = writeln!(out, "+++ {path_display} (proposed)"); - // `similar`'s `unified_diff()` without `.header()` emits only the - // `@@` hunk markers and `+/-/space` content lines; we already wrote - // path-bearing headers above, so no library header is needed. let unified = diff.unified_diff().context_radius(3).to_string(); out.push_str(&unified); out @@ -608,7 +601,6 @@ mod tests { assert!(out.contains("+{")); assert!(out.contains("+ \"a\": 1")); assert!(out.contains("+}")); - // Every content line is prefixed; no spurious `-` lines. assert!(!out.contains("\n-")); } @@ -619,7 +611,6 @@ mod tests { let diff = render_unified_diff(".fallowrc.json", current, proposed); assert!(diff.contains("--- .fallowrc.json (current)")); assert!(diff.contains("+++ .fallowrc.json (proposed)")); - // Additions only; no `-` lines for the unchanged rules block. assert!( diff.lines() .any(|l| l.starts_with("+ { \"file\": \"src/a.ts\"")) @@ -733,8 +724,6 @@ mod tests { #[test] fn workspace_check_does_not_block_when_root_has_marker() { - // When the user invokes fallow at the workspace root itself, - // the create-fallback should fire there (not be blocked). let dir = tempfile::tempdir().unwrap(); let workspace = dir.path(); std::fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n").unwrap(); @@ -780,8 +769,6 @@ mod tests { fn apply_missing_config_creates_init_shape_file() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); - // Detect a TypeScript + Storybook + Vitest project so the seed - // includes framework-aware scaffolding instead of a thin shell. std::fs::write(root.join("tsconfig.json"), "{}").unwrap(); std::fs::create_dir_all(root.join(".storybook")).unwrap(); std::fs::write( @@ -883,8 +870,6 @@ mod tests { assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["skipped"], serde_json::json!(true)); assert_eq!(fixes[0]["skip_reason"], "monorepo_subpackage"); - // Relative `../..` from `packages/ui` up to `workspace` - // (two parent hops: `packages/ui` -> `packages` -> workspace). assert_eq!(fixes[0]["workspace_root"], "../.."); } @@ -914,8 +899,6 @@ mod tests { assert_eq!(fixes.len(), 1); let diff = fixes[0]["proposed_diff"].as_str().unwrap(); assert!(diff.contains("(current)") && diff.contains("(proposed)")); - // Comment must be preserved in the rendered proposal. - // (The diff context window shows surrounding lines.) assert!(diff.contains("ignoreExports")); } diff --git a/crates/cli/src/fix/deps.rs b/crates/cli/src/fix/deps.rs index 0d743c66c..791163c7d 100644 --- a/crates/cli/src/fix/deps.rs +++ b/crates/cli/src/fix/deps.rs @@ -6,18 +6,10 @@ use fallow_core::results::UnusedDependency; use super::plan::{CapturedHashes, FixPlan}; -/// Apply dependency fixes to package.json files (root and workspace), -/// returning JSON fix entries. +/// Apply dependency fixes to package.json files and return JSON fix entries. /// -/// Stages every per-file rewrite on `plan`; the orchestrator commits the -/// plan after all fixers run, so a single stage failure in any fixer -/// leaves the project untouched. `hashes` is accepted for signature -/// uniformity across fixers; package.json files are NOT in the captured -/// hash map (extract does not parse JSON), so the per-file hash check is -/// a no-op for the dep fixer. The dep modify path re-reads + reparses -/// each package.json before stage time, which is the natural safety net -/// for this file kind (key lookup is self-validating; missing keys are a -/// no-op fix). +/// `hashes` is accepted for signature uniformity; `package.json` files are +/// re-read and reparsed here, so the hash check is a no-op. pub(super) fn apply_dependency_fixes( root: &Path, results: &fallow_core::results::AnalysisResults, @@ -36,7 +28,6 @@ pub(super) fn apply_dependency_fixes( return; } - // Group all unused deps by their package.json path so we can batch edits per file let mut deps_by_pkg: FxHashMap<&Path, Vec<(&str, &str)>> = FxHashMap::default(); for dep in &results.unused_dependencies { queue_dependency_removal(&mut deps_by_pkg, &dep.dep, "dependencies"); @@ -95,11 +86,6 @@ pub(super) fn apply_dependency_fixes( plan.stage(pkg_path.to_path_buf(), pkg_content.into_bytes()); } Err(e) => { - // Serialization failure is rare: package.json was - // already parsed once into the same Value shape. - // Surface as a per-path failure entry so the - // orchestrator can flag it; we do NOT stage so - // the commit step never sees a half-built buffer. eprintln!("Error: failed to serialize {}: {e}", pkg_path.display()); for entry in fixes.iter_mut() { let matches = entry @@ -134,11 +120,6 @@ fn queue_dependency_removal<'a>( mod tests { use super::*; - /// Thin wrapper preserving the pre-#454 test API surface: builds a - /// FixPlan + CapturedHashes around `apply_dependency_fixes` and - /// commits, returning whether the commit produced any per-path - /// failure. Tests that assert no error path on the dry-run / no-op - /// case keep working unchanged. fn run_fix_deps( root: &Path, results: &fallow_core::results::AnalysisResults, @@ -178,7 +159,6 @@ mod tests { let mut fixes = Vec::new(); run_fix_deps(root, &results, OutputFormat::Json, true, &mut fixes); - // package.json should not change assert_eq!(std::fs::read_to_string(&pkg_path).unwrap(), original); assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["type"], "remove_dependency"); @@ -428,7 +408,6 @@ mod tests { let had_error = run_fix_deps(root, &results, OutputFormat::Human, false, &mut fixes); assert!(!had_error); - // No fix was applied (dep not found) assert!(fixes.is_empty()); } @@ -454,7 +433,6 @@ mod tests { let mut fixes = Vec::new(); run_fix_deps(root, &results, OutputFormat::Human, true, &mut fixes); - // File should not be modified assert_eq!(std::fs::read_to_string(&pkg_path).unwrap(), original); assert_eq!(fixes.len(), 1); assert!(fixes[0].get("applied").is_none()); @@ -481,7 +459,6 @@ mod tests { let mut fixes = Vec::new(); let had_error = run_fix_deps(root, &results, OutputFormat::Human, false, &mut fixes); - // Invalid JSON: the let-chain fails, so this path is just skipped assert!(!had_error); assert!(fixes.is_empty()); } @@ -533,7 +510,6 @@ mod tests { let had_error = run_fix_deps(root, &results, OutputFormat::Human, false, &mut fixes); assert!(!had_error); - // No dependencies section -> no fix assert!(fixes.is_empty()); } diff --git a/crates/cli/src/fix/enum_members.rs b/crates/cli/src/fix/enum_members.rs index 1f1ca05e5..774eace7c 100644 --- a/crates/cli/src/fix/enum_members.rs +++ b/crates/cli/src/fix/enum_members.rs @@ -136,7 +136,6 @@ pub(super) fn apply_enum_member_fixes( continue; } - // Safety check: the line should contain the member name let line = lines[line_idx]; if !line.contains(&member.member_name) { continue; @@ -216,16 +215,13 @@ pub(super) fn apply_enum_member_fixes( for fix in &member_fixes { if folded_parents.contains(fix.parent_name.as_str()) { - // Folded ranges are deleted as full blocks; skip per-member edits. continue; } let line = &new_lines[fix.line_idx]; if line.contains('{') && line.contains('}') { - // Single-line enum: remove the member token from the line let new_line = remove_member_from_single_line(line, &fix.member_name); new_lines[fix.line_idx] = new_line; } else { - // Multi-line enum: mark this line for removal new_lines[fix.line_idx] = String::new(); lines_to_delete.push(fix.line_idx); } @@ -243,8 +239,6 @@ pub(super) fn apply_enum_member_fixes( stage_fixed_content(plan, path, &new_lines, &meta, &content); - // Optimistic `applied: true`; orchestrator flips to false on - // commit failure for this target path via the __target sidechannel. let target = path.display().to_string(); for fix in &member_fixes { if folded_parents.contains(fix.parent_name.as_str()) { @@ -285,7 +279,6 @@ pub(super) fn apply_enum_member_fixes( /// /// Returns the modified line with the member removed and commas cleaned up. fn remove_member_from_single_line(line: &str, member_name: &str) -> String { - // Find the content between { and } let Some(open) = line.find('{') else { return line.to_string(); }; @@ -300,11 +293,8 @@ fn remove_member_from_single_line(line: &str, member_name: &str) -> String { let suffix = &line[close..]; let inner = &line[open + 1..close]; - // Split inner by comma to get individual member tokens let parts: Vec<&str> = inner.split(',').collect(); - // Filter out the part that matches the member name. - // A member part might be " Active", " Active = 'active'", etc. let filtered: Vec = parts .iter() .filter(|part| { @@ -312,7 +302,6 @@ fn remove_member_from_single_line(line: &str, member_name: &str) -> String { if trimmed.is_empty() { return false; } - // Extract just the identifier name (before any `=` sign) let ident = trimmed.split('=').next().unwrap_or(trimmed).trim(); ident != member_name }) @@ -320,10 +309,8 @@ fn remove_member_from_single_line(line: &str, member_name: &str) -> String { .collect(); if filtered.is_empty() { - // All members removed — leave empty enum body: `enum Foo {}` format!("{}{}", prefix.trim_end(), suffix.trim_start()) } else { - // Reconstruct with consistent formatting: `{ A, B }` let members_str = filtered.join(", "); format!("{prefix} {members_str} {suffix}") } @@ -640,7 +627,6 @@ mod tests { let original = "enum Status {\n Active,\n Inactive,\n}\n"; std::fs::write(&file, original).unwrap(); - // Point at line 2 (Active), but claim the member name is "Missing" let fixes = fix_single_member(root, &file, "Status", "Missing", 2, false); assert_eq!(std::fs::read_to_string(&file).unwrap(), original); @@ -668,7 +654,6 @@ mod tests { let file = root.join("status.ts"); std::fs::write(&file, "enum Status {\n Active,\n Inactive,\n}\n").unwrap(); - // Remove the last member fix_single_member(root, &file, "Status", "Inactive", 3, false); let content = std::fs::read_to_string(&file).unwrap(); @@ -692,8 +677,6 @@ mod tests { assert_eq!(content, "enum Priority {\n Low = 0,\n High = 2,\n}\n"); } - // ── remove_member_from_single_line unit tests ─────────────── - #[test] fn single_line_remove_first_member() { let result = remove_member_from_single_line("enum Foo { A, B, C }", "A"); @@ -732,7 +715,6 @@ mod tests { #[test] fn single_line_remove_two_members_sequentially() { - // Remove two members from a single-line enum via two separate fixes let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("status.ts"); @@ -812,25 +794,18 @@ mod tests { #[test] fn enum_fix_single_line_with_trailing_comma() { let result = remove_member_from_single_line("enum Foo { A, B, C, }", "B"); - // Trailing empty part from split should be filtered (empty after trim) assert_eq!(result, "enum Foo { A, C }"); } #[test] fn enum_fix_single_line_no_braces() { - // Edge case: no opening brace let result = remove_member_from_single_line("enum Foo A, B, C", "B"); assert_eq!(result, "enum Foo A, B, C"); } #[test] fn enum_fix_single_line_close_before_open() { - // Edge case: close brace before open brace let result = remove_member_from_single_line("} enum Foo { A }", "A"); - // rfind('}') finds the last one, find('{') finds the first one at position 13 - // But '}' at position 0 < '{' at position 13 is: open=13, close=15 - // So open >= close? No, 13 < 15. Actually this would work. - // Let's just verify it doesn't panic assert!(!result.is_empty()); } @@ -902,7 +877,6 @@ mod tests { let file = root.join("status.ts"); std::fs::write(&file, "enum Status { Active }\n").unwrap(); - // line=0 saturates to line_idx=0 let member = make_enum_member(&file, "Status", "Active", 0); let mut members_by_file: FxHashMap> = FxHashMap::default(); members_by_file.insert(file.clone(), vec![&member]); @@ -921,8 +895,6 @@ mod tests { ); let _ = plan.commit(); - // line_idx=0 points to "enum Status { Active }" which contains "Active" - // and has both { and }, so it's treated as single-line let content = std::fs::read_to_string(&file).unwrap(); assert_eq!(content, "enum Status {}\n"); } @@ -1105,13 +1077,11 @@ mod tests { &mut fixes, ); - // File is untouched on dry-run let content = std::fs::read_to_string(&file).unwrap(); assert_eq!(content, "export enum Status {\n Active,\n Inactive,\n}\n"); assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["type"], "remove_export"); assert_eq!(fixes[0]["name"], "Status"); - // Dry-run entries should NOT carry an applied key. assert!(fixes[0].get("applied").is_none()); } @@ -1141,7 +1111,6 @@ mod tests { ); let _ = plan.commit(); - // Non-exported enum: fold does not fire, members removed individually. let content = std::fs::read_to_string(&file).unwrap(); assert_eq!(content, "enum Status {\n}\n"); assert_eq!(fixes.len(), 2); diff --git a/crates/cli/src/fix/exports.rs b/crates/cli/src/fix/exports.rs index 327a6b931..9734ad7a7 100644 --- a/crates/cli/src/fix/exports.rs +++ b/crates/cli/src/fix/exports.rs @@ -79,7 +79,6 @@ pub(super) struct ExportFix { /// Check if a line (after stripping `export `) is a named export list like `{ A, B } ...` fn is_export_list(after_export: &str) -> bool { let s = after_export.trim_start(); - // `export type { ... }` also counts (handle any whitespace between `type` and `{`) let s = if let Some(rest) = s.strip_prefix("type") { rest.trim_start() } else { @@ -95,7 +94,6 @@ fn remove_specifiers_from_export_list(line: &str, names_to_remove: &[&str]) -> O let indent = line.len() - line.trim_start().len(); let trimmed = line.trim_start(); - // Determine if it's `export type { ... }` or `export { ... }` let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed); let (type_prefix, after_type) = if let Some(rest) = after_export.strip_prefix("type") { if rest.trim_start().starts_with('{') { @@ -107,20 +105,17 @@ fn remove_specifiers_from_export_list(line: &str, names_to_remove: &[&str]) -> O ("", after_export) }; - // Find the braces let brace_start = after_type.find('{')?; let brace_end = after_type.find('}')?; let inside = &after_type[brace_start + 1..brace_end]; let after_brace = &after_type[brace_end + 1..]; - // Parse specifiers (handle `A as B` aliases) let remaining: Vec<&str> = inside .split(',') .map(str::trim) .filter(|s| !s.is_empty()) .filter(|spec| { - // Extract the exported name (the original name, before `as`) let exported_name = if let Some((original, _alias)) = spec.split_once(" as ") { original.trim() } else { @@ -131,7 +126,6 @@ fn remove_specifiers_from_export_list(line: &str, names_to_remove: &[&str]) -> O .collect(); if remaining.is_empty() { - // All specifiers removed — delete the entire line None } else { let prefix = &line[..indent]; @@ -175,9 +169,6 @@ fn push_export_fix_json( }); if let Some(applied) = applied { value["applied"] = serde_json::json!(applied); - // Sidechannel: orchestrator reads __target to correlate the entry - // with the absolute path the FixPlan committed (or failed). The - // field is stripped before the JSON is serialized to stdout. value["__target"] = serde_json::json!(absolute.display().to_string()); } fixes.push(value); @@ -214,12 +205,6 @@ pub(super) fn apply_export_fixes( for (path, file_exports) in exports_by_file { let relative = path.strip_prefix(root).unwrap_or(path); - // Confidence gate (issue #602): withhold export removals in files - // whose consumers may be invisible to static analysis. Runs BEFORE - // the source read because the decision needs only the path; the - // skip is recorded on `plan` and surfaced by the orchestrator's - // `build_skipped_records`. Conservative by design: it can only - // decline a removal `fix` would otherwise do, never mutate wrongly. if let Some(reason) = low_confidence_skip_reason(relative, path, unresolved_import_files) { plan.skip(path.clone(), reason); continue; @@ -232,7 +217,6 @@ pub(super) fn apply_export_fixes( let mut line_fixes: Vec = Vec::new(); for export in file_exports { - // Use the 1-indexed line field from the export directly let line_idx = export.line.saturating_sub(1) as usize; if line_idx >= lines.len() { @@ -242,14 +226,12 @@ pub(super) fn apply_export_fixes( let line = lines[line_idx]; let trimmed = line.trim_start(); - // Skip lines that don't start with "export " if !trimmed.starts_with("export ") { continue; } let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed); - // Handle `export default` cases if after_export.starts_with("default ") { let after_default = after_export .strip_prefix("default ") @@ -259,13 +241,7 @@ pub(super) fn apply_export_fixes( || after_default.starts_with("class ") || after_default.starts_with("abstract class ") { - // `export default function Foo` -> `function Foo` - // `export default async function Foo` -> `async function Foo` - // `export default class Foo` -> `class Foo` - // `export default abstract class Foo` -> `abstract class Foo` - // handled below via line_fixes } else { - // `export default expression` -> skip (can't safely remove) continue; } } @@ -285,11 +261,8 @@ pub(super) fn apply_export_fixes( continue; } - // Sort by line index descending so we can work backwards without shifting indices line_fixes.sort_by_key(|f| std::cmp::Reverse(f.line_idx)); - // Group fixes by line_idx (multiple specifiers on the same `export { ... }` line) - // We no longer dedup — instead we collect all export names per line. let mut grouped: Vec<(usize, Vec)> = Vec::new(); for fix in &line_fixes { if let Some(last) = grouped.last_mut() @@ -309,7 +282,6 @@ pub(super) fn apply_export_fixes( push_export_fix_json(fixes, relative, path, fix, None); } } else { - // Apply all fixes to a single in-memory copy let mut new_lines: Vec = lines.iter().map(ToString::to_string).collect(); let mut lines_to_delete: Vec = Vec::new(); let mut ranges_to_delete: Vec = Vec::new(); @@ -328,12 +300,10 @@ pub(super) fn apply_export_fixes( let trimmed = line.trim_start(); let after_export = trimmed.strip_prefix("export ").unwrap_or(trimmed); - // Check if this is an `export { ... }` or `export type { ... }` line if is_export_list(after_export) { let name_refs: Vec<&str> = names.iter().map(String::as_str).collect(); match remove_specifiers_from_export_list(line, &name_refs) { None => { - // All specifiers removed — delete the entire line lines_to_delete.push(*line_idx); } Some(new_line) => { @@ -347,7 +317,6 @@ pub(super) fn apply_export_fixes( || after_export.starts_with("default class ") || after_export.starts_with("default abstract class ") { - // `export default function Foo` -> `function Foo` after_export .strip_prefix("default ") .unwrap_or(after_export) @@ -360,8 +329,6 @@ pub(super) fn apply_export_fixes( } } - // Delete all marked lines in descending order so earlier removals do - // not shift later source indices. let mut delete_indices = lines_to_delete; for range in ranges_to_delete { delete_indices.extend(range.start_line..=range.end_line); @@ -374,8 +341,6 @@ pub(super) fn apply_export_fixes( stage_fixed_content(plan, path, &new_lines, &meta, &content); - // Optimistic: queued for commit. Orchestrator flips `applied` - // to false post-commit if the rename failed for this path. for fix in &line_fixes { push_export_fix_json(fixes, relative, path, fix, Some(true)); } @@ -465,9 +430,7 @@ mod tests { let (_, fixes) = fix_single(root, &file, "foo", 1, true); - // File should not be modified assert_eq!(std::fs::read_to_string(&file).unwrap(), original); - // Fix should be reported assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["type"], "remove_export"); assert_eq!(fixes[0]["name"], "foo"); @@ -552,7 +515,6 @@ mod tests { let (_, fixes) = fix_single(root, &file, "default", 1, false); - // File unchanged — expression defaults are not safely removable assert_eq!(std::fs::read_to_string(&file).unwrap(), original); assert!(fixes.is_empty()); } @@ -615,7 +577,6 @@ mod tests { ); let _ = plan.commit(); - // File should be untouched and no fixes generated assert_eq!(std::fs::read_to_string(&outside_file).unwrap(), original); assert!(fixes.is_empty()); } @@ -680,10 +641,8 @@ mod tests { let file = root.join("short.ts"); std::fs::write(&file, "export function a() {}\n").unwrap(); - // Line 999 is way out of bounds let (_, fixes) = fix_single(root, &file, "ghost", 999, false); - // File unchanged, no fixes let content = std::fs::read_to_string(&file).unwrap(); assert_eq!(content, "export function a() {}\n"); assert!(fixes.is_empty()); @@ -845,7 +804,6 @@ mod tests { #[test] fn export_fix_deduplicates_same_line() { - // Two exports pointing to the same line should only apply one fix let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("dup.ts"); @@ -873,7 +831,6 @@ mod tests { let content = std::fs::read_to_string(&file).unwrap(); assert_eq!(content, "function foo() {}\n"); - // Both fixes are reported (same line, same name) assert_eq!(fixes.len(), 2); } @@ -892,7 +849,6 @@ mod tests { #[test] fn export_fix_line_zero_saturating_sub() { - // line=0 should saturate to 0 (line_idx = 0) let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("zero.ts"); @@ -944,7 +900,6 @@ mod tests { &mut fixes, ); - // File not modified assert_eq!(std::fs::read_to_string(&file).unwrap(), original); assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["type"], "remove_export"); @@ -953,7 +908,6 @@ mod tests { #[test] fn export_fix_skips_default_variable_export() { - // `export default someVariable;` should not be touched let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("config.ts"); @@ -1080,7 +1034,6 @@ mod tests { let file = root.join("index.ts"); std::fs::write(&file, "export { Foo as MyFoo, Bar } from \"./mod\";\n").unwrap(); - // The export name reported by fallow is the original name let (_, _) = fix_single(root, &file, "Foo", 1, false); let content = std::fs::read_to_string(&file).unwrap(); @@ -1104,15 +1057,12 @@ mod tests { assert_eq!(content, "export { Bar } from \"./bar\";\n"); } - // ── issue #602: low-confidence off-graph gate ── - #[test] fn is_off_graph_consumer_path_matches_every_listed_dir() { for dir in OFF_GRAPH_CONSUMER_DIRS { let p = PathBuf::from(format!("src/{dir}/file.ts")); assert!(is_off_graph_consumer_path(&p), "{dir} should match"); } - // Matches a directory at any depth. assert!(is_off_graph_consumer_path(Path::new( "packages/app/e2e/utils/helper.ts" ))); @@ -1123,7 +1073,6 @@ mod tests { #[test] fn is_off_graph_consumer_path_rejects_normal_and_excluded_dirs() { - // Component equality, not substring: `fixtured` is not `fixtures`. for p in [ "src/utils.ts", "src/components/App.tsx", @@ -1196,14 +1145,11 @@ mod tests { &mut fixes, ); - // No remove_export fix; one off-graph skip on the plan. Inspect the - // plan BEFORE `commit` consumes it. assert!(fixes.is_empty()); assert_eq!(plan.skipped().len(), 1); assert_eq!(plan.skipped()[0].reason, SkipReason::LowConfidenceOffGraph); let _ = plan.commit(); - // Source untouched. assert_eq!(std::fs::read_to_string(&file).unwrap(), original); } @@ -1218,7 +1164,6 @@ mod tests { let (_, fixes) = fix_single(root, &file, "client", 1, true); - // Dry-run never writes; the gate also produces no remove_export entry. assert_eq!(std::fs::read_to_string(&file).unwrap(), original); assert!(fixes.is_empty()); } @@ -1292,14 +1237,12 @@ mod tests { &mut fixes, ); - // One remove_export fix (the src file), one off-graph skip on the plan. assert_eq!(fixes.len(), 1); assert_eq!(fixes[0]["name"], "realDead"); assert_eq!(plan.skipped().len(), 1); assert_eq!(plan.skipped()[0].reason, SkipReason::LowConfidenceOffGraph); let _ = plan.commit(); - // Off-graph export kept; high-confidence src export removed. assert_eq!( std::fs::read_to_string(&e2e_file).unwrap(), "export function helper() {}\n" diff --git a/crates/cli/src/fix/io.rs b/crates/cli/src/fix/io.rs index 256175287..deb662f1a 100644 --- a/crates/cli/src/fix/io.rs +++ b/crates/cli/src/fix/io.rs @@ -62,9 +62,6 @@ pub(super) fn read_source( /// classifier on in-memory bytes without a disk round-trip. pub(super) fn classify_source(raw: &str) -> Result<(String, EncodingMetadata), EncodingError> { let had_bom = raw.starts_with('\u{FEFF}'); - // `strip_prefix` returns the original slice when the prefix is absent, so - // `unwrap_or(raw)` is identical to a branched `if had_bom { strip } else - // { raw }` without leaving an `.expect()` panic in production code. let content = raw.strip_prefix('\u{FEFF}').unwrap_or(raw).to_owned(); let crlf_count = content.matches("\r\n").count(); @@ -134,7 +131,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.ts"); atomic_write(&path, b"data").unwrap(); - // Only the target file should exist; no stray temp files let entries: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .filter_map(Result::ok) @@ -168,8 +164,6 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), data); } - // -- read_source tests --------------------------------------------------- - #[test] fn read_source_returns_none_for_path_outside_root() { let dir = tempfile::tempdir().unwrap(); @@ -227,18 +221,15 @@ mod tests { let (content, meta) = read_source(root, &file).unwrap().unwrap(); assert_eq!(content, ""); - assert_eq!(meta.line_ending, "\n"); // defaults to LF when no line endings found + assert_eq!(meta.line_ending, "\n"); assert!(!meta.had_bom); } - // -- BOM + mixed-EOL tests (issue #475) --------------------------------- - #[test] fn read_source_strips_utf8_bom_and_flags_metadata() { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("bom.ts"); - // EF BB BF + "export const x = 1;\nexport const y = 2;\n" std::fs::write(&file, "\u{FEFF}export const x = 1;\nexport const y = 2;\n").unwrap(); let (content, meta) = read_source(root, &file).unwrap().unwrap(); @@ -269,7 +260,6 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("mixed.ts"); - // CRLF on line 1, LF on lines 2 and 3. std::fs::write( &file, "export const a = 1;\r\nexport const b = 2;\nexport const c = 3;\r\n", @@ -290,9 +280,6 @@ mod tests { #[test] fn read_source_mixed_with_bom_is_still_mixed_after_strip() { - // The BOM strip happens first; mixed-EOL detection runs on the - // post-strip view. A BOM-bearing file with mixed line endings is - // still mixed and must surface the same error. let dir = tempfile::tempdir().unwrap(); let root = dir.path(); let file = root.join("bom-mixed.ts"); @@ -304,8 +291,6 @@ mod tests { #[test] fn classify_source_pure_lf_no_bom_round_trips() { - // Sanity check on the classifier directly: pure LF, no BOM is the - // happy path that pre-#475 code optimized for. let (content, meta) = classify_source("a\nb\nc\n").unwrap(); assert_eq!(content, "a\nb\nc\n"); assert_eq!(meta.line_ending, "\n"); @@ -318,8 +303,4 @@ mod tests { assert_eq!(meta.line_ending, "\n"); assert!(!meta.had_bom); } - - // The line-ending-preserving join logic that used to live in this - // module is now covered by plan.rs::stage_fixed_content + the - // per-fixer round-trip integration tests under crates/cli/tests/. } diff --git a/crates/cli/src/fix/mod.rs b/crates/cli/src/fix/mod.rs index 5f2a8895e..4223dfc2e 100644 --- a/crates/cli/src/fix/mod.rs +++ b/crates/cli/src/fix/mod.rs @@ -53,8 +53,6 @@ pub struct FixOptions<'a> { reason = "orchestrator threads results across 5 per-issue-type fixers + the post-#454 commit + envelope assembly; splitting harms locality of the wire-format authoring" )] pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { - // In non-TTY environments (CI, AI agents), require --yes or --dry-run - // to prevent accidental destructive operations. if !opts.dry_run && !opts.yes && !std::io::stdin().is_terminal() { let msg = "fix command requires --yes (or --force) in non-interactive environments. \ Use --dry-run to preview changes first, then pass --yes to confirm."; @@ -105,7 +103,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { let mut fixes: Vec = Vec::new(); let mut plan = FixPlan::new(); - // Group exports by file path so we can apply all fixes to a single in-memory copy. let mut exports_by_file: FxHashMap> = FxHashMap::default(); for finding in &results.unused_exports { @@ -115,11 +112,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { .push(&finding.export); } - // Files with at least one unresolved import have an incomplete local - // usage graph, so their "this export is unused" findings are lower - // confidence; the export fixer withholds removals there (issue #602). - // Paths are absolute (the detector stores them absolute; see the - // path-anchored-finding convention), matching `UnusedExport.path`. let unresolved_import_files: FxHashSet = results .unresolved_imports .iter() @@ -157,7 +149,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { &mut fixes, ); - // Group unused enum members by file path for batch editing. if !results.unused_enum_members.is_empty() { let mut enum_members_by_file: FxHashMap> = FxHashMap::default(); @@ -204,29 +195,14 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { let catalog_skipped = catalog_summary.skipped + empty_catalog_summary.skipped; let catalog_comment_lines_removed = catalog_summary.comment_lines_removed; - // Materialize hash-mismatch + mixed-EOL skip records on BOTH the dry-run - // and apply paths: the fixers' `read_source_with_hash_check` calls push - // to `plan.skipped()` regardless of dry_run, and the acceptance criterion - // is that dry-run surfaces both kinds of skips without writes. The skip - // records appear in the same `fixes` array the JSON renderer serializes, - // so consumers see one stream. let plan_skip_records = build_skipped_records(opts.root, plan.skipped(), opts.quiet); fixes.extend(plan_skip_records.iter().cloned()); - // A recoverable skip (hash mismatch / mixed line endings) means a fix - // the user asked for could not complete; it flips the non-zero exit - // code. Intentional skips (low-confidence export preservation, #602) do - // NOT, so classify from the enum here before `commit` consumes `plan`. let has_recoverable_skip = plan .skipped() .iter() .any(|skip| !skip.reason.is_intentional()); - // Commit the batched plan: stage every queued write, then promote. - // Stage failure leaves every target file at its original content; rename - // failure is reported per-path (the rename primitive is per-file atomic - // but there is no atomic multi-rename on POSIX). The dry-run path - // returns an empty outcome and bypasses commit entirely. let commit_outcome = if opts.dry_run { CommitOutcome::empty_for_dry_run() } else { @@ -235,8 +211,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { outcome }; - // Strip the __target sidechannel field before serialization. It is a - // correlation hint, not part of the public JSON contract. strip_target_sidechannel(&mut fixes); let content_changed_count = plan_skip_records @@ -251,11 +225,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { r.get("skip_reason").and_then(serde_json::Value::as_str) == Some("mixed_line_endings") }) .count(); - // Low-confidence export skips (issue #602) are INTENTIONAL: the export - // was deliberately preserved, so they do NOT flip `had_write_error` - // (unlike the two recoverable skips above). The combined counter sums - // both off-graph-directory and unresolved-import skips; the per-record - // `skip_reason` keeps the two distinguishable for agents. let low_confidence_count = plan_skip_records .iter() .filter(|r| { @@ -281,13 +250,6 @@ pub fn run_fix(opts: &FixOptions<'_>) -> ExitCode { .unwrap_or(false) }) .count(); - // The legacy `skipped` counter pre-dates #454 and meant "catalog / - // YAML fix skipped due to consumer / multi-doc / line-out-of-range - // guard". Hash-mismatch + mixed-EOL skips carry the same - // `skipped: true` flag for consumer convenience but are counted - // separately via `skipped_content_changed` / - // `skipped_mixed_line_endings`; exclude them here so the existing - // counter keeps its prior meaning. let skipped_count = fixes .iter() .filter(|f| { @@ -509,8 +471,6 @@ fn emit_human_summary( ); } if low_confidence_count > 0 { - // `low_confidence_count` of 1 is the common case (a single mock / - // e2e file), so pluralization matters here. let files_word = if low_confidence_count == 1 { "file" } else { diff --git a/crates/cli/src/fix/plan.rs b/crates/cli/src/fix/plan.rs index 1e9f54d3a..c99cabca1 100644 --- a/crates/cli/src/fix/plan.rs +++ b/crates/cli/src/fix/plan.rs @@ -1,26 +1,14 @@ //! Batch-atomicity layer for `fallow fix`. //! -//! Each per-issue-type fixer (`exports`, `enum_members`, `deps`, `catalog`) -//! accumulates `(PathBuf, Vec)` entries on a shared [`FixPlan`] instead -//! of writing directly. After all fixers run, the orchestrator commits the -//! plan: every entry is first staged to a `NamedTempFile` in the same -//! directory as the target, and only when every stage has succeeded does -//! the commit promote each temp to its final path via the existing atomic -//! rename. A failure at the stage step leaves the project untouched. A -//! failure at the rename step is reported per-path; some renames may have -//! already landed (POSIX rename is per-file atomic; there is no atomic -//! multi-rename primitive). -//! -//! The plan also carries skipped-file records (e.g. hash mismatch between -//! the in-process analysis and the on-disk content at fix time); the -//! orchestrator surfaces these in the JSON envelope and non-zero exit code. +//! Fixers stage writes into a shared [`FixPlan`], then the orchestrator +//! commits them atomically per file and records any skips. use std::path::{Path, PathBuf}; use rustc_hash::{FxHashMap, FxHashSet}; use tempfile::NamedTempFile; -/// One file's content waiting to be written. +/// One file's staged content. struct PlannedWrite { path: PathBuf, content: Vec, @@ -29,35 +17,13 @@ struct PlannedWrite { /// Why a file was skipped during a fix run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum SkipReason { - /// The file's content hash at fix time differs from the hash captured - /// during the in-process analysis. Applying offsets computed against - /// the analyzed bytes would land them on the wrong source. + /// The file changed after analysis, so the offsets are stale. ContentChanged, - /// The file mixes CRLF and bare-LF line endings. The fix pipeline - /// detects line endings by presence check then splits / joins on the - /// detected style; on a mixed file that would silently rewrite to the - /// wrong offsets. The skip is NOT self-healing: re-running `fallow fix` - /// does not clear it. The user (or agent) must normalize the file (e.g. - /// `dos2unix `, `git config core.autocrlf input`, prettier with - /// `endOfLine: lf`) before re-running. Issue #475. + /// The file mixes CRLF and LF line endings. MixedLineEndings, - /// The file lives in a directory whose consumers are commonly invisible - /// to static analysis (mocks, e2e suites, fixture / golden harnesses). - /// Removing the `export` keyword here risks breaking a real consumer - /// fallow's graph cannot see (Vitest mock aliases, off-workspace e2e - /// trees, generated/fixture build steps). Unlike the two skips above, - /// this is an INTENTIONAL conservative decision, not a recoverable - /// failure: it does NOT contribute to a non-zero exit code, and - /// re-running `fallow fix` will keep skipping. The export is still - /// reported by `fallow check`; the user removes it by hand if they have - /// confirmed it is truly unused. Issue #602. + /// Conservative skip for files whose consumers may be off-graph. LowConfidenceOffGraph, - /// The file itself has at least one import fallow could not resolve, so - /// its local usage graph is incomplete and any "this export is unused" - /// conclusion is lower confidence. Same intentional-skip semantics as - /// [`Self::LowConfidenceOffGraph`] (exit code unaffected, not - /// self-healing). The user resolves the import or removes the export by - /// hand. Issue #602. + /// Conservative skip for files with unresolved imports. LowConfidenceUnresolvedImports, } @@ -71,10 +37,7 @@ impl SkipReason { } } - /// True for skips that represent an intentional conservative decision - /// rather than a recoverable failure. These do NOT contribute to a - /// non-zero exit code (the user did not ask for and cannot fix a - /// "failure"; the export is deliberately preserved). Issue #602. + /// True for conservative skips rather than recoverable failures. pub(super) fn is_intentional(self) -> bool { matches!( self, @@ -89,15 +52,15 @@ impl SkipReason { path.display(), ), Self::MixedLineEndings => format!( - "Skipping {}: file has mixed CRLF/LF line endings. Normalize with `dos2unix` or set `git config core.autocrlf input`, then re-run `fallow fix`.", + "Skipping {}: file has mixed CRLF/LF line endings. Normalize it, then re-run `fallow fix`.", path.display(), ), Self::LowConfidenceOffGraph => format!( - "Kept unused export(s) in {}: this file is in a test, mock, or fixture directory whose consumers (Vitest mock aliases, e2e suites, fixture harnesses) may import it in ways fallow cannot see. Still listed by `fallow check`; remove by hand if you have confirmed it is unused.", + "Kept unused export(s) in {}: consumer coverage is incomplete, so the export was preserved.", path.display(), ), Self::LowConfidenceUnresolvedImports => format!( - "Kept unused export(s) in {}: this file has imports fallow could not resolve, so its usage may be incomplete. Still listed by `fallow check`; resolve the imports or remove the export by hand.", + "Kept unused export(s) in {}: unresolved imports make the usage graph incomplete.", path.display(), ), } @@ -112,18 +75,13 @@ pub(super) struct SkippedFile { /// Outcome of [`FixPlan::commit`]. pub(super) struct CommitOutcome { - /// Absolute paths whose new content landed on disk. Held for - /// observability and post-commit verification by integration tests; - /// the orchestrator only inspects `failed` (every fixer sets - /// `applied: true` optimistically before commit, then we flip to - /// false on failure via the `__target` sidechannel). + /// Absolute paths whose new content landed on disk. #[allow( dead_code, reason = "test-only reader; `#[expect]` is unfulfilled under `--all-targets` because the test cfg satisfies dead_code while the lib cfg would fire it" )] pub written: FxHashSet, - /// Per-path errors. `failed.is_empty() && written == plan.entries` is - /// the success case. + /// Per-path errors. pub failed: Vec<(PathBuf, std::io::Error)>, } @@ -150,10 +108,7 @@ impl FixPlan { } } - /// Queue a write. The last call for a given path wins; the caller is - /// responsible for composing edits on top of any prior staged content - /// (via `read_source_with_hash_check`, which returns the staged bytes - /// when present so the next fixer's edits compose rather than collide). + /// Queue a write. The last call for a path wins. pub(super) fn stage(&mut self, path: PathBuf, content: Vec) { if let Some(existing) = self.entries.iter_mut().find(|e| e.path == path) { existing.content = content; @@ -162,15 +117,7 @@ impl FixPlan { self.entries.push(PlannedWrite { path, content }); } - /// Return the currently-staged content for `path`, if any. Used by - /// `read_source_with_hash_check` so a second fixer reads its starting - /// bytes from the first fixer's pending plan entry instead of from - /// disk; this composes cross-fixer edits on the same file (e.g. - /// removing both an unused export AND an unused enum member from - /// the same source) into a single coherent rewrite. Without this - /// hand-off, the second stage would overwrite the first with a - /// plan-fresh-from-disk view, silently losing the first fixer's - /// edits. + /// Return the currently-staged content for `path`, if any. pub(super) fn staged_content(&self, path: &Path) -> Option<&[u8]> { self.entries .iter() @@ -178,24 +125,7 @@ impl FixPlan { .map(|e| e.content.as_slice()) } - /// Record that a file was skipped. The orchestrator uses this to - /// surface a clear diagnostic and set a non-zero exit code. - /// - /// Deduped on `(path, reason)`: every per-issue-type fixer - /// (`apply_export_fixes`, `apply_enum_member_fixes`, - /// `apply_catalog_entry_fixes`, etc.) calls - /// `read_source_with_hash_check` independently for files that carry - /// findings of its issue type, and any precondition failure (hash - /// mismatch, mixed CRLF/LF) lands here on every invocation. Without - /// the dedupe, one mixed-EOL file with both an unused export AND an - /// unused enum member would surface as TWO `fixes[]` entries and a - /// `skipped_mixed_line_endings: 2` envelope counter for what is - /// structurally one file. The orchestrator comment in - /// `build_skipped_records` and the user-facing reminder line both - /// document "one entry per skipped file" semantics; this dedupe - /// preserves that contract regardless of how many fixer invocations - /// produce the same skip. Caught 2026-05-21 by codex parallel - /// /fallow-review on issue #475. + /// Record that a file was skipped. Deduped on `(path, reason)`. pub(super) fn skip(&mut self, path: PathBuf, reason: SkipReason) { if self .skipped @@ -219,24 +149,13 @@ impl FixPlan { self.entries.iter().map(|e| e.path.as_path()) } - /// Stage every entry to a sibling `NamedTempFile`, then promote each - /// to its final path. If staging any entry fails, returns immediately - /// without renaming anything: the project is untouched. If a rename - /// fails (rare, filesystem-level), the entries that already renamed - /// stay applied and the failure is reported per-path. + /// Stage every entry to a sibling `NamedTempFile`, then promote each to + /// its final path. pub(super) fn commit(self) -> CommitOutcome { if self.entries.is_empty() { return CommitOutcome::empty(); } - // Stage every entry first. Hold the NamedTempFile handles until we - // know every stage succeeded; on staging failure, all handles drop - // here and the temp files are removed before any rename runs. We - // also carry the RESOLVED (canonicalized) path so the final - // rename writes through symlinks, matching `fallow_config::atomic_write`'s - // contract; persisting to the original path would replace the - // symlink itself with a regular file and leave the real target - // untouched. let mut staged: Vec = Vec::with_capacity(self.entries.len()); for entry in self.entries { match stage_one(&entry.path, &entry.content) { @@ -250,10 +169,6 @@ impl FixPlan { } } - // Sort by REQUESTED path (the user-visible identity) for - // deterministic rename order. Stable per-path ordering matters - // for debugability (failure logs name files in a predictable - // order across runs). staged.sort_by(|a, b| a.requested.cmp(&b.requested)); let mut written = FxHashSet::default(); @@ -264,7 +179,6 @@ impl FixPlan { written.insert(stage.requested); } Err(err) => { - // PersistError -> io::Error preserves the original errno. failed.push((stage.requested, err.error)); } } @@ -287,12 +201,6 @@ struct StagedEntry { } fn stage_one(target: &Path, content: &[u8]) -> std::io::Result { - // Match atomic_write's behavior: canonicalize through symlinks so the - // temp file lands in the directory of the resolved target AND the - // final rename promotes the temp into the resolved path. Persisting - // to the original (non-canonical) path replaces the symlink with the - // temp file, leaving the real target untouched; that regresses the - // pre-#454 atomic_write contract. let resolved = std::fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf()); let dir = resolved.parent().ok_or_else(|| { std::io::Error::new( @@ -304,10 +212,6 @@ fn stage_one(target: &Path, content: &[u8]) -> std::io::Result { use std::io::Write; handle.write_all(content)?; handle.as_file().sync_all()?; - // Preserve the target's existing file mode on Unix. NamedTempFile creates - // the temp with 0600 by default; persisting directly would downgrade a - // target previously at 0644 (or any other mode) to owner-only, breaking - // shared workspaces and CI runners that rely on the existing read bit. fallow_config::preserve_target_mode(handle.path(), &resolved); Ok(StagedEntry { handle, @@ -347,21 +251,8 @@ pub(super) fn read_source_with_hash_check( hashes: &CapturedHashes, plan: &mut FixPlan, ) -> Option<(String, super::io::EncodingMetadata)> { - // Cross-fixer composition: prefer the in-plan staged content over a - // disk read. Staged bytes are internal and always valid UTF-8 (every - // fixer produces text via `String::into_bytes`); a UTF-8 failure here - // would indicate a programmer error, not a user-facing bug. Run the - // staged bytes through the SAME classifier as the disk path so the - // `had_bom` flag survives a cross-fixer round trip: the first fixer's - // `stage_fixed_content` re-prepended the BOM bytes when `had_bom` was - // true, and a second fixer reading the same path must rebuild the - // metadata from those bytes (otherwise the second `stage_fixed_content` - // would drop the BOM via last-write-wins). See issue #475. if let Some(staged) = plan.staged_content(path) { let raw = String::from_utf8(staged.to_vec()).ok()?; - // Mixed-EOL is impossible on staged bytes because the prior staging - // joined uniformly, but we run the same classifier for symmetry. If - // it ever errs, surface as a skip so the regression is visible. return match super::io::classify_source(&raw) { Ok((content, meta)) => Some((content, meta)), Err(super::io::EncodingError::MixedLineEndings { .. }) => { @@ -409,8 +300,6 @@ pub(super) fn stage_fixed_content( result.push_str(meta.line_ending); } let bytes = if meta.had_bom { - // UTF-8 BOM is three bytes (`EF BB BF`); reserve exactly to avoid - // a reallocation on the prepend. let bom_bytes = "\u{FEFF}".as_bytes(); let mut buf = Vec::with_capacity(result.len() + bom_bytes.len()); buf.extend_from_slice(bom_bytes); @@ -447,9 +336,6 @@ mod tests { #[test] fn commit_stage_failure_leaves_project_untouched() { - // Force staging to fail by pointing at a path whose parent does - // not exist; no temp can be created. The other entry must NOT - // be promoted. let dir = tempfile::tempdir().unwrap(); let good = dir.path().join("good.txt"); let bad = dir.path().join("nonexistent").join("bad.txt"); @@ -479,8 +365,6 @@ mod tests { #[test] fn skip_reason_wire_value_is_stable() { - // Downstream JSON consumers gate on these strings; flag rename - // bombs at PR-review time. assert_eq!(SkipReason::ContentChanged.as_wire_str(), "content_changed"); assert_eq!( SkipReason::LowConfidenceOffGraph.as_wire_str(), @@ -494,8 +378,6 @@ mod tests { #[test] fn low_confidence_skips_are_intentional_others_are_not() { - // Intentional skips must NOT flip the non-zero exit code; the two - // recoverable failures must. Issue #602. assert!(SkipReason::LowConfidenceOffGraph.is_intentional()); assert!(SkipReason::LowConfidenceUnresolvedImports.is_intentional()); assert!(!SkipReason::ContentChanged.is_intentional()); @@ -544,9 +426,6 @@ mod tests { #[test] fn read_source_with_hash_check_proceeds_when_path_not_in_map() { - // Files not produced by the extract layer (package.json, YAML) - // are not in the captured-hash map. They must proceed without a - // skip (atomic_write per-file is the existing safety net). let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("package.json"); std::fs::write(&file, "{}").unwrap(); @@ -577,11 +456,6 @@ mod tests { #[test] fn staged_content_lets_a_second_fixer_compose_on_top_of_the_first() { - // Regression for the issue #454 cross-fixer composition gap - // (codex parallel review BLOCK): when two fixers touch the same - // file, the second must read the FIRST's staged content (not the - // original disk bytes), so its rewrite composes instead of - // overwriting via last-write-wins. let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("sample.ts"); let original = "line a\nline b\nline c\n"; @@ -594,24 +468,19 @@ mod tests { let mut plan = FixPlan::new(); - // First fixer: removes "line b" (whole-file rewrite). let first_view = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan) .expect("first read succeeds"); assert_eq!(first_view.0, original); plan.stage(file.clone(), b"line a\nline c\n".to_vec()); - // Second fixer: reads the same path; MUST see the first fixer's - // staged content, not the disk content, so its edits compose. let second_view = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan) .expect("second read sees staged content"); assert_eq!( second_view.0, "line a\nline c\n", "second fixer must read the first fixer's staged rewrite, not the original disk bytes" ); - // Second fixer mutates "line a" -> "edited a", stages the result. plan.stage(file.clone(), b"edited a\nline c\n".to_vec()); - // Commit and confirm the on-disk file carries BOTH edits. let outcome = plan.commit(); assert!(outcome.failed.is_empty()); assert_eq!( @@ -624,10 +493,6 @@ mod tests { #[cfg(unix)] #[test] fn commit_preserves_target_file_mode() { - // Regression: NamedTempFile defaults to 0600. Without an explicit - // chmod step before persist, a target previously at 0644 would land - // at 0600 post-fix, silently downgrading the read bit for group + - // other. The commit MUST preserve the target's pre-existing mode. use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("source.ts"); @@ -650,11 +515,6 @@ mod tests { #[cfg(unix)] #[test] fn commit_writes_through_symlink_to_the_real_target() { - // Regression for the issue #454 symlink BLOCK (codex parallel - // review): the previous shape canonicalized only to choose the - // temp directory but persisted to the original (non-canonical) - // path, so the rename replaced the symlink itself with a regular - // file and the real target was never touched. let dir = tempfile::tempdir().unwrap(); let real = dir.path().join("real.ts"); let link = dir.path().join("link.ts"); @@ -666,9 +526,6 @@ mod tests { let outcome = plan.commit(); assert!(outcome.failed.is_empty()); - // The symlink must still BE a symlink (not replaced by a regular - // file), and the rewrite must have flowed through to the real - // target. assert!( std::fs::symlink_metadata(&link) .unwrap() @@ -689,27 +546,14 @@ mod tests { #[test] fn _atomic_write_still_works_for_callers_not_routed_through_the_plan() { - // Sanity check: the existing atomic_write entry point used by - // config.rs (which is intentionally NOT batched) still works. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.json"); fallow_config::atomic_write(&path, b"{}").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "{}"); } - // -- Issue #475: BOM round-trip + mixed-EOL skip ------------------------ - #[test] fn skip_deduplicates_repeat_entries_for_same_path_and_reason() { - // Codex parallel /fallow-review BLOCK on issue #475: every - // per-issue-type fixer (`apply_export_fixes`, `apply_enum_member_fixes`, - // `apply_catalog_entry_fixes`) calls `read_source_with_hash_check` - // independently for files that carry findings of its issue type. If - // a file has both an unused export AND an unused enum member AND - // mixed CRLF/LF line endings, BOTH fixers hit the precondition - // failure and push a skip. Without dedupe, the user sees two - // identical fixes[] entries and a misleading - // `skipped_mixed_line_endings: 2` for what is structurally one file. let mut plan = FixPlan::new(); let path = PathBuf::from("/tmp/mixed.ts"); plan.skip(path.clone(), SkipReason::MixedLineEndings); @@ -720,14 +564,12 @@ mod tests { 1, "multiple skip calls for the same (path, reason) must dedupe to one entry", ); - // A different reason on the same path is a distinct skip: leave it. plan.skip(path, SkipReason::ContentChanged); assert_eq!( plan.skipped().len(), 2, "distinct reasons on the same path stay separate", ); - // A different path with an already-seen reason is also distinct. plan.skip(PathBuf::from("/tmp/other.ts"), SkipReason::MixedLineEndings); assert_eq!(plan.skipped().len(), 3); } @@ -738,9 +580,6 @@ mod tests { let file = dir.path().join("mixed.ts"); std::fs::write(&file, "a\r\nb\nc\r\n").unwrap(); let mut hashes = CapturedHashes::default(); - // Captured hash will not match because read_source returns Err - // BEFORE the hash check; the file must still skip with the mixed - // EOL reason, not with content-changed. hashes.insert(file.clone(), 0xDEAD_BEEF); let mut plan = FixPlan::new(); @@ -753,16 +592,6 @@ mod tests { #[test] fn read_source_with_hash_check_dedupes_mixed_eol_across_two_fixer_calls() { - // Codex parallel /fallow-review reproduction: a single mixed-EOL - // file that carries findings for multiple per-issue-type fixers - // (e.g. an unused export AND an unused enum member) gets two - // calls into `read_source_with_hash_check`, one per fixer. Each - // call hits the mixed-EOL precondition and tries to push a skip. - // The orchestrator's user-facing reporting promises "one entry - // per skipped file"; only the dedupe in `FixPlan::skip` upholds - // that contract. Without the dedupe, `fixes[]` carries two - // identical `mixed_line_endings` entries and the JSON envelope's - // `skipped_mixed_line_endings` counter reads 2 for one file. let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("mixed.ts"); std::fs::write(&file, "a\r\nb\nc\r\n").unwrap(); @@ -770,15 +599,12 @@ mod tests { let mut plan = FixPlan::new(); - // First fixer (e.g. apply_export_fixes) calls read_source_with_hash_check. let first = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan); assert!(first.is_none(), "first fixer call must skip"); - // Second fixer (e.g. apply_enum_member_fixes) hits the SAME file. let second = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan); assert!(second.is_none(), "second fixer call must also skip"); - // Despite two skip pushes, the contract is "one entry per skipped file". assert_eq!( plan.skipped().len(), 1, @@ -789,8 +615,6 @@ mod tests { #[test] fn skip_reason_mixed_line_endings_wire_value_is_stable() { - // Downstream JSON consumers gate on this string; flag rename bombs - // at PR-review time. assert_eq!( SkipReason::MixedLineEndings.as_wire_str(), "mixed_line_endings" @@ -801,12 +625,10 @@ mod tests { fn stage_fixed_content_preserves_bom_on_round_trip() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("bom.ts"); - // BOM + LF source. let body = "export const a = 1;\nexport const b = 2;\n"; std::fs::write(&file, format!("\u{FEFF}{body}")).unwrap(); let mut plan = FixPlan::new(); - // Read via the canonical entry to capture BOM metadata. let (content, meta) = crate::fix::io::read_source(dir.path(), &file) .unwrap() .unwrap(); @@ -817,14 +639,12 @@ mod tests { "post-strip content must omit the BOM" ); - // Pretend a fixer removed the second line. let new_lines: Vec = vec!["export const a = 1;".to_owned()]; stage_fixed_content(&mut plan, &file, &new_lines, &meta, &content); let outcome = plan.commit(); assert!(outcome.failed.is_empty(), "commit must succeed"); - // The on-disk bytes must start with the BOM bytes (`EF BB BF`). let on_disk = std::fs::read(&file).unwrap(); assert_eq!( &on_disk[..3], @@ -832,23 +652,12 @@ mod tests { "BOM must be re-prepended on round-trip; got {:?}", &on_disk[..on_disk.len().min(8)], ); - // And the rest must be the new content plus the original trailing newline. let rest = std::str::from_utf8(&on_disk[3..]).unwrap(); assert_eq!(rest, "export const a = 1;\n"); } #[test] fn staged_content_round_trip_through_second_fixer_preserves_bom() { - // BOM-preservation invariant across the two-fixer staged-content - // round trip. Two fixers stage on the same BOM-bearing file in - // sequence: the first fixer's `stage_fixed_content` re-prepends - // the BOM; the second fixer reads via - // `read_source_with_hash_check` which routes through the - // `staged_content` fast path, must re-detect the BOM on the - // staged bytes via `classify_source`, propagate `had_bom = true` - // on its returned `EncodingMetadata`, and the second - // `stage_fixed_content` must re-prepend the BOM again. After - // commit, on-disk bytes must STILL start with the BOM. let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("bom-multi.ts"); let body = "line a\nline b\nline c\n"; @@ -858,7 +667,6 @@ mod tests { let mut plan = FixPlan::new(); - // First fixer: remove `line b`. let (first_content, first_meta) = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan).unwrap(); assert!(first_meta.had_bom); @@ -872,9 +680,6 @@ mod tests { &first_content, ); - // Second fixer: read again; MUST see the BOM re-prepended on the - // staged bytes via the staged_content fast path, and MUST flag - // had_bom = true on the returned metadata. let (second_content, second_meta) = read_source_with_hash_check(dir.path(), &file, &hashes, &mut plan).unwrap(); assert!( @@ -885,7 +690,6 @@ mod tests { !second_content.starts_with('\u{FEFF}'), "second fixer content must be post-BOM-strip", ); - // Mutate `line a` -> `edited a`. let second_new_lines: Vec = vec!["edited a".to_owned(), "line c".to_owned(), String::new()]; stage_fixed_content( @@ -896,7 +700,6 @@ mod tests { &second_content, ); - // Commit and confirm BOM survives both round-trips. let outcome = plan.commit(); assert!(outcome.failed.is_empty()); let on_disk = std::fs::read(&file).unwrap(); diff --git a/crates/cli/src/flags.rs b/crates/cli/src/flags.rs index 4bb2e8900..f45e3341f 100644 --- a/crates/cli/src/flags.rs +++ b/crates/cli/src/flags.rs @@ -82,13 +82,11 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { Err(code) => return code, }; - // Discover files let files = fallow_core::discover::discover_files_with_plugin_scopes(&config); if files.is_empty() { return emit_error("no files discovered", 2, opts.output); } - // Parse all files (flag extraction happens automatically during parse) let cache_store = if config.no_cache { None } else { @@ -100,10 +98,8 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { }; let parse_result = fallow_core::extract::parse_all_files(&files, cache_store.as_ref(), false); - // Build file_id -> path lookup from discovered files let file_paths: rustc_hash::FxHashMap<_, _> = files.iter().map(|f| (f.id, &f.path)).collect(); - // Prepare user-configured flag patterns for supplementary extraction let extra_sdk: Vec<(String, usize, String)> = config .flags .sdk_patterns @@ -120,14 +116,12 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { || !config.flags.env_prefixes.is_empty() || config.flags.config_object_heuristics; - // Collect feature flags from parsed modules (built-in patterns from cache/parse) let mut flags: Vec = Vec::new(); for module in &parse_result.modules { let Some(path) = file_paths.get(&module.file_id) else { continue; }; - // Built-in flag results from parse/cache let file_suppressed = fallow_core::suppress::is_file_suppressed( &module.suppressions, fallow_core::suppress::IssueKind::FeatureFlag, @@ -145,10 +139,6 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { flags.push(flag_use_to_feature_flag(flag_use, module, path)); } - // Supplementary extraction pass for user-configured patterns. - // Built-in patterns are already in module.flag_uses (cached). - // Custom SDK patterns, env prefixes, and config object heuristics - // require re-reading source because they weren't applied at parse time. if has_custom_config && let Ok(source) = std::fs::read_to_string(path) { let custom_flags = fallow_core::extract::flags::extract_flags_from_source( &source, @@ -157,7 +147,6 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { &config.flags.env_prefixes, config.flags.config_object_heuristics, ); - // Only add flags not already found by built-in extraction (dedup by line+name) for flag_use in &custom_flags { let already_found = module.flag_uses.iter().any(|existing| { existing.line == flag_use.line && existing.flag_name == flag_use.flag_name @@ -175,8 +164,6 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { } } - // Run dead code analysis for cross-reference (flags guarding dead code). - // Uses pre-parsed modules to avoid re-parsing. #[expect( deprecated, reason = "ADR-008 deprecates fallow_core::analyze_with_parse_result and the feature_flags helpers externally; flags still uses the workspace path dependency" @@ -190,14 +177,12 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { ); } - // Filter to changed files if --changed-since is active if let Some(git_ref) = opts.changed_since && let Some(changed) = crate::check::get_changed_files(opts.root, git_ref) { flags.retain(|f| changed.contains(&f.path)); } - // Filter to workspace(s) if specified (either --workspace or --changed-workspaces) let ws_scope = match crate::check::resolve_workspace_scope( opts.root, opts.workspace, @@ -211,7 +196,6 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { flags.retain(|f| ws_roots.iter().any(|r| f.path.starts_with(r))); } - // Sort for deterministic output flags.sort_by(|a, b| { a.path .cmp(&b.path) @@ -219,14 +203,12 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { .then(a.flag_name.cmp(&b.flag_name)) }); - // Apply top N limit if let Some(top) = opts.top { flags.truncate(top); } let elapsed = start.elapsed(); - // Badge format is health-only if matches!( opts.output, OutputFormat::PrCommentGithub @@ -242,9 +224,6 @@ pub fn run_flags(opts: &FlagsOptions<'_>) -> ExitCode { ); } - // Render output. `files.len()` is the discovered source-file count, which - // matches the "Scanned N files" wording better than the parsed-module count - // (the latter undercounts when a file fails to read). let files_scanned = files.len(); print_flags_result(&flags, &config, opts, elapsed, files_scanned); @@ -412,13 +391,11 @@ fn print_flags_human( return; } - // Separate flags guarding dead code (cross-reference) from inventory let dead_code_flags: Vec<&FeatureFlag> = flags .iter() .filter(|f| !f.guarded_dead_exports.is_empty()) .collect(); - // Cross-reference section first (the primary value) if !dead_code_flags.is_empty() { let label = format!("Flags guarding dead code ({})", dead_code_flags.len()); println!("{} {}", "\u{25cf}".yellow(), label.yellow().bold()); @@ -455,7 +432,6 @@ fn print_flags_human( println!(); } - // Full inventory section let mut by_file: Vec<(&std::path::Path, Vec<&FeatureFlag>)> = Vec::new(); for flag in flags { if let Some(entry) = by_file.iter_mut().find(|(p, _)| *p == flag.path.as_path()) { @@ -483,7 +459,6 @@ fn print_flags_human( } } - // Footer if !quiet { let elapsed_str = format!("{:.2}s", elapsed.as_secs_f64()); eprintln!( @@ -618,10 +593,8 @@ fn print_flags_markdown(flags: &[FeatureFlag], config: &ResolvedConfig) { return; } - // Summary heading println!("## Feature flags: {} found\n", flags.len()); - // Cross-reference section first let dead_flags: Vec<&FeatureFlag> = flags .iter() .filter(|f| !f.guarded_dead_exports.is_empty()) @@ -643,7 +616,6 @@ fn print_flags_markdown(flags: &[FeatureFlag], config: &ResolvedConfig) { println!(); } - // Full inventory println!("### Feature flags ({})\n", flags.len()); println!("| File | Line | Flag | Kind |"); println!("|------|------|------|------|"); @@ -667,7 +639,6 @@ fn print_flags_codeclimate(flags: &[FeatureFlag], config: &ResolvedConfig) { let issues: Vec = flags .iter() .map(|f| { - // Use crate::report::n for bracket encoding (Next.js dynamic routes) let path = crate::report::normalize_uri(&relative_path(f, &config.root)); let mut description = format!( "Feature flag '{}' detected ({})", diff --git a/crates/cli/src/health/coverage.rs b/crates/cli/src/health/coverage.rs index 8982e2975..97a6da04f 100644 --- a/crates/cli/src/health/coverage.rs +++ b/crates/cli/src/health/coverage.rs @@ -67,10 +67,6 @@ const BINARY_SIGNING_VERIFY_KEY: [u8; 32] = [ 0x48, 0x7e, 0x6b, 0x46, 0x3c, 0x02, 0x9e, 0xd3, 0x06, 0xdf, 0x2f, 0x01, 0xb5, 0x63, 0x6b, 0x58, ]; -// Hard stop: `test-sidecar-key` ships the test pubkey instead of the real -// binary-signing pubkey. A release build with this feature active would accept -// stub sidecars signed by any party in possession of the seed. Debug builds -// only. #[cfg(all(feature = "test-sidecar-key", not(debug_assertions)))] compile_error!( "feature `test-sidecar-key` must never be enabled in release builds; it swaps the sidecar binary-signing pubkey for a test keypair whose seed is public" @@ -283,11 +279,6 @@ fn validate_license_status( } pub fn discover_sidecar(root: Option<&Path>) -> Result { - // `FALLOW_COV_BIN` is an explicit override: if the user sets it, they - // expect fallow to either use that path or error. Silently falling - // through to auto-discovery when the path is missing / not a file - // contradicts the "explicit beats implicit" contract documented in - // `.claude/rules/cli-crate.md`. if let Some(path) = env_non_empty("FALLOW_COV_BIN") { let candidate = PathBuf::from(&path); if candidate.is_file() { @@ -298,11 +289,6 @@ pub fn discover_sidecar(root: Option<&Path>) -> Result { )); } - // `FALLOW_COV_BINARY_PATH` is the air-gap / pre-placed-binary override. - // Precedes project-local, canonical, and PATH lookup so users in - // enterprise / Docker / distro-packaged setups can point fallow straight - // at a specific binary without having it on PATH. Same explicit-beats- - // implicit semantics as FALLOW_COV_BIN: if it's set and invalid, error. if let Some(path) = env_non_empty("FALLOW_COV_BINARY_PATH") { let candidate = PathBuf::from(&path); if candidate.is_file() { @@ -313,12 +299,6 @@ pub fn discover_sidecar(root: Option<&Path>) -> Result { )); } - // Prefer the platform-specific package's real binary over the wrapper at - // `node_modules/.bin/fallow-cov`. The wrapper is a Node.js script that - // re-execs the platform binary; its path has no adjacent `.sig` file, so - // sig verification fails if we point at the wrapper. The real binary - // lives at `node_modules/@fallow-cli/fallow-cov-/fallow-cov` - // with its signature alongside. if let Some(root) = root && let Some(path) = find_platform_package_sidecar(root) { @@ -436,8 +416,6 @@ fn find_scoped_platform_sidecar(fallow_cli_dir: &Path, binary_name: &str) -> Opt let Some(name_str) = name.to_str() else { continue; }; - // Match only `fallow-cov-` subpackages, not the - // pure-wrapper `fallow-cov` package. if !name_str.starts_with("fallow-cov-") { continue; } @@ -727,10 +705,6 @@ fn static_function( start_column: None, end_line: None, end_column: None, - // Content digest of the function's full-span source slice (from - // `FunctionComplexity.source_hash`). Carried on the wire so the sidecar - // can echo it back, letting runtime-coverage baselines survive line - // moves. Excluded from `stable_id`. source_hash, resolution: IdentityResolution::Unresolved, stable_id: function_identity_id(relative_posix, name, start_line), @@ -769,9 +743,6 @@ fn build_request( coverage_sources: Vec, codeowners_path: Option<&str>, ) -> (Request, FunctionLocations) { - // Sidecar expects a single project_root for path relativization. When a - // single workspace is scoped, use it; otherwise fall back to the repo root - // so multi-workspace runs stay unambiguous. let project_root = match ws_roots { Some([only]) => only.as_path(), _ => root, @@ -810,13 +781,6 @@ fn build_request( if module.complexity.is_empty() { continue; } - // Forward-slash repo-relative path. Used BOTH as the sidecar - // `StaticFile.path` AND as the `FunctionIdentity.file` / `stable_id` - // input, so the identity fallow produces here agrees with the - // static-inventory producer and the `coverage analyze` consumer (both - // hash the repo-relative path). Sidecar wire format uses forward - // slashes regardless of host OS so a Windows-hosted run interoperates - // with a sidecar on a different machine. let relative_posix = relative.to_string_lossy().replace('\\', "/"); let functions = module .complexity @@ -831,16 +795,6 @@ fn build_request( static_signals, istanbul_coverage, ); - // Export-level dead-code signals are reliable enough to mark - // unreferenced exports as statically unused. Internal-only - // functions still default to `true` until fallow grows an - // intra-file call graph; that avoids false `safe_to_delete` - // verdicts when a private helper is only called locally. - // - // Join real test evidence when available: Istanbul per-function - // hits first, then direct test-reachable export references as a - // conservative fallback. We intentionally do not infer "covered" - // for every function in a test-reachable file. static_function( &relative_posix, &function.name, @@ -856,8 +810,6 @@ fn build_request( }) .collect(); files.push(StaticFile { - // Matches the existing convention in `report::ci::diff_filter` - // and `crates/cli/src/health/mod.rs::relative_to_root`. path: relative_posix, functions, }); @@ -876,19 +828,9 @@ fn build_request( min_invocations_for_hot: Some(options.min_invocations_hot), min_observation_volume: options.min_observation_volume, low_traffic_threshold: options.low_traffic_threshold, - // Trace count, period, and deployments come from the beacon side in - // Phase 3. Phase 2 reads a single coverage dump — the sidecar falls - // back to summing observed invocations when `trace_count` is None. trace_count: None, period_days: None, deployments_seen: None, - // Window/instance hints feed `CaptureQuality` on the sidecar. - // In Phase 2 single-dump local mode all four of trace_count, - // period_days, deployments_seen, window_seconds, and - // instances_observed are None; the sidecar derives - // `CaptureQuality.instances_observed` from the count of - // distinct deployments it sees in the dump itself. - // Populated by the beacon transport in Phase 3. window_seconds: None, instances_observed: None, }, @@ -1756,26 +1698,12 @@ fn convert_response( if matches!(verdict, RuntimeCoverageVerdict::Active) { return None; } - // Pull both the cross-surface join key and the line-move-immune - // content digest off the sidecar's FunctionIdentity in one move - // (`identity` is consumed; `None` for 0.5-shape responses). let (stable_id, source_hash) = finding.identity.map_or((None, None), |identity| { (Some(identity.stable_id), identity.source_hash) }); - // The sidecar backfills source_hash onto its finding identities - // from the static index the CLI sends (joined by stable_id), so the - // wire value is authoritative. It is None only for pre-#742 - // sidecars; such a finding then degrades to stable_id / legacy-id - // baseline keying (no line-move tolerance for that finding). Some(RuntimeCoverageFinding { id: finding.id, - // Cross-surface join key from the sidecar's FunctionIdentity - // when present; `None` for 0.5-shape responses (legacy - // fallback). Baseline keying prefers this over `id`. stable_id, - // Content digest from the sidecar's FunctionIdentity (the - // sidecar backfills it from the static index); stable across - // line moves so baselines suppress after a pure line shift. source_hash, path: PathBuf::from(finding.file), function: finding.function, @@ -1813,16 +1741,9 @@ fn convert_response( path: PathBuf::from(entry.file), function: entry.function, line: entry.line, - // 0.4-shape sidecars omit end_line; protocol's serde default is - // 0. The line-overlap filter folds 0 into a single-line range, - // so we forward the value as-is rather than synthesizing - // `entry.line` here (preserves the "we don't know" signal). end_line: entry.end_line, invocations: entry.invocations, percentile: entry.percentile, - // Actions on hot paths are reserved for future protocol versions - // (e.g., a "review-on-change" suggestion). The sidecar protocol - // at 0.5 does not emit per-hot-path actions, so leave empty. actions: Vec::new(), }) .collect::>(); @@ -1949,10 +1870,6 @@ const fn map_risk_band(risk_band: RiskBand) -> RuntimeCoverageRiskBand { match risk_band { RiskBand::Low => RuntimeCoverageRiskBand::Low, RiskBand::High => RuntimeCoverageRiskBand::High, - // Medium, plus the forward-compat `Unknown` sentinel (protocol 0.7.0): - // a sidecar on a newer protocol emitted a risk band this CLI has not - // seen yet. Map it to the neutral middle rather than dropping the - // entry; fallow's own output enum carries only Low/Medium/High. RiskBand::Medium | RiskBand::Unknown => RuntimeCoverageRiskBand::Medium, } } @@ -2093,15 +2010,9 @@ mod tests { #[test] fn binary_signing_verify_key_is_32_bytes() { - // Ed25519 public keys are always 32 bytes. Guards against accidental - // byte-array edits that would silently break verification. assert_eq!(BINARY_SIGNING_VERIFY_KEY.len(), 32); } - // Hard-fail gate for the release process. Asserts the constant is not the - // all-zeros placeholder that shipped in the Phase 2.5 A' commit. Now runs - // by default (no `#[ignore]`) so any accidental revert to the placeholder - // would break `cargo test` immediately. #[test] fn binary_signing_verify_key_must_not_be_placeholder() { assert_ne!( @@ -2110,17 +2021,8 @@ mod tests { ); } - // Structural invariant: the runtime-coverage analysis path must not - // perform any network I/O. Enterprise / air-gapped buyers depend on this. - // The gate for Phase 2 step 4 of the roadmap is explicitly "integration - // test asserting zero network calls during analysis"; this source-level - // assertion is the fastest regression guard for that contract. The sibling - // integration tests in `crates/cli/tests/runtime_coverage_tests.rs` - // exercise the full spawn pipeline with a signed stub sidecar. #[test] fn runtime_coverage_module_has_no_network_code() { - // Scan only the non-test portion of the file; the FORBIDDEN list below - // would otherwise match its own entries. let full = include_str!("coverage.rs"); let analysis_source = full.split("#[cfg(test)]").next().unwrap_or(full); const FORBIDDEN: &[&str] = &[ @@ -2318,12 +2220,6 @@ mod tests { .unwrap_or_else(|err| panic!("failed to clean temp dir {}: {err}", root.display())); } - // Regression test for the Phase 2.5 smoke-test finding: when both the - // `@fallow-cli/fallow-cov-/fallow-cov` real binary and the - // `node_modules/.bin/fallow-cov` Node wrapper exist (the usual layout - // after `npm install @fallow-cli/fallow-cov`), discovery must prefer - // the platform package's real binary. The wrapper has no adjacent - // `.sig` file, so pointing at it breaks signature verification. #[test] fn discovers_platform_package_sidecar_before_bin_wrapper() { let root = make_temp_dir("sidecar-platform-pkg"); @@ -2425,9 +2321,6 @@ mod tests { #[test] fn discovers_pnpm_store_platform_sidecar_before_bin_wrapper() { let root = make_temp_dir("sidecar-pnpm-store"); - // pnpm with `node-linker=isolated` extracts platform packages into - // `.pnpm/@_/...`; the peer-hash - // suffix must not break the `@fallow-cli+fallow-cov-` prefix match. let platform_dir = root .join("node_modules") .join(".pnpm") @@ -2567,10 +2460,6 @@ mod tests { #[test] fn static_function_round_trips() { - // Pins the `static_function` serde round-trip shape. If a future - // protocol revision adds a required-without-default field to - // StaticFunction, this fails in CI rather than the `.expect()` - // panicking in the paid runtime-coverage path. let sf = static_function( "src/render.tsx", "render", @@ -2589,9 +2478,7 @@ mod tests { assert_eq!(value["end_line"], 50); assert_eq!(value["identity"]["resolution"], "unresolved"); assert_eq!(value["identity"]["stable_id"], "fallow:fn:cb4482d6aef7c79a"); - // source_hash is carried on the wire so the sidecar can echo it back. assert_eq!(value["identity"]["source_hash"], "0123456789abcdef"); - // Columns are deliberately absent on the health path (Unresolved). assert!(value["identity"].get("start_column").is_none()); } diff --git a/crates/cli/src/health/grouping.rs b/crates/cli/src/health/grouping.rs index c9d00fefb..37405b405 100644 --- a/crates/cli/src/health/grouping.rs +++ b/crates/cli/src/health/grouping.rs @@ -174,9 +174,6 @@ fn build_group( .filter(|l| paths.contains(&l.path)) .cloned() .collect(); - // `group_targets` flows straight into `RefactoringTargetFinding::with_actions` - // below; no intermediate collect needed. - let total_files = paths.len(); let (mut vital_signs, mut counts) = compute_vital_signs_and_counts( score_output, diff --git a/crates/cli/src/health/hotspots.rs b/crates/cli/src/health/hotspots.rs index 1e8365c7a..06c25beca 100644 --- a/crates/cli/src/health/hotspots.rs +++ b/crates/cli/src/health/hotspots.rs @@ -149,9 +149,6 @@ pub(super) fn compute_hotspots( let churn_result = churn_fetch.result; let since = churn_fetch.since; - // Warn about shallow clones (read from churn result to avoid redundant git call). - // Also surfaces an authorship-inflation warning when ownership is requested: - // squash merges and shallow clones distort author attribution. let shallow_clone = churn_result.shallow_clone; if shallow_clone && !opts.quiet { eprintln!( @@ -170,8 +167,6 @@ pub(super) fn compute_hotspots( let (max_weighted, max_density) = compute_normalization_maxima(file_scores, &churn_result.files, min_commits); - // Compile ownership inputs once. When --ownership is off, skip discovery - // entirely so users without git authorship data pay no setup cost. let ownership_cfg = &config.health.ownership; let bot_globs_owned: Option = opts.ownership.then(|| { compile_bot_globs(&ownership_cfg.bot_patterns).unwrap_or_else(|e| { @@ -183,20 +178,17 @@ pub(super) fn compute_hotspots( }); let codeowners_owned: Option = opts .ownership - .then(|| { - match crate::codeowners::CodeOwners::load(&config.root, None) { + .then( + || match crate::codeowners::CodeOwners::load(&config.root, None) { Ok(co) => Some(co), Err(e) => { - // Don't hard-fail --ownership when CODEOWNERS is unparsable - // or absent: the feature still works from git authorship alone. - // Surface the error so silent nulls don't confuse users. if !opts.quiet && !e.contains("no CODEOWNERS file found") { eprintln!("Warning: failed to parse CODEOWNERS: {e}"); } None } - } - }) + }, + ) .flatten(); let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -210,7 +202,6 @@ pub(super) fn compute_hotspots( now_secs, }); - // Build hotspot entries let mut hotspot_entries = Vec::new(); let mut files_excluded: usize = 0; @@ -252,14 +243,12 @@ pub(super) fn compute_hotspots( }); } - // Sort by score descending (highest risk first) hotspot_entries.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); - // Compute summary BEFORE --top truncation let files_analyzed = hotspot_entries.len(); let summary = HotspotSummary { since: since.display, @@ -269,7 +258,6 @@ pub(super) fn compute_hotspots( shallow_clone, }; - // Apply --top to hotspots if let Some(top) = opts.top { hotspot_entries.truncate(top); } @@ -281,42 +269,33 @@ pub(super) fn compute_hotspots( mod tests { use super::*; - // --- compute_hotspot_score --- - #[test] fn hotspot_score_both_maxima_zero() { - // When both maxima are zero, avoid division by zero -> score 0 assert!((compute_hotspot_score(0.0, 0.0, 0.0, 0.0)).abs() < f64::EPSILON); } #[test] fn hotspot_score_max_weighted_zero() { - // Churn dimension zero, complexity present -> score 0 assert!((compute_hotspot_score(5.0, 0.0, 0.5, 1.0)).abs() < f64::EPSILON); } #[test] fn hotspot_score_max_density_zero() { - // Complexity dimension zero, churn present -> score 0 assert!((compute_hotspot_score(5.0, 10.0, 0.0, 0.0)).abs() < f64::EPSILON); } #[test] fn hotspot_score_equal_normalization() { - // File equals both maxima -> normalized values both 1.0 -> score 100 let score = compute_hotspot_score(10.0, 10.0, 2.0, 2.0); assert!((score - 100.0).abs() < f64::EPSILON); } #[test] fn hotspot_score_half_values() { - // Half of each maximum -> 0.5 * 0.5 * 100 = 25.0 let score = compute_hotspot_score(5.0, 10.0, 1.0, 2.0); assert!((score - 25.0).abs() < f64::EPSILON); } - // --- is_excluded_from_hotspots --- - #[test] fn excluded_no_filters() { let path = std::path::Path::new("/project/src/foo.ts"); @@ -378,8 +357,6 @@ mod tests { assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None)); } - // --- compute_normalization_maxima --- - #[test] fn normalization_maxima_empty_input() { let scores: Vec = vec![]; @@ -461,7 +438,6 @@ mod tests { }, ); - // File has only 2 commits, below min_commits=3 -> excluded let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 3); assert!((max_w).abs() < f64::EPSILON); assert!((max_d).abs() < f64::EPSILON); @@ -505,48 +481,36 @@ mod tests { assert!((max_d).abs() < f64::EPSILON); } - // --- compute_hotspot_score: additional edge cases --- - #[test] fn hotspot_score_high_churn_low_complexity() { - // File at maximum churn but only 10% complexity -> 10.0 let score = compute_hotspot_score(10.0, 10.0, 0.1, 1.0); assert!((score - 10.0).abs() < f64::EPSILON); } #[test] fn hotspot_score_low_churn_high_complexity() { - // File at 10% churn but maximum complexity -> 10.0 let score = compute_hotspot_score(1.0, 10.0, 2.0, 2.0); assert!((score - 10.0).abs() < f64::EPSILON); } #[test] fn hotspot_score_rounding() { - // 0.33 * 0.33 * 100 = 10.89 -> should round to one decimal let score = compute_hotspot_score(1.0, 3.0, 1.0, 3.0); - // 1/3 * 1/3 * 100 = 11.111... -> rounded to 11.1 assert!((score - 11.1).abs() < f64::EPSILON); } #[test] fn hotspot_score_very_small_values() { - // Both values are tiny fractions of their maxima let score = compute_hotspot_score(0.01, 100.0, 0.001, 10.0); - // 0.0001 * 0.0001 * 100 = 0.001 -> rounds to 0.0 assert!((score).abs() < 0.1); } #[test] fn hotspot_score_weighted_exceeds_max() { - // Edge case: weighted_commits > max_weighted (shouldn't happen but be robust) let score = compute_hotspot_score(15.0, 10.0, 1.0, 2.0); - // 1.5 * 0.5 * 100 = 75.0 assert!((score - 75.0).abs() < f64::EPSILON); } - // --- compute_normalization_maxima: additional edge cases --- - #[test] fn normalization_maxima_multiple_files_picks_max() { let scores = vec![ @@ -641,8 +605,6 @@ mod tests { #[test] fn normalization_maxima_mixed_above_and_below_threshold() { - // Two files: one above min_commits, one below. - // Only the above-threshold file should contribute. let scores = vec![ FileHealthScore { path: std::path::PathBuf::from("/src/frequent.ts"), @@ -703,14 +665,12 @@ mod tests { ); let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 5); - // Only frequent.ts qualifies assert!((max_w - 7.0).abs() < f64::EPSILON); assert!((max_d - 0.4).abs() < f64::EPSILON); } #[test] fn normalization_maxima_file_score_without_churn() { - // File exists in scores but has no churn data -> ignored let scores = vec![FileHealthScore { path: std::path::PathBuf::from("/src/no_churn.ts"), fan_in: 0, @@ -735,7 +695,6 @@ mod tests { #[test] fn normalization_maxima_min_commits_zero() { - // min_commits=0 means every file qualifies let scores = vec![FileHealthScore { path: std::path::PathBuf::from("/src/foo.ts"), fan_in: 0, @@ -767,7 +726,6 @@ mod tests { }, ); - // commits=0 >= min_commits=0, so file is included let (max_w, max_d) = compute_normalization_maxima(&scores, &churn_files, 0); assert!((max_w).abs() < f64::EPSILON); assert!((max_d - 0.3).abs() < f64::EPSILON); @@ -775,7 +733,6 @@ mod tests { #[test] fn normalization_maxima_exactly_at_threshold() { - // File has exactly min_commits -> should be included let scores = vec![FileHealthScore { path: std::path::PathBuf::from("/src/foo.ts"), fan_in: 0, @@ -812,11 +769,8 @@ mod tests { assert!((max_d - 1.5).abs() < f64::EPSILON); } - // --- is_excluded_from_hotspots: additional edge cases --- - #[test] fn excluded_workspace_and_glob_combined() { - // File matches workspace but also matches ignore glob -> excluded let path = std::path::Path::new("/project/packages/a/src/generated/types.ts"); let root = std::path::Path::new("/project"); let ws_roots = [std::path::PathBuf::from("/project/packages/a")]; @@ -834,7 +788,6 @@ mod tests { #[test] fn excluded_workspace_match_but_glob_no_match() { - // File is in workspace and doesn't match ignore -> not excluded let path = std::path::Path::new("/project/packages/a/src/index.ts"); let root = std::path::Path::new("/project"); let ws_roots = [std::path::PathBuf::from("/project/packages/a")]; @@ -852,7 +805,6 @@ mod tests { #[test] fn excluded_path_equals_root() { - // Path is the root itself (edge case for strip_prefix) let path = std::path::Path::new("/project"); let root = std::path::Path::new("/project"); let ignore_set = globset::GlobSet::empty(); @@ -862,16 +814,12 @@ mod tests { #[test] fn excluded_path_outside_root() { - // Path not under root -> strip_prefix falls back to full path let path = std::path::Path::new("/other/src/foo.ts"); let root = std::path::Path::new("/project"); let mut builder = globset::GlobSetBuilder::new(); - // Glob matches relative path, but strip_prefix fails so full path is used builder.add(globset::Glob::new("src/foo.ts").unwrap()); let ignore_set = builder.build().unwrap(); - // strip_prefix fails -> uses full path "/other/src/foo.ts" - // which doesn't match "src/foo.ts" assert!(!is_excluded_from_hotspots(path, root, &ignore_set, None)); } diff --git a/crates/cli/src/health/mod.rs b/crates/cli/src/health/mod.rs index 211d34326..bae18df1c 100644 --- a/crates/cli/src/health/mod.rs +++ b/crates/cli/src/health/mod.rs @@ -3,10 +3,6 @@ mod coverage_intelligence; mod grouping; mod hotspots; pub mod ownership; -// `mod health` is itself private at the lib root, so `pub mod` here is -// effectively `pub(crate)`; clippy's `redundant_pub_crate` rejects literal -// `pub(crate)` when the parent is non-pub. The same reasoning applies to -// `pub fn` items inside this module. pub mod scoring; mod targets; @@ -136,9 +132,6 @@ pub struct HealthOptions<'a> { pub report_only: bool, /// Paid runtime coverage sidecar input. pub runtime_coverage: Option, - // CLI calls source the parsed diff from the process-wide startup cache. - // Programmatic and NAPI calls pass `diff_index` explicitly so each request - // can carry its own line-level scope without touching process globals. } struct HealthPipelineTimings { @@ -168,9 +161,6 @@ pub fn execute_health_with_shared_parse( ) -> Result { scoring::validate_coverage_root_absolute(opts.coverage_root) .map_err(|e| emit_error(&e, 2, opts.output))?; - // Health re-derives its own config even when parse is reused, so measure - // it for real rather than reporting 0.0. Only discover + parse are - // genuinely reused from the upstream check pass. let t = Instant::now(); let config = crate::load_config_for_analysis( opts.root, @@ -219,7 +209,6 @@ pub fn execute_health(opts: &HealthOptions<'_>) -> Result 0.0; let ignore_set = build_ignore_set(&config.health.ignore); let changed_files = opts .changed_since .and_then(|git_ref| get_changed_files(opts.root, git_ref)); - // Use one diff source for runtime-coverage and finding-level filters. - // CLI calls fall back to the startup cache; embedded callers pass an - // explicit per-call index. let diff_index = match opts.diff_index { Some(index) => Some(index), None if opts.use_shared_diff_index => crate::report::ci::diff_filter::shared_diff_index(), @@ -309,11 +290,6 @@ fn execute_health_inner( opts.output, )?; - // Validate `--group-by` upfront so misconfigured invocations - // (`--group-by package` on a non-monorepo, missing CODEOWNERS) fail - // before any expensive pipeline stage runs and emit exactly one - // structured error rather than chaining a later git/hotspot error - // with the group-by error in the same JSON stream. let group_resolver = if opts.group_by.is_some() { crate::build_ownership_resolver( opts.group_by, @@ -325,14 +301,8 @@ fn execute_health_inner( None }; - // Build FileId -> path lookup for O(1) access let file_paths: rustc_hash::FxHashMap<_, _> = files.iter().map(|f| (f.id, &f.path)).collect(); - // Collect and filter complexity findings. - // - // The workspace filter is pushed inside `collect_findings` so the - // `files_analyzed` and `total_functions` counters (which feed the report - // summary) reflect the workspace subset rather than the entire monorepo. let t = Instant::now(); let (findings, files_analyzed, total_functions) = collect_findings( &modules, @@ -347,21 +317,12 @@ fn execute_health_inner( let mut findings = findings; let complexity_ms = t.elapsed().as_secs_f64() * 1000.0; - // Coverage gaps have two separate concerns: - // - reporting: include the section in the rendered health output - // - gating: fail the command when config severity is `error` - // - // Config severity may enable reporting for top-level `health` when the user - // did not explicitly choose sections, but it must not override callers that - // intentionally set `coverage_gaps: false` (combined mode, audit, score-only). let config_coverage_enabled = config.rules.coverage_gaps != fallow_config::Severity::Off; let report_coverage_gaps = opts.coverage_gaps || (opts.config_activates_coverage_gaps && config_coverage_enabled); let enforce_coverage_gaps = opts.enforce_coverage_gap_gate && config.rules.coverage_gaps == fallow_config::Severity::Error; - // Load Istanbul coverage data for accurate CRAP scoring. - // Priority: explicit --coverage flag > auto-detected coverage-final.json. let istanbul_coverage = if let Some(coverage_path) = opts.coverage { match scoring::load_istanbul_coverage(coverage_path, opts.coverage_root, Some(&config.root)) { @@ -372,8 +333,6 @@ fn execute_health_inner( } } } else if let Some(auto_path) = scoring::auto_detect_coverage(&config.root) { - // Auto-detected coverage file: best-effort, don't fail if it can't be parsed. - // Note in CI environments so pipelines know scores may vary with coverage presence. if std::env::var("CI").is_ok_and(|v| !v.is_empty()) { eprintln!( "note: using auto-detected coverage at {}; pass --coverage explicitly for deterministic CI scores", @@ -385,10 +344,6 @@ fn execute_health_inner( None }; - // Compute file-level health scores (needed by hotspots and targets too). - // `enforce_crap` requires per-function CRAP data emitted as a side effect - // of file scoring, so it forces the file-score pipeline on even for - // otherwise-complexity-only runs. let needs_file_scores = opts.file_scores || report_coverage_gaps || enforce_coverage_gaps @@ -443,9 +398,6 @@ fn execute_health_inner( None }; - // Run file scoring and churn fetch in parallel when both are needed. - // Churn fetch involves a `git log` shell-out that dominates health timing, - // so keep it tied to sections that actually consume churn data. let needs_churn = opts.hotspots || opts.targets; let (file_score_result, file_scores_ms, churn_fetch) = if needs_file_scores && needs_churn { std::thread::scope(|s| { @@ -496,7 +448,6 @@ fn execute_health_inner( .map_or((0.0, false), |cf| (cf.git_log_ms, cf.cache_hit)); let (score_output, files_scored, average_maintainability) = file_score_result?; - // Print churn cache note on cold miss (only when cache is enabled) if let Some(ref cf) = churn_fetch && !cf.cache_hit && !opts.no_cache @@ -517,10 +468,6 @@ fn execute_health_inner( .as_ref() .map_or(&[] as &[_], |o| o.scores.as_slice()); - // Merge per-function CRAP data into complexity findings. Functions that - // only exceed `--max-crap` (and not cyclomatic/cognitive) are added as - // new findings; functions already flagged for complexity get the CRAP - // fields populated so reports can surface the combined risk. if enforce_crap && let Some(ref score_out) = score_output { merge_crap_findings( &mut findings, @@ -537,13 +484,6 @@ fn execute_health_inner( max_cognitive, ); } - // Synthesise per-component rollup findings for Angular components that - // contribute BOTH a class function finding AND a template finding. The - // rollup folds `worst_class_function + template` into a single - // `` finding so `--targets` and the headline rank surface a - // template-heavy component as one unit; the per-function and - // per-`