diff --git a/.bca-baseline.toml b/.bca-baseline.toml index 2d1ea23d..af568303 100644 --- a/.bca-baseline.toml +++ b/.bca-baseline.toml @@ -17,36 +17,12 @@ qualified = "Baseline::from_str" metric = "halstead.effort" value = 58410.89968953587 -[[entry]] -path = "big-code-analysis-cli/src/baseline.rs" -qualified = "Baseline::match_in_group" -metric = "nargs" -value = 7.0 - -[[entry]] -path = "big-code-analysis-cli/src/baseline_diff.rs" -qualified = "BaselineDiff::column_widths" -metric = "nargs" -value = 8.0 - [[entry]] path = "big-code-analysis-cli/src/baseline_diff.rs" qualified = "BaselineDiff::compute" metric = "halstead.effort" value = 118801.67275912332 -[[entry]] -path = "big-code-analysis-cli/src/baseline_diff.rs" -qualified = "BaselineDiff::compute" -metric = "nargs" -value = 8.0 - -[[entry]] -path = "big-code-analysis-cli/src/check_format.rs" -qualified = "AggregatedFormat::dump" -metric = "nargs" -value = 7.0 - [[entry]] path = "big-code-analysis-cli/src/check_format.rs" qualified = "write_github_annotations" @@ -72,16 +48,22 @@ metric = "halstead.effort" value = 48544.21441032752 [[entry]] -path = "big-code-analysis-cli/src/commands/analyze.rs" -qualified = "write_aggregate" +path = "big-code-analysis-cli/src/commands/check.rs" +qualified = "classify_check_violations" metric = "nargs" -value = 7.0 +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/commands/check.rs" +qualified = "emit_check_results" +metric = "nargs" +value = 5.0 [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" qualified = "filter_by_baseline" metric = "nargs" -value = 8.0 +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/commands/check.rs" @@ -89,35 +71,41 @@ qualified = "run_check" metric = "halstead.effort" value = 60852.18615151166 +[[entry]] +path = "big-code-analysis-cli/src/commands/check/effective_config.rs" +qualified = "EffectiveCheck::from_resolved" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "EffectiveConfig::from_resolved" metric = "nargs" -value = 12.0 +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/commands/check/effective_config.rs" qualified = "print_effective_config" metric = "nargs" -value = 9.0 +value = 7.0 [[entry]] -path = "big-code-analysis-cli/src/commands/diff_cmd.rs" -qualified = "compute_since_diff" +path = "big-code-analysis-cli/src/commands/check/explain.rs" +qualified = "run_explain_thresholds" metric = "nargs" -value = 8.0 +value = 5.0 [[entry]] -path = "big-code-analysis-cli/src/commands/init.rs" -qualified = "run_command_init" +path = "big-code-analysis-cli/src/commands/check/thresholds.rs" +qualified = "SharedLayers<'a>::new" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] path = "big-code-analysis-cli/src/commands/init.rs" qualified = "scaffold_baseline" metric = "nargs" -value = 7.0 +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/commands/report.rs" @@ -137,12 +125,6 @@ qualified = "materialize_tree" metric = "halstead.effort" value = 60390.01093335322 -[[entry]] -path = "big-code-analysis-cli/src/diff.rs" -qualified = "materialize_tree" -metric = "nargs" -value = 10.0 - [[entry]] path = "big-code-analysis-cli/src/diff.rs" qualified = "materialize_tree" @@ -155,6 +137,48 @@ qualified = "parse_ls_tree_record" metric = "nexits" value = 6.0 +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "analyze_file" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_check_file" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_count" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_dump" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_exemptions" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_find" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_functions" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_metrics" @@ -165,19 +189,37 @@ value = 7.0 path = "big-code-analysis-cli/src/dispatch.rs" qualified = "dispatch_ops" metric = "nargs" -value = 8.0 +value = 7.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_report" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/dispatch.rs" +qualified = "dispatch_strip_comments" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "generate_html_report_with_vcs" +metric = "nargs" +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/html_report.rs" qualified = "write_table_classed" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] path = "big-code-analysis-cli/src/html_report.rs" qualified = "write_table_classed_with_tooltips" metric = "nargs" -value = 9.0 +value = 7.0 [[entry]] path = "big-code-analysis-cli/src/html_report.rs" @@ -185,17 +227,23 @@ qualified = "write_table_core" metric = "nargs" value = 7.0 +[[entry]] +path = "big-code-analysis-cli/src/html_report.rs" +qualified = "write_table_head" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-cli/src/html_report.rs" qualified = "write_table_with_tooltips" metric = "nargs" -value = 8.0 +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" qualified = "" metric = "loc.ploc" -value = 547.0 +value = 529.0 [[entry]] path = "big-code-analysis-cli/src/html_report/sections.rs" @@ -213,43 +261,61 @@ value = 59351.805921378844 path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "" metric = "loc.ploc" -value = 597.0 +value = 588.0 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "extract_summaries" +metric = "nargs" +value = 5.0 [[entry]] path = "big-code-analysis-cli/src/markdown_report.rs" qualified = "extract_summaries_inner" metric = "abc" -value = 39.153543900903784 +value = 40.124805295477756 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "generate_report_with_vcs" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/markdown_report.rs" +qualified = "write_language_section" +metric = "nargs" +value = 6.0 [[entry]] path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" qualified = "" metric = "loc.ploc" -value = 650.0 +value = 696.0 [[entry]] -path = "big-code-analysis-cli/src/markdown_report/sections.rs" -qualified = "emit_section_md" +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select" metric = "nargs" -value = 8.0 +value = 5.0 [[entry]] -path = "big-code-analysis-cli/src/metric_diff.rs" -qualified = "MetricDiff::from_sets" +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select_cc" metric = "nargs" -value = 8.0 +value = 5.0 [[entry]] -path = "big-code-analysis-cli/src/threshold_suggestion.rs" -qualified = "closest_names" -metric = "halstead.effort" -value = 55785.628204736706 +path = "big-code-analysis-cli/src/markdown_report/hotspot.rs" +qualified = "select_for" +metric = "nargs" +value = 7.0 [[entry]] path = "big-code-analysis-cli/src/threshold_suggestion.rs" qualified = "closest_names" -metric = "nargs" -value = 9.0 +metric = "halstead.effort" +value = 55785.628204736706 [[entry]] path = "big-code-analysis-cli/src/threshold_suggestion.rs" @@ -258,22 +324,22 @@ metric = "halstead.effort" value = 66737.04848699087 [[entry]] -path = "big-code-analysis-cli/src/vcs_command.rs" +path = "big-code-analysis-cli/src/thresholds.rs" qualified = "" metric = "loc.ploc" -value = 532.0 +value = 544.0 [[entry]] -path = "big-code-analysis-cli/src/vcs_command.rs" -qualified = "build_options" +path = "big-code-analysis-cli/src/thresholds.rs" +qualified = "ThresholdSet::evaluate_with_policy" metric = "nargs" -value = 8.0 +value = 5.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" -qualified = "rank" -metric = "nargs" -value = 7.0 +qualified = "" +metric = "loc.ploc" +value = 532.0 [[entry]] path = "big-code-analysis-cli/src/vcs_command.rs" @@ -282,10 +348,34 @@ metric = "nexits" value = 5.0 [[entry]] -path = "big-code-analysis-cli/src/vcs_report.rs" -qualified = "write_html_body" +path = "big-code-analysis-cli/src/walk.rs" +qualified = "assemble_globals" metric = "nargs" -value = 8.0 +value = 6.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk.rs" +qualified = "expand_seed_paths" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk.rs" +qualified = "visit_walk_entry" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk_seed.rs" +qualified = "AnchoredExcludes<'a>::new" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-cli/src/walk_seed.rs" +qualified = "relative_tail_with" +metric = "nexits" +value = 5.0 [[entry]] path = "big-code-analysis-py/python/big_code_analysis/_flatten.py" @@ -315,7 +405,7 @@ value = 6.0 path = "big-code-analysis-py/src/batch.rs" qualified = "analyze_paths" metric = "nargs" -value = 12.0 +value = 11.0 [[entry]] path = "big-code-analysis-py/src/batch.rs" @@ -323,6 +413,12 @@ qualified = "analyze_paths" metric = "nexits" value = 5.0 +[[entry]] +path = "big-code-analysis-py/src/batch.rs" +qualified = "push_one_result" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "PyVcsOptions::py_new" @@ -341,6 +437,12 @@ qualified = "analyze" metric = "nargs" value = 8.0 +[[entry]] +path = "big-code-analysis-py/src/lib.rs" +qualified = "analyze_source" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "extract_as_of" @@ -353,6 +455,12 @@ qualified = "register_vcs_submodule" metric = "nexits" value = 14.0 +[[entry]] +path = "big-code-analysis-py/src/lib.rs" +qualified = "vcs_rank" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-py/src/lib.rs" qualified = "vcs_trend" @@ -389,6 +497,12 @@ qualified = "extract_line_number" metric = "nexits" value = 5.0 +[[entry]] +path = "big-code-analysis-py/src/sarif.rs" +qualified = "record_threshold_breaches" +metric = "nargs" +value = 6.0 + [[entry]] path = "big-code-analysis-py/src/sarif.rs" qualified = "resolve_thresholds" @@ -413,6 +527,18 @@ qualified = "options_from" metric = "nexits" value = 8.0 +[[entry]] +path = "big-code-analysis-py/src/vcs.rs" +qualified = "vcs_trend_json" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "big-code-analysis-py/src/walk.rs" +qualified = "walk_seed" +metric = "nargs" +value = 5.0 + [[entry]] path = "big-code-analysis-web/src/web/server.rs" qualified = "run_parse" @@ -423,7 +549,7 @@ value = 82478.1543040951 path = "big-code-analysis-web/src/web/server.rs" qualified = "run_with_timeout" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] path = "big-code-analysis-web/src/web/server/routing.rs" @@ -455,6 +581,174 @@ qualified = "" metric = "loc.ploc" value = 545.0 +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::get_ast_node" +metric = "nargs" +value = 6.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "Alterator::get_default" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "BashCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CppCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "CsharpCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "ElixirCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "GoCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "GroovyCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "IrulesCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "JavaCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "JavascriptCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "KotlinCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "LuaCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "MozcppCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "MozjsCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "ObjcCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PerlCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PhpCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "PythonCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "RubyCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "RustCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "TclCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "TsxCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/alterator.rs" +qualified = "TypescriptCode::alterate" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/ast.rs" +qualified = "AstNode::with_field_name" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/ast.rs" +qualified = "Span::new" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/ast.rs" qualified = "build" @@ -473,12 +767,24 @@ qualified = "step_normal" metric = "halstead.effort" value = 66515.67234293568 +[[entry]] +path = "src/c_macro.rs" +qualified = "step_normal" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/c_macro.rs" qualified = "step_normal" metric = "nexits" value = 6.0 +[[entry]] +path = "src/c_macro.rs" +qualified = "step_raw_string" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/getter/bash.rs" qualified = "BashCode::get_op_type" @@ -503,24 +809,12 @@ qualified = "IrulesCode::get_op_type" metric = "halstead.effort" value = 53163.06867422894 -[[entry]] -path = "src/getter/javascript.rs" -qualified = "JavascriptCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/getter/mozcpp.rs" qualified = "MozcppCode::get_func_space_name" metric = "cognitive" value = 15.0 -[[entry]] -path = "src/getter/mozjs.rs" -qualified = "MozjsCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/getter/perl.rs" qualified = "PerlCode::get_op_type" @@ -545,18 +839,6 @@ qualified = "RubyCode::get_op_type" metric = "halstead.effort" value = 84039.86210807812 -[[entry]] -path = "src/getter/tsx.rs" -qualified = "TsxCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - -[[entry]] -path = "src/getter/typescript.rs" -qualified = "TypescriptCode::get_func_space_name" -metric = "cognitive" -value = 15.0 - [[entry]] path = "src/metrics/abc/csharp.rs" qualified = "csharp_inspect_container" @@ -593,12 +875,6 @@ qualified = "KotlinCode::compute" metric = "cyclomatic" value = 16.0 -[[entry]] -path = "src/metrics/abc/perl.rs" -qualified = "perl_inspect_container" -metric = "halstead.effort" -value = 49178.00338181524 - [[entry]] path = "src/metrics/abc/rust.rs" qualified = "RustCode::compute" @@ -608,62 +884,134 @@ value = 16.0 [[entry]] path = "src/metrics/cognitive.rs" qualified = "tcl_switch_decision_arms" +metric = "nexits" +value = 6.0 + +[[entry]] +path = "src/metrics/cognitive/bash.rs" +qualified = "BashCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/cognitive.rs" -qualified = "tcl_switch_decision_arms" -metric = "nexits" -value = 6.0 +path = "src/metrics/cognitive/c.rs" +qualified = "CCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/cpp.rs" +qualified = "CppCode::compute" +metric = "nargs" +value = 5.0 [[entry]] path = "src/metrics/cognitive/csharp.rs" qualified = "CsharpCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/elixir.rs" +qualified = "ElixirCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/go.rs" +qualified = "GoCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/groovy.rs" +qualified = "GroovyCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/irules.rs" +qualified = "IrulesCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/java.rs" +qualified = "JavaCode::compute" +metric = "nargs" +value = 5.0 [[entry]] path = "src/metrics/cognitive/kotlin.rs" qualified = "KotlinCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/lua.rs" +qualified = "LuaCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/mozcpp.rs" +qualified = "MozcppCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/objc.rs" +qualified = "ObjcCode::compute" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/metrics/cognitive/perl.rs" +qualified = "PerlCode::compute" +metric = "nargs" +value = 5.0 [[entry]] path = "src/metrics/cognitive/php.rs" qualified = "PhpCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/loc/c.rs" -qualified = "CCode::compute" +path = "src/metrics/cognitive/python.rs" +qualified = "PythonCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/loc/cpp.rs" -qualified = "CppCode::compute" +path = "src/metrics/cognitive/ruby.rs" +qualified = "RubyCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/loc/groovy.rs" -qualified = "GroovyCode::compute" +path = "src/metrics/cognitive/rust.rs" +qualified = "RustCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/loc/mozcpp.rs" -qualified = "MozcppCode::compute" +path = "src/metrics/cognitive/tcl.rs" +qualified = "TclCode::compute" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] -path = "src/metrics/loc/objc.rs" -qualified = "ObjcCode::compute" +path = "src/metrics/cyclomatic.rs" +qualified = "Cyclomatic::compute_with_options" metric = "nargs" -value = 7.0 +value = 5.0 + +[[entry]] +path = "src/metrics/cyclomatic/rust.rs" +qualified = "RustCode::compute_with_options" +metric = "nargs" +value = 5.0 [[entry]] path = "src/metrics/loc/perl.rs" @@ -675,19 +1023,13 @@ value = 55025.91689557041 path = "src/metrics/loc/shared.rs" qualified = "add_multiline_string_ploc" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] path = "src/metrics/nargs.rs" qualified = "" metric = "loc.ploc" -value = 542.0 - -[[entry]] -path = "src/metrics/nargs.rs" -qualified = "ObjcCode::compute" -metric = "nargs" -value = 7.0 +value = 547.0 [[entry]] path = "src/metrics/npa/csharp.rs" @@ -695,12 +1037,6 @@ qualified = "CsharpCode::compute" metric = "cognitive" value = 15.0 -[[entry]] -path = "src/metrics/npa/csharp.rs" -qualified = "CsharpCode::compute" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/metrics/npa/kotlin.rs" qualified = "KotlinCode::compute" @@ -719,12 +1055,6 @@ qualified = "PhpCode::compute" metric = "halstead.effort" value = 52227.844233305164 -[[entry]] -path = "src/metrics/npa/php.rs" -qualified = "PhpCode::compute" -metric = "nargs" -value = 10.0 - [[entry]] path = "src/metrics/npa/python.rs" qualified = "python_self_attr_name_bytes" @@ -737,12 +1067,6 @@ qualified = "GoCode::compute" metric = "halstead.effort" value = 65898.2477704053 -[[entry]] -path = "src/metrics/npm/go.rs" -qualified = "GoCode::compute" -metric = "nargs" -value = 10.0 - [[entry]] path = "src/metrics/npm/objc.rs" qualified = "ObjcCode::compute" @@ -753,7 +1077,7 @@ value = 15.0 path = "src/node.rs" qualified = "Node<'a>" metric = "nom" -value = 35.0 +value = 34.0 [[entry]] path = "src/ops.rs" @@ -773,6 +1097,24 @@ qualified = "write_code_climate" metric = "halstead.effort" value = 60471.77938561803 +[[entry]] +path = "src/output/dump.rs" +qualified = "dump_node" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/output/dump.rs" +qualified = "write_node_line" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/output/dump_metrics.rs" +qualified = "dump_group" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_metrics.rs" qualified = "dump_metrics" @@ -785,12 +1127,24 @@ qualified = "dump_space" metric = "nexits" value = 9.0 +[[entry]] +path = "src/output/dump_metrics.rs" +qualified = "dump_value" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_metrics.rs" qualified = "dump_value" metric = "nexits" value = 5.0 +[[entry]] +path = "src/output/dump_ops.rs" +qualified = "dump_ops_values" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/output/dump_ops.rs" qualified = "dump_ops_values" @@ -815,18 +1169,36 @@ qualified = "metric_values" metric = "halstead.effort" value = 80351.11907066956 -[[entry]] -path = "src/output/sarif.rs" -qualified = "write_sarif_with_suppressed" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/parser.rs" qualified = "Parser::filters" metric = "halstead.effort" value = 58860.61554860244 +[[entry]] +path = "src/preproc.rs" +qualified = "accumulate_reachable_includes" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/preproc.rs" +qualified = "classify_preproc_node" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/preproc.rs" +qualified = "record_indirect_includes" +metric = "nargs" +value = 5.0 + +[[entry]] +path = "src/spaces.rs" +qualified = "FuncSpace::new" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/spaces/ast.rs" qualified = "Ast::from_path" @@ -839,6 +1211,12 @@ qualified = "CodeMetrics::fmt" metric = "nexits" value = 8.0 +[[entry]] +path = "src/spaces/compute.rs" +qualified = "apply_comment_suppression" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/spaces/compute.rs" qualified = "compute_per_node" @@ -857,6 +1235,12 @@ qualified = "metrics_inner" metric = "halstead.effort" value = 120010.12175375846 +[[entry]] +path = "src/spaces/compute.rs" +qualified = "open_func_space" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/suppression.rs" qualified = "parse_native" @@ -869,12 +1253,6 @@ qualified = "read_file_with_eol" metric = "nexits" value = 7.0 -[[entry]] -path = "src/vcs/bus_factor.rs" -qualified = "authors_of_file" -metric = "nargs" -value = 9.0 - [[entry]] path = "src/vcs/bus_factor.rs" qualified = "compute" @@ -883,9 +1261,9 @@ value = 47694.803479864124 [[entry]] path = "src/vcs/bus_factor.rs" -qualified = "compute" +qualified = "greedy_truck_factor" metric = "nargs" -value = 10.0 +value = 5.0 [[entry]] path = "src/vcs/git/blame.rs" @@ -893,12 +1271,6 @@ qualified = "PerFunctionBlame" metric = "nom" value = 30.0 -[[entry]] -path = "src/vcs/git/blame.rs" -qualified = "PerFunctionBlame::blame_spans" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/git/blame.rs" qualified = "PerFunctionBlame::open" @@ -911,6 +1283,12 @@ qualified = "PerFunctionBlame::resolve_commit" metric = "nexits" value = 6.0 +[[entry]] +path = "src/vcs/git/cached.rs" +qualified = "assemble" +metric = "nargs" +value = 6.0 + [[entry]] path = "src/vcs/git/cached.rs" qualified = "build_cached" @@ -927,13 +1305,19 @@ value = 11.0 path = "src/vcs/git/cached.rs" qualified = "incremental_events" metric = "nargs" -value = 12.0 +value = 9.0 [[entry]] path = "src/vcs/git/cached.rs" qualified = "load_candidates" metric = "nargs" -value = 7.0 +value = 5.0 + +[[entry]] +path = "src/vcs/git/cached.rs" +qualified = "persist" +metric = "nargs" +value = 6.0 [[entry]] path = "src/vcs/git/diff_parse.rs" @@ -947,6 +1331,12 @@ qualified = "collect_events" metric = "halstead.effort" value = 48928.29416311421 +[[entry]] +path = "src/vcs/git/history.rs" +qualified = "collect_events" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/git/history.rs" qualified = "collect_events" @@ -1005,7 +1395,7 @@ value = 5.0 path = "src/vcs/git/jit.rs" qualified = "experience_features" metric = "nargs" -value = 7.0 +value = 6.0 [[entry]] path = "src/vcs/git/jit.rs" @@ -1019,6 +1409,12 @@ qualified = "history_features" metric = "halstead.effort" value = 52560.02538757721 +[[entry]] +path = "src/vcs/git/jit.rs" +qualified = "history_features" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/vcs/git/jit.rs" qualified = "score_commit" @@ -1065,7 +1461,7 @@ value = 5.0 path = "src/vcs/replay.rs" qualified = "fold_commit" metric = "nargs" -value = 7.0 +value = 5.0 [[entry]] path = "src/vcs/score.rs" @@ -1073,38 +1469,26 @@ qualified = "apply_percentile" metric = "halstead.effort" value = 68450.87280037583 -[[entry]] -path = "src/vcs/score.rs" -qualified = "apply_percentile" -metric = "nargs" -value = 13.0 - [[entry]] path = "src/vcs/stats.rs" qualified = "Accumulator::finalize" metric = "halstead.effort" value = 79569.58843331039 -[[entry]] -path = "src/vcs/stats.rs" -qualified = "Accumulator::finalize" -metric = "nargs" -value = 7.0 - [[entry]] path = "src/vcs/stats.rs" qualified = "Accumulator::record" metric = "halstead.effort" value = 56240.27018612609 +[[entry]] +path = "src/vcs/trend.rs" +qualified = "Trend::from_snapshots" +metric = "nargs" +value = 5.0 + [[entry]] path = "src/wire.rs" qualified = "CodeMetrics::from" metric = "halstead.effort" value = 49790.15156224738 - -[[entry]] -path = "src/wire/vcs.rs" -qualified = "VcsTrend::from_trend" -metric = "nargs" -value = 8.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84b23617..c33a02c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -552,6 +552,11 @@ jobs: # this check. - name: snapshot anchors (explicit) run: python3 utils/check-snapshot-anchors.py + # Run the gate's own unittests as their own explicit step, for the + # same reason as the rustfmt-bail twin below: a refactor that + # breaks the script must not be able to disable the gate silently. + - name: snapshot-anchors self-tests (explicit) + run: python3 -m unittest -q utils/check-snapshot-anchors-test.py # Defensive twin for the rustfmt-bail gate (#1136): a comment # inside a match pattern makes rustfmt emit the whole match # verbatim while `cargo fmt --check` exits 0, so the fmt job diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc8621a6..89bafbce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -80,6 +80,16 @@ repos: entry: python3 utils/check-snapshot-anchors.py pass_filenames: false + # Self-tests for the snapshot-anchors gate itself. Kept as a + # separate hook so the gate hook above remains a one-line + # invariant check, matching the rustfmt-bail pair below. + - id: snapshot-anchors-test + name: snapshot-anchors-test + language: system + files: '^utils/check-snapshot-anchors(-test)?\.py$' + entry: python3 -m unittest -q utils/check-snapshot-anchors-test.py + pass_filenames: false + # Block new match arms rustfmt refuses to format (#1136). A # comment inside a match *pattern* makes rustfmt emit the whole # match verbatim while `cargo fmt --check` still exits 0, so the diff --git a/.snapshot-anchor-baseline.txt b/.snapshot-anchor-baseline.txt index 368dee17..7aa33afe 100644 --- a/.snapshot-anchor-baseline.txt +++ b/.snapshot-anchor-baseline.txt @@ -2,18 +2,16 @@ # Maintained by check-snapshot-anchors.py; see AGENTS.md. # Lower-or-equal current counts pass; any increase fails CI. # Regenerate with: ./utils/check-snapshot-anchors.py --update +# +# Only files with outstanding bare calls are listed. An unlisted +# file is allowed zero, which `load_baseline`'s `.get(rel, 0)` +# already encodes — so omitting them keeps this a list of debt +# rather than a census of every scanned file (#1192 made the +# scan recursive, which would otherwise have added 126 zeroes). src/metrics/abc.rs 27 -src/metrics/cognitive.rs 0 src/metrics/cyclomatic.rs 1 src/metrics/halstead.rs 3 -src/metrics/loc.rs 0 -src/metrics/mi.rs 0 -src/metrics/mod.rs 0 -src/metrics/nargs.rs 0 -src/metrics/nexits.rs 0 -src/metrics/nom.rs 0 src/metrics/npa.rs 12 src/metrics/npm.rs 23 -src/metrics/tokens.rs 0 src/metrics/wmc.rs 13 diff --git a/AGENTS.md b/AGENTS.md index 615a405d..7c684c49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -436,12 +436,27 @@ as part of `make pre-commit` and `make ci`, the `.github/workflows/ci.yml`) invokes `./utils/check-snapshot-anchors.py`, which scans every `insta::assert_json_snapshot!(metric.…)` call under `src/metrics/` -and counts the unanchored ones per file. The current per-file -counts are checked in at `.snapshot-anchor-baseline.txt`; CI fails -on any *increase*. Decreases are silent and may be locked in with +— **subdirectories included since #1192**; before that the glob was +non-recursive and the 126 files under `abc/`, `cognitive/`, +`cyclomatic/`, `loc/`, `npa/` and `npm/` were invisible to it — and +counts the unanchored ones per file. Outstanding counts are checked +in at `.snapshot-anchor-baseline.txt`; CI fails on any *increase*. +A file with no bare calls is **not** listed, and an unlisted file is +allowed zero, so the baseline reads as a list of debt rather than a +census. Decreases are silent and may be locked in with `./utils/check-snapshot-anchors.py --update`, which regenerates the baseline from the working tree. +The gate lexes Rust literals to decide what is live code, and that +lexer is itself gated: `make snapshot-anchors-test` runs +`utils/check-snapshot-anchors-test.py`, which pins both directions of +the char-literal rule (#1192). A `b'"'` read as an unpaired quote +opens a string span that hides every later snapshot call, and a +lifetime (`'a`, `'outer:`) read as a literal swallows the rest of the +file the other way. Both failure modes make the gate report a clean +file — the outcome it exists to prevent — so neither may be left to +inspection. + **`cargo fmt --check` is not the whole formatting gate.** A comment inside a match *pattern* makes rustfmt emit the enclosing match verbatim, silently, with `cargo fmt --check` still exiting `0` — so diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c27109..74bba396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,32 @@ for historical reference. ### Added +- `bca check --print-effective-config` reports which exclude globs are + manifest-anchored (#1194). After #1164 a glob's meaning depends on its + origin — a `--check-exclude` pattern resolves against the caller's + working directory, a `bca.toml` one against the manifest's directory — + and a single flattened array cannot express that. `manifest_exclude`, + `manifest_check_exclude`, `manifest_exclude_from` and + `manifest_check_exclude_from` name the manifest-origin subset + alongside the resolved lists, which stay where they were so the TOML + form keeps round-tripping through `--config`. The anchor is the + reported `manifest` file's directory. Each key is omitted when the + manifest contributed nothing; the `*_from` pair is present only when + the manifest's file is the one actually in effect, since a CLI + `--exclude-from` *replaces* rather than unions with it. +- Kotlin property accessors (`get()` / `set()`) and `init { … }`, Java + and Groovy `static { … }`, and JavaScript class static blocks now open + a function space of their own (#1184). Each carries executable code but + was referenced nowhere outside the generated language enum, so its + control flow was charged to the enclosing class and `bca check` could + never flag one however complex it got. They are reported under + synthesised names — ``, ``, ``, `` — + following the existing `` convention. **They are + deliberately absent from `nom.functions`, `nargs` and `bca functions`**: + none is a callable named at a call site, and counting an accessor as a + method would make `npm` bill the same property once as an attribute and + again as a method. See the NOM section of the metrics guide. + - `bca check --explain-threshold =`: preview what a candidate threshold would cost at **both** tiers without editing `bca.toml` or running a gate (#1169). Reports hard-tier offenders, the @@ -157,6 +183,64 @@ for historical reference. ### Changed +- `PreprocDiagnostic`'s `Display` output now uses a lowercase + `warning:` prefix for all five variants. `SelfInclusion`, + `IncludeCycle` and `NotPreprocessed` previously capitalised it while + the two non-UTF-8 variants did not, so `bca preproc` emitted both + spellings — and the capitalised three matched no other CLI + diagnostic, since `warn()` has printed lowercase `warning:` since + #609. Per `STABILITY.md`, `Display` impls are stable but their exact + wording is not, so this is not a breaking change. It is also + deliberately byte-compatible with routing these through `warn()` + later: `warn(msg)` renders `warning: {msg}`, so moving the prefix out + of `Display` will produce identical output rather than a second + user-visible change. + +- This project's own `nargs` limit converges from 7 to the shipped + default of 5 (#1183). Repository configuration only — no library or CLI + behaviour changes. The convergence was declined twice before, both + times correctly: #1143 measured it against a hard-tier count that + missed the soft tier, and #1183 found the offenders were mostly + artifacts of the gate summing closure parameters into the enclosing + function. #1196 removed that, and with it the reason to stay at 7 — + which had become a limit catching nothing in the current tree. + +- **`bca check --threshold nargs=N` now gates each callable on its own + parameter list** rather than on `nargs.total()`, which summed a + function's parameters with every nested closure's (#1196). This changes + gate outcomes on existing configurations — read it before upgrading a + pinned CI. + + A three-parameter function containing a two-parameter sort comparator + was reported at 5, and the remediation the number implied — fewer + parameters — was not the one that would clear it. Measured on this + repository, of the 76 functions a limit of 5 would have newly gated, + only 17 had six or more parameters of their own; one had a single + parameter plus five contributed by closures in its body. Refreshing + this project's own baseline under the new rule retired 45 of its 61 + recorded `nargs` entries. + + Every comparable tool measures the same quantity the gate now does — + RuboCop `Metrics/ParameterLists`, ESLint `max-params`, Clippy + `too_many_arguments`, lizard, SonarQube S107, Pylint `R0913` — and two + of those are the anchors the shipped default of 5 is derived from, so + the default and the gate were previously calibrated against different + quantities. + + Nothing escapes the narrower rule. Where a closure opens its own space + (Rust, the JavaScript family, C#, Go, PHP, Perl, Ruby, Lua, Elixir) it + is gated on its own offender row. Where a lambda opens none (Python, + Java, Kotlin, C++) its arguments still fold into the enclosing + function, and the offender row now shows the split — + `nargs = 8 (1 own + 7 lambda)` — so the reader can tell whether the + lever is the signature or the lambda. + + Unchanged: the serialized `function_args` / `closure_args` / `total` + keys, which remain subtree sums. Only the gate's reading of them moved. + If you have a `nargs` limit tuned against the old behaviour, expect + fewer offenders and consider whether the limit is now looser than you + intended. + - This repository's own `bca.toml` gates `cognitive` at 15, the shipped default, instead of the pre-#1140 folklore value of 25 (#1143). This is self-scan configuration only — no public API, no metric @@ -682,6 +766,61 @@ for historical reference. ### Fixed +- **Metric drift.** ABC `conditions` moves wherever a comment sits inside + a ternary (#1181). Slots are now addressed by grammar field rather than + by neighbouring token or fixed index, which fixes two opposite errors + from one cause: C, C++, Objective-C, Mozcpp, PHP, Perl, JavaScript, + TypeScript, TSX and MozJS **over**-counted (`a ? /*n*/ (b) : c` scored + 3 against `a ? (b) : c`'s 2), while Java, C# and Groovy **under**-counted + (`a ? /*n*/ !b : c` scored 2 against `a ? !b : c`'s 3). +- **Metric drift.** ABC `conditions` counts Ruby's and Perl's `not` + keyword like `!` (#1182). `if not b` scored 0 against `if !b`'s 1, and a + `not` ternary scored 2 against the `!` form's 4. Lua and Elixir were + already correct. +- **Metric drift.** Tcl and iRules gained the Phase 2B slot routing every + other language already had (#1180): `if {$a}` and `while {$a}` move 0 → + 1, `if`/`elseif`/`else` 2 → 4, and `expr {$a ? !$b : !$c}` 1 → 4, + matching the value the other languages report for the same expression. + The argument and `return` slots remain unrouted. +- **Metric drift.** A lambda written without its optional parentheses + reports its parameter in Java and C# (#1185). `x -> x + 1` scored + `nargs` 0 where `(x) -> x + 1` scored 1; the parameters are billed to + `closure_args` as before. +- **Metric drift.** JavaScript-family generator functions are classified + as functions rather than closures (#1186), so `nom`'s function/closure + split, `nargs`' `fn_args`/`closure_args` split and cognitive nesting all + move for `function*`. `bca functions` and `bca find --type function` + now report a named generator, which they previously omitted. +- **Metric drift.** An immediately-invoked function expression is a + closure whether or not its result is bound (#1188). `nom` and `nargs` + previously classified `(function(){…})()` and + `const v = (function(){…})()` differently. A class field initialiser and + a non-identifier-keyed object property are now classified the same way + whether written as a function expression or an arrow. +- **Metric drift.** Cognitive complexity resets the lambda surcharge at + every function boundary, not only in the JavaScript family (#1187). A + function *declared inside* a closure scored 3 where the same body + outside one scored 2, in Rust, Java, C++, PHP and C#. Separately, + `(function(){ function g(){…} })()` and `(() => { function g(){…} })()` + now charge `g` the same function depth. +- **Metric drift.** The file-level unit's line span is anchored at line 1 + (#1195). A whitespace-only file reported `0..0`, and any file opening + with blank lines reported a span that omitted them — `"\n\n\nfn a(){}\n"` + gave `4..4` of a 4-line file. An empty file still reports `0..0`, having + no lines. +- **Metric drift.** Kotlin `init { … }` complexity now contributes to its + class's WMC, which follows from the new function space (#1184). +- A `bca.toml` `exclude` glob keeps applying under a directory seed + (#1189). `bca metrics -p sub` moved the walk root, and manifest globs — + written against the manifest's directory — silently stopped matching. + The rule is now stated once and shared by the walker and the `bca check` + gate. +- `utils/check-snapshot-anchors.py` lexes char literals, byte-raw strings + and the whole of `src/metrics/` (#1192). A `b'"'` opened a string span + that hid every later snapshot call, and the scan was non-recursive so + the 126 files under the per-language subdirectories were never checked. + Latent — no live count changed. + - **Metric values move.** A Java record's compact constructor (`record R(int a) { R { … } }`) now opens its own function space instead of charging its body to the enclosing class (#1160). diff --git a/Makefile b/Makefile index 47ca8fb8..7fca3e28 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ find-by-ext = $(if $(FD),$(FD) --extension $(1) $(FD_EXCLUDE) $(2),find . -name NEXTEST := $(shell command -v cargo-nextest 2>/dev/null) TEST_CMD = $(if $(NEXTEST),$(NEXTEST) nextest run --workspace --all-features,cargo test --workspace --all-features --lib --bins --tests) -.PHONY: help check-tools worktree-setup worktree-setup-test build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-manpage-assets gate-status-test enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-all _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-all _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest +.PHONY: help check-tools worktree-setup worktree-setup-test build build-release check test test-doc chain-audit fmt fmt-check markdown-fmt markdown-lint shellcheck sh-fmt sh-fmt-check toml-fmt toml-fmt-check toml-lint makefile-check actionlint snapshot-anchors snapshot-anchors-test rustfmt-bail rustfmt-bail-test grammar-marker-sync grammar-marker-sync-test check-versions check-excluded-manifests check-excluded-manifests-test check-manpage-assets gate-status-test enums-check enums-codegen-drift enums-codegen-drift-test self-scan self-scan-headroom self-scan-write-baseline self-scan-write-baseline-headroom vcs lint clippy udeps insta-review insta-accept clean distclean install install-cli install-web doc doc-open doc-check doc-check-docsrs book book-serve book-pot book-po-update book-ja book-deploy all pre-commit ci release-check verify-changelog pkg-deb-local pkg-rpm-local dev-env-build dev-env-run dev-env-shell dev-env-rm py-bootstrap py-sync py-relock py-clean py-fmt py-fmt-check py-lint py-typecheck py-test py-stubtest smoke smoke-cli smoke-lib bench bench-scaling bench-walk _check-find _pc-all _pc-fmt _pc-clippy _pc-test _pc-doc-check _pc-udeps _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test _pc-self-scan _pc-self-scan-headroom _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest _ci-all _ci-fmt-check _ci-clippy _ci-test _ci-doc-check _ci-build _ci-udeps _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test _ci-enums-codegen-drift-test _ci-self-scan _ci-self-scan-headroom _ci-cargo-pipeline _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest # Default target help: @@ -128,6 +128,7 @@ help: @echo " makefile-check Lint Makefile with checkmake" @echo " actionlint Lint GitHub Actions workflows with actionlint" @echo " snapshot-anchors Block new bare insta snapshots" + @echo " snapshot-anchors-test Self-tests for the snapshot-anchors gate" @echo " rustfmt-bail Block new match arms rustfmt refuses to format" @echo " rustfmt-bail-test Self-tests for the rustfmt-bail gate" @echo " grammar-marker-sync Block grammar-marker bumps without source regen" @@ -406,6 +407,13 @@ snapshot-anchors: @echo "Checking insta snapshot anchors..." @python3 $(BASE_DIR)utils/check-snapshot-anchors.py +# Self-tests for the snapshot-anchors gate. Separate target for the +# same reason as rustfmt-bail-test below: the gate stays a one-line +# invariant check and test failures get their own parallel-arm output. +snapshot-anchors-test: + @echo "Running snapshot-anchors self-tests..." + @(cd $(BASE_DIR) && python3 -m unittest -q utils/check-snapshot-anchors-test.py) + # rustfmt-bail gate (#1136). `cargo fmt --check` exits 0 over regions # rustfmt declined to format — a comment inside a match pattern makes # it emit the whole match verbatim, silently. This probe over-indents @@ -975,7 +983,7 @@ lint: $(MAKE) -j --output-sync=target \ _ci-clippy \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test # --------------------------------------------------------------------------- # Maintenance @@ -1140,7 +1148,7 @@ _pc-all: $(MAKE) -j --output-sync=target \ _pc-test \ _pc-shellcheck _pc-markdown-lint _pc-toml-lint _pc-makefile-check \ - _pc-actionlint _pc-snapshot-anchors _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ + _pc-actionlint _pc-snapshot-anchors _pc-snapshot-anchors-test _pc-rustfmt-bail _pc-rustfmt-bail-test _pc-grammar-marker-sync _pc-grammar-marker-sync-test _pc-check-versions _pc-check-versions-test _pc-check-grammar-crate-test _pc-check-excluded-manifests _pc-check-excluded-manifests-test _pc-check-manpage-assets _pc-worktree-setup-test _pc-gate-status-test _pc-enums-check _pc-enums-codegen-drift _pc-enums-codegen-drift-test \ _pc-manpages \ _pc-self-scan _pc-self-scan-headroom \ _pc-py-fmt _pc-py-typecheck _pc-py-test _pc-py-stubtest @@ -1150,7 +1158,7 @@ _ci-all: $(MAKE) -j --output-sync=target \ _ci-cargo-pipeline \ _ci-shellcheck _ci-markdown-lint _ci-toml-lint _ci-makefile-check \ - _ci-actionlint _ci-snapshot-anchors _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ + _ci-actionlint _ci-snapshot-anchors _ci-snapshot-anchors-test _ci-rustfmt-bail _ci-rustfmt-bail-test _ci-grammar-marker-sync _ci-grammar-marker-sync-test _ci-check-versions _ci-check-versions-test _ci-check-grammar-crate-test _ci-check-excluded-manifests _ci-check-excluded-manifests-test _ci-check-manpage-assets _ci-worktree-setup-test _ci-gate-status-test _ci-enums-check _ci-enums-codegen-drift _ci-enums-codegen-drift-test \ _ci-py-fmt-check _ci-py-lint _ci-py-typecheck _ci-py-test _ci-py-stubtest # --------------------------------------------------------------------------- @@ -1178,6 +1186,7 @@ _ci-all: # ├── _pc-makefile-check # ├── _pc-actionlint # ├── _pc-snapshot-anchors +# ├── _pc-snapshot-anchors-test # ├── _pc-rustfmt-bail # ├── _pc-rustfmt-bail-test # ├── _pc-grammar-marker-sync @@ -1251,6 +1260,9 @@ _pc-actionlint: _pc-fmt _pc-snapshot-anchors: _pc-fmt $(MAKE) snapshot-anchors +_pc-snapshot-anchors-test: _pc-fmt + $(MAKE) snapshot-anchors-test + _pc-rustfmt-bail: _pc-fmt $(MAKE) rustfmt-bail @@ -1421,6 +1433,9 @@ _ci-actionlint: _ci-snapshot-anchors: $(MAKE) snapshot-anchors +_ci-snapshot-anchors-test: + $(MAKE) snapshot-anchors-test + _ci-rustfmt-bail: $(MAKE) rustfmt-bail diff --git a/bca.toml b/bca.toml index bdfd6326..027e7396 100644 --- a/bca.toml +++ b/bca.toml @@ -135,24 +135,34 @@ exclude = [ # limit is not over it, and a baseline entry keeps the growth alarm a # suppression marker would discard. # -# `nargs` deliberately stays at 7. #1143 proposed 7 -> 6 as a free -# ratchet on the strength of a hard-tier measurement: every offender at -# limit 6 is already baselined, so `bca check --threshold nargs=6` -# reports zero. But the soft tier scales every limit by BCA_HEADROOM -# (0.95), and 74 functions sit at exactly 6, so a limit of 6 puts all of -# them 0.3 below the band at once — they cannot ever clear it, because -# they are the limit. That is 74 baseline entries bought for no -# hard-tier gain, which is the "reads as debt rather than as a decision" -# outcome #1143 exists to avoid. The honest options are to stay at 7 or -# to do the real 6 -> 5 work. +# `nargs` is at the shipped default of 5, converged from 7 (#1183). # -# Re-measure with `bca check --explain-threshold nargs=6` (#1169) rather -# than a bare `--threshold`, which has no soft tier by design. What makes -# `nargs` different from the `cognitive` convergence above is where the -# limit falls in the distribution: nothing in this repo exceeds 6, so a -# limit of 6 sits on the population's ceiling and buys no hard-tier gate, -# whereas `cognitive` runs to 20 with 15 inside the distribution. The -# 6 -> 5 step is tracked separately in #1183. +# The convergence was declined twice before, and both refusals were +# right at the time. #1143 measured 7 -> 6 as a free ratchet on a +# hard-tier count and missed that the proportional soft tier would park +# 74 functions permanently in the band. #1183 then measured 6 -> 5 and +# found the offenders were mostly not parameter-heavy at all: the gate +# summed a function's own parameters with every nested closure's, so 59 +# of 76 would-be offenders were artifacts and one had a single parameter +# plus five from closures in its body. +# +# #1196 fixed that — the gate now counts a callable's own parameters, +# which is what RuboCop, ESLint, Clippy, lizard, SonarQube and Pylint all +# measure, and what this limit's own rationale in +# `default_thresholds.rs` was derived from. That removed the reason to +# stay at 7, and staying would have left a limit that caught nothing in +# the current tree: at 7 there were 6 offenders and all 6 were already +# baselined, which is the same "cannot catch anything" the book uses to +# argue the shipped default off 7. +# +# The remaining cost is the soft tier, and it is worth naming precisely. +# For an integer metric, a function sitting *exactly* at the limit is +# always above `0.95 x limit`, so it is always in the soft band — that is +# structural, not specific to 5, and it is true of the functions at +# exactly 7 today. What changes with the limit is only how many sit +# there: 91 at 5 against 10 at 7. That is the real content of #1143's +# "cluster" objection, and it is an argument about the soft tier's fit +# for small-integer metrics rather than about this number. cognitive = 15 cyclomatic = 15 "halstead.effort" = 50000 @@ -183,7 +193,7 @@ cyclomatic = 15 # limit, which is trimming-to-fit rather than a natural distribution. "loc.sloc" = 1300 nom = 30 -nargs = 7 +nargs = 5 nexits = 5 abc = 40 wmc = 60 diff --git a/big-code-analysis-book/src/commands/check.md b/big-code-analysis-book/src/commands/check.md index 4156f369..979eee10 100644 --- a/big-code-analysis-book/src/commands/check.md +++ b/big-code-analysis-book/src/commands/check.md @@ -508,6 +508,22 @@ Most-specific to least, `bca check` resolves exemptions in this order: `--print-effective-config` reports the resolved `check_exclude` globs alongside the other gate inputs. +Globs resolve against different roots depending on where they came +from: one you pass on the command line resolves against your working +directory, one from a `bca.toml` against that file's directory. The +resolved `check_exclude` array is the union of both and cannot express +that split, so a `manifest_check_exclude` key names the manifest-origin +subset, with the anchor being the reported `manifest` file's directory. +The walker's own `exclude` surface reports the same pair. Both keys are +omitted when no manifest contributed a glob. + +`manifest_exclude_from` and `manifest_check_exclude_from` follow the +same idea for the `exclude_from` *files*, with one difference worth +knowing: a command-line `--exclude-from` **replaces** the manifest's +file rather than adding to it, so the manifest key is present only when +the manifest's file is the one actually in effect. The inline glob +lists union; the files do not. + ## Baselines When you adopt thresholds on an existing codebase you typically face a diff --git a/big-code-analysis-book/src/metrics.md b/big-code-analysis-book/src/metrics.md index 52e2e9f2..7311a86a 100644 --- a/big-code-analysis-book/src/metrics.md +++ b/big-code-analysis-book/src/metrics.md @@ -143,10 +143,10 @@ application would over-count. | C, Go, Rust | `try` / `catch` omitted | No `try`/`catch` keyword in the grammar; error-handling uses `errno` / `Result` / `Result`-like sums. | | Ruby | `Rescue` substitutes for `catch` | Ruby's exception-handling keyword is `rescue`; the AST node `Rescue` plays the role of Java's `catch`. | | All languages | `default` / `_` wildcard arm excluded from the condition set | Fitzpatrick's Figure 2 lists `default`, but it falls through unconditionally — counting it would inflate `C` on every `switch` / `match` regardless of body. big-code-analysis omits it for every language (the Rust `_ =>` and Java `default:` arms included). | -| Tcl | Chain-operand unary conditions wired; bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` chain inside `expr {…}` counts as one condition, so `if {$a && $b}` reports two. The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | -| iRules | Chain-operand unary conditions wired (unlike its Tcl sibling); bare-truthy / argument / `return` slots are not | Each operand of a `&&` / `\|\|` / `and` / `or` chain counts as one condition (Rule 9), so `if {!$a && !$b}` reports two. iRules also recognises the word-form string-match comparators (`contains`, `starts_with`, `ends_with`, `equals`, `matches`, …) that Tcl lacks (Tcl's `eq` / `ne` / `in` / `ni` are shared). The broader Phase 2B slot routing is not wired, so a bare-truthy `if {$a}` still reports zero. | +| Tcl | Chain-operand, bare-truthy and ternary slots wired; argument and `return` slots are not | Each operand of a `&&` / `\|\|` chain inside `expr {…}` counts as one condition, so `if {$a && $b}` reports two. `if` / `elseif` / `while` route their `expr {…}` predicate, so a bare-truthy `if {$a}` reports one and `if {!$a}` likewise — matching C's `if (a)` (#1180). A predicate written as a command substitution is a truthy test of that command's result, so `if {[somecmd]}` also reports one, and the redundant `if {[expr {$a \|\| $b}]}` idiom reports three (the chain's two operands plus the substitution). The argument and `return` slots remain unrouted: a negation reached only through a standalone `expr {…}` command still reports zero. | +| iRules | Chain-operand, bare-truthy and ternary slots wired; argument and `return` slots are not | Each operand of a `&&` / `\|\|` / `and` / `or` chain counts as one condition (Rule 9), so `if {!$a && !$b}` reports two. iRules also recognises the word-form string-match comparators (`contains`, `starts_with`, `ends_with`, `equals`, `matches`, …) that Tcl lacks (Tcl's `eq` / `ne` / `in` / `ni` are shared). Bare-truthy and ternary slot routing matches its Tcl sibling exactly (#1180) — see that row for the shared detail, including the argument and `return` slots that remain unrouted. | | All Phase 2 languages (Java, Groovy, C#, Rust, Go, JavaScript, TypeScript, TSX, Mozjs, PHP, C, C++, Objective-C, Mozcpp, Python, Perl, Lua) | `if (true) {}`, `m(!a, !b)`, `return !x` count their operand(s) | Phase 2B routes `if` / `while` / `do-while` / argument-list / `return` slots through the same walker, so the rule applies uniformly across decision-bearing positions. A bare `return x` continues to report zero — Fitzpatrick treats an identifier in a return slot as a value, not a unary conditional. | -| Ternary slots: Java, Groovy, C#, C, C++, Objective-C, Mozcpp, JavaScript, TypeScript, TSX, Mozjs, PHP, Perl, Ruby, Python | `a ? !b : !c` counts its condition and both branch operands | The same walker also runs over a ternary's three operand slots, so `a ? !b : !c` reports 4 (the `?` plus three unary conditions) rather than 1. Python arrives at the same total by a different route: `not` operands are counted by the `NotOperator` rule wherever they appear, so only the condition slot is routed through the walker and `(not b) if a else (not c)` likewise reports 4. Languages with no ternary (Rust, Go, Kotlin, Lua, Elixir) are unaffected. Tcl and iRules do have one but do not yet route it — one more instance of the unwired Phase 2B slot routing described in their rows above (issue #1180). | +| Ternary slots: Java, Groovy, C#, C, C++, Objective-C, Mozcpp, JavaScript, TypeScript, TSX, Mozjs, PHP, Perl, Ruby, Python, Tcl, iRules | `a ? !b : !c` counts its condition and both branch operands | The same walker also runs over a ternary's three operand slots, so `a ? !b : !c` reports 4 (the `?` plus three unary conditions) rather than 1. Tcl and iRules reach the same 4 without grammar fields: their `ternary_expr` exposes none, and `_expr` inlines `( … )`, so the slots are located relative to the `?` and `:` tokens instead of by index (#1180). Python arrives at the same total by a different route: `not` operands are counted by the `NotOperator` rule wherever they appear, so only the condition slot is routed through the walker and `(not b) if a else (not c)` likewise reports 4. Languages with no ternary (Rust, Go, Kotlin, Lua, Elixir) are unaffected. | | Ruby | Bare-predicate `if` / `unless` / `while` / `until` (block and modifier forms) count one condition | Idiomatic Ruby favours bare predicates (`if flag`, `x if flag`); counting the condition slot keeps ABC conditions at or above Ruby's cyclomatic decision count (the alignment enforced across the other languages). A comparison (`if a == b`) or `&&` / `\|\|` chain in the predicate is counted by its own operator / walker arm and is not double-counted. | | Bash | `if` / `elif` / `while` and each non-wildcard `case` arm count one condition | A Bash predicate is a *command*, so the branch keyword itself — not an embedded boolean expression — is the condition signal. Each matches a Bash cyclomatic decision; the bare `*)` case arm (the analogue of `default:`) is excluded, mirroring the cyclomatic standard count. The arithmetic ternary `$(( a ? b : c ))` therefore contributes nothing: it carries no branch keyword, so it falls outside the rule set rather than through a gap in it. | | Kotlin | `try` counts a condition alongside `catch` | Fitzpatrick counts both keywords, and Java / C# / C++ / Groovy already count both; Kotlin previously counted only the catch block. | @@ -336,6 +336,15 @@ Sonar ecosystem. sense. It adds a surcharge *on top of* the enclosing nesting instead of replacing it, so a decision inside a lambda written inside an `if` is charged for both. +- That surcharge stops at the next function boundary. A function + *declared inside* a closure body opens a fresh lexical scope, so it + does not inherit the closure's surcharge — `fn g` inside a `|| { … }` + scores what it would score outside one. Until #1187 only the + JavaScript family applied this, so the same body scored differently + in Rust, Java, C++, PHP and C# depending on whether something two + levels up happened to be a closure. Python cannot express the shape + at all: a `def` is a statement and a lambda body is a single + expression. - **Python** charges a boolean operator an extra `+1` for each enclosing `lambda`, on top of the `+1` the boolean sequence itself earns. No other language does this. Only the outermost operator @@ -735,6 +744,37 @@ The implementation handles default arguments, variadic arguments, keyword-only arguments, and destructured parameters consistently per language. +### What the threshold gate measures {#nargs-gate} + +`bca check --threshold nargs=N` gates each callable on **its own** +parameter list, which is what every comparable tool measures — RuboCop +`Metrics/ParameterLists`, ESLint `max-params`, Clippy +`too_many_arguments`, lizard, SonarQube S107 and Pylint `R0913` all +count one callable at a time. + +Note that this is *not* the serialized `total`. The `function_args` and +`closure_args` keys above are subtree sums: a function that declares two +parameters and contains a three-parameter nested function reports +`function_args: 5`. Before +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196) the +gate read that sum, so a three-parameter function with a two-parameter +sort comparator was flagged at 5 — and the remediation its number +implied, fewer parameters, was not the one that would clear it. + +Nothing escapes the narrower rule. In the ten grammars whose closures +open their own space — Rust, JavaScript, TypeScript, TSX, MozJS, C#, Go, +PHP, Perl, Ruby, Lua and Elixir — a closure is gated on its own offender +row, which is also where its fix belongs. In Python, Java, Kotlin and +C++ a lambda opens no space, so its arguments can only be attributed to +the enclosing function; there the offender row shows the split: + +```text +small: nargs = 8 (1 own + 7 lambda) (limit 5) +``` + +so you can tell at a glance whether the lever is the signature or the +lambda. + ### Languages where it reads 0 {#nargs-language-gaps} A metric that silently reports 0 reads as "no offenders" rather than @@ -830,6 +870,18 @@ crate with many closures and few functions is typical of iterator-heavy code; a Python module with many functions and few closures is typical of script-style code. +Some constructs that carry executable code are deliberately counted by +neither field: Kotlin property accessors (`get()` / `set()`) and `init` +blocks, Java and Groovy `static { … }` initialisers, and JavaScript +class static blocks. Each opens a function *space* — so it has its own +complexity scores, `bca check` can flag it, and it contributes to WMC — +but none is a callable you name at a call site, and counting an accessor +as a method would make NPM bill the same property once as an attribute +and again as a method, skewing the NPA/NPM ratio the OOP metrics exist +to report. A Kotlin file of nothing but property accessors therefore +reports `nom.functions == 0` while still reporting their complexity +(#1184). + ### How to read it NOM is the input to several other metrics — WMC sums *cyclomatic* diff --git a/big-code-analysis-book/src/recipes/thresholds.md b/big-code-analysis-book/src/recipes/thresholds.md index 6761f89b..d5191c2c 100644 --- a/big-code-analysis-book/src/recipes/thresholds.md +++ b/big-code-analysis-book/src/recipes/thresholds.md @@ -71,7 +71,10 @@ who derive risk bands at the 70th, 80th, and 90th percentiles. Their 90th percen Two of the previous defaults failed that rule and changed as a result. `nargs` at 7 fired on under 1% of functions in the median language and on nothing at all in this project's own source, which -makes it a limit that cannot catch anything. `cognitive` at 25 was inherited from +makes it a limit that cannot catch anything. (That corpus measurement predates +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196), which narrowed `nargs` to a +function's own parameters. Re-measuring would move the figure *down*, and most for closure-heavy +languages, so the "cannot catch anything" conclusion holds a fortiori.) `cognitive` at 25 was inherited from [clippy](https://rust-lang.github.io/rust-clippy/master/#cognitive_complexity), which is deliberately conservative because it lints rather than gates; 15 is what the metric's designers chose. @@ -366,6 +369,36 @@ short of rewriting the signatures. The limit stayed at `7`; the honest alternati `6 → 5` work. ([#1143](https://github.com/dekobon/big-code-analysis/issues/1143), [#1169](https://github.com/dekobon/big-code-analysis/issues/1169).) +Those figures were measured before +[#1196](https://github.com/dekobon/big-code-analysis/issues/1196), when the `nargs` gate summed a +function's own parameters with every nested closure's. Most of that population was not +parameter-heavy at all: of the 76 functions a limit of `5` would have newly gated, only 17 had six +or more parameters of their own, and one had a single parameter plus five contributed by closures +in its body. Re-measured on the same tree under own-parameter gating: + +| `nargs` limit | hard offenders | new (unbaselined) | soft offenders | new | +|---|---|---|---|---| +| 7 (kept) | 6 | 0 | 16 | 0 | +| 6 | 16 | 0 | 38 | 17 | +| 5 | 38 | 17 | 129 | 99 | + +The cluster trap itself is unchanged and is the reason this section exists — 91 functions now sit +at exactly `5`, so a limit of `5` still puts every one of them permanently inside the soft band. +What changed is the size of the real work behind it: signatures rather than mixed cases. + +The convergence landed on that evidence. One thing measuring it clarified, which this section +previously got wrong: the cluster effect is **structural for every integer metric**, not a property +of landing on a population's mode. A function sitting exactly at limit `L` is always above +`0.95 x L`, so it is always in the soft band — the ten functions at exactly `7` were in it before +the change, just as the ninety-one at exactly `5` are after. What the limit choice controls is only +*how many* sit there. Read `#1143`'s objection that way: it was sound because the hard-tier gain +was zero, not because `6` was near a mode. + +That also means the soft tier carries little information for a small-integer metric, where +`0.95 x L` and `L` differ by less than one. It is doing real work for `halstead.effort` (50000 vs +47500) and almost none for `nargs` (5 vs 4.75, which flags every compliant function at the +ceiling). + `bca check --explain-threshold =` measures both tiers in one walk and names the cluster when it finds one, without touching `bca.toml`: diff --git a/big-code-analysis-cli/src/baseline_tests.rs b/big-code-analysis-cli/src/baseline_tests.rs index e3a6ab1a..12bbc18b 100644 --- a/big-code-analysis-cli/src/baseline_tests.rs +++ b/big-code-analysis-cli/src/baseline_tests.rs @@ -21,6 +21,7 @@ fn v(path: &str, function: &str, start_line: usize, metric: &'static str, value: lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -52,6 +53,7 @@ fn v_hashed( Violation { body_hash: Some(body_hash), suppressed: false, + nargs_split: None, ..v(path, function, start_line, metric, value) } } @@ -944,6 +946,7 @@ fn from_str_defensive_anchor_normalization() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }), Coverage::Covered { recorded } if recorded == 5.0 )); @@ -973,6 +976,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let violation_b = Violation { path: path_b, @@ -986,6 +990,7 @@ fn baseline_covers_distinguishes_non_utf8_paths() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; // Baseline contains only `path_a`. classify(violation_b) would diff --git a/big-code-analysis-cli/src/check_format.rs b/big-code-analysis-cli/src/check_format.rs index e4c83897..9d5c46dc 100644 --- a/big-code-analysis-cli/src/check_format.rs +++ b/big-code-analysis-cli/src/check_format.rs @@ -576,6 +576,11 @@ pub(crate) fn violation_to_offender(v: Violation) -> OffenderRecord { // offender formatters (`OffenderRecord::default_message`, // Code Climate severity), so it is not carried on the record. lower_is_worse: _, + // A presentation detail of the human row (`summary_tail`), not + // part of the machine record — the serialized offender already + // carries the value, and consumers that want the split can read + // the `nargs` block on the space itself. + nargs_split: _, // The body hash is an internal baseline-matching aid, not part // of the serialized offender record. body_hash: _, @@ -614,6 +619,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -661,6 +667,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let offender = violation_to_offender(v); assert_eq!(offender.path, path); @@ -682,6 +689,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -825,6 +833,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let mut buf = Vec::new(); write_github_annotations(&mut buf, std::iter::once(&v), DEFAULT_GITHUB_ANNOTATION_CAP) @@ -895,6 +904,7 @@ mod tests { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; let mut buf = Vec::new(); write_github_annotations(&mut buf, std::iter::once(&v), DEFAULT_GITHUB_ANNOTATION_CAP) diff --git a/big-code-analysis-cli/src/cli_args/mod.rs b/big-code-analysis-cli/src/cli_args/mod.rs index 3426139f..39d7fa40 100644 --- a/big-code-analysis-cli/src/cli_args/mod.rs +++ b/big-code-analysis-cli/src/cli_args/mod.rs @@ -112,20 +112,22 @@ pub(crate) struct WalkSelectionArgs { /// something from `bca check`'s threshold gate whichever way it is /// named, use `--check-exclude` / `[check] exclude` instead. /// - /// A relative glob given here is resolved against the directory you - /// ran `bca` from. One written in `bca.toml` is matched against - /// each file's path relative to the *walk root*, which is the - /// manifest's own directory only when the walk starts there — as it - /// does for the usual `paths = ["."]`. Naming a subdirectory - /// explicitly (`bca metrics -p sub`) moves the walk root and can - /// stop a manifest glob matching. `[check] exclude` does not have - /// this caveat: it anchors at the manifest whatever the caller - /// names. + /// The two sources anchor differently, and it is worth knowing which + /// you are writing. A glob given *here* is matched against each + /// file's path as the walk emits it — relative to the seed you + /// named — so `bca metrics -p sub -X './skipme/**'` excludes + /// `sub/skipme`, while `'./sub/skipme/**'` matches nothing. A glob + /// written in `bca.toml` is resolved against the manifest's own + /// directory instead, whatever path you name and wherever you run + /// from, so the bare `bca metrics` and `bca metrics -p sub` honour + /// the same manifest exclusions. `[check] exclude` anchors at the + /// manifest the same way. // The per-file agent hooks in the book's agent-feedback recipe are - // the caller this bit exists for; see #1146. The walk-root caveat - // above is #1189 — #1164 anchored the explicit-path warning and the - // `[check] exclude` gate at the manifest, but left the directory - // walk matching manifest globs against the walk root. + // the caller this bit exists for; see #1146. #1164 anchored the + // explicit-path warning and the `[check] exclude` gate at the + // manifest; #1189 did the same for the directory walk, which until + // then matched manifest globs against the walk root and so stopped + // excluding anything once a directory seed moved it. #[clap(long, short = 'X', num_args(1), action = clap::ArgAction::Append, help_heading = "Input selection")] pub(crate) exclude: Vec, /// Force a language instead of inferring from extension. Accepts a diff --git a/big-code-analysis-cli/src/commands/check.rs b/big-code-analysis-cli/src/commands/check.rs index 7099338b..32190f99 100644 --- a/big-code-analysis-cli/src/commands/check.rs +++ b/big-code-analysis-cli/src/commands/check.rs @@ -356,11 +356,13 @@ pub(crate) fn write_check_baseline( /// `bca.toml` sits at the root of. One list can carry only one anchor, /// and merging them silently gave the manifest half the caller's. /// -/// The mirroring is in the *split*, not in the matching: this type -/// anchors the manifest set always, whereas `WalkFilters` does so only -/// in its override warning, because a directory walk's root and the -/// manifest's coincide for the canonical `paths = ["."]`. Do not assume -/// the two apply the same rule. +/// The matching rule itself lives in +/// [`crate::walk_seed::AnchoredExcludes`], which the walker's filters +/// also use. It did not always: this type anchored the manifest set +/// always and `WalkFilters::passes` never did, on the reasoning that a +/// directory walk's root and the manifest's coincide — true only for +/// the canonical `paths = ["."]`, and the source of #1189. The two now +/// share one implementation and cannot drift again. struct CheckExcludes<'a> { cli: crate::ExcludeGlobs, manifest: crate::ExcludeGlobs, @@ -372,7 +374,16 @@ struct CheckExcludes<'a> { /// decide whether the file lies under the manifest, and `exempts` /// runs for every violation — so reading it there cost a syscall /// per offender on exactly the runs #1164 exists to serve. + /// + /// Owned rather than borrowed because `AnchoredExcludes` borrows it, + /// and a self-referential struct is not worth the alternative here; + /// `exempts` rebuilds the borrowed view per call, which is three + /// pointer copies. cwd: std::path::PathBuf, + /// `manifest_dir` resolved through symlinks, once per run — see + /// [`crate::walk_seed::ManifestAnchor`]'s `canonical_root`. `exempts` + /// runs per violation, so this must not be recomputed there. + canonical_manifest_dir: Option, } impl<'a> CheckExcludes<'a> { @@ -401,6 +412,7 @@ impl<'a> CheckExcludes<'a> { "bca.toml [check] exclude_from", ), manifest_dir: manifest.map(|m| m.dir.as_path()), + canonical_manifest_dir: manifest.and_then(|m| m.dir.canonicalize().ok()), cwd: std::env::current_dir().unwrap_or_default(), }) } @@ -408,23 +420,17 @@ impl<'a> CheckExcludes<'a> { /// Whether a violation at `path` — `walk_form` being its /// walk-root-anchored spelling — is exempt from the threshold gate. /// - /// The `is_empty()` arms are not redundant with the matches, for the - /// same reason [`crate::walk::WalkFilters::passes`] spells them out: - /// `GlobSet::is_match` builds its `Candidate` *before* its own empty - /// check. Only one of the two sets is configured in the usual case, - /// so the other should cost nothing. The manifest guard buys more - /// than that — it also skips `manifest_match_path`, which - /// normalises the path per violation. + /// Delegates to the shared rule, so the gate and the walker cannot + /// disagree about which files a manifest glob describes. fn exempts(&self, path: &Path, walk_form: crate::walk_seed::CwdForm<'_>) -> bool { - (!self.cli.is_empty() && self.cli.is_match(walk_form.0)) - || (!self.manifest.is_empty() - && self - .manifest - .is_match(crate::walk_seed::manifest_match_path( - crate::walk_seed::ManifestAnchor::resolve(self.manifest_dir, &self.cwd), - path, - walk_form, - ))) + crate::walk_seed::AnchoredExcludes::new( + &self.cli, + &self.manifest, + self.manifest_dir, + &self.cwd, + self.canonical_manifest_dir.as_deref(), + ) + .excludes(path, walk_form) } } diff --git a/big-code-analysis-cli/src/commands/check/effective_config.rs b/big-code-analysis-cli/src/commands/check/effective_config.rs index 3726b8f6..fea21d61 100644 --- a/big-code-analysis-cli/src/commands/check/effective_config.rs +++ b/big-code-analysis-cli/src/commands/check/effective_config.rs @@ -14,11 +14,46 @@ fn reported_globs(cli: &[String], manifest: Option<&ManifestExcludes>) -> Vec, manifest: Option<&ManifestExcludes>) -> Option { cli.or_else(|| manifest.and_then(|m| m.globs_from.as_deref())) .map(|p| p.display().to_string()) } +/// The manifest-origin subset of a reported exclude list (#1194). +/// +/// `--print-effective-config` is the surface consulted to answer "which +/// exemptions are in effect?", and after #1164 the answer depends on +/// where each glob came from: a CLI glob resolves against the caller's +/// working directory, a manifest glob against the `bca.toml` directory. +/// The flattened array cannot express that, so this names the subset +/// carrying the manifest anchor. The anchor itself is the `manifest` +/// key's parent directory, which is populated exactly when these are. +/// +/// Additive rather than a split, so the resolved list stays where it is +/// and the TOML form keeps round-tripping through `--config`. +fn manifest_globs(manifest: Option<&ManifestExcludes>) -> Vec { + manifest.map(|m| m.globs.clone()).unwrap_or_default() +} + +/// The manifest's own `exclude_from` file, when one is in effect. +/// +/// Present exactly when `exclude_from` above resolved to the manifest's +/// file, absent when a CLI flag replaced it — which is the provenance +/// question for this key: the two together say whether the effective +/// file came from the manifest or the command line. +fn manifest_globs_from(manifest: Option<&ManifestExcludes>) -> Option { + manifest + .and_then(|m| m.globs_from.as_deref()) + .map(|p| p.display().to_string()) +} + /// Serialize the resolved threshold/check configuration to stdout. /// Used by `--print-effective-config` to surface the post-merge view /// of every layer (`--config` TOML + repeated `--threshold` CLI @@ -128,16 +163,35 @@ pub(crate) struct EffectiveCheck { pub(crate) paths: Vec, pub(crate) include: Vec, pub(crate) exclude: Vec, + /// The subset of `exclude` that came from the manifest, and so + /// resolves against the `manifest` key's directory rather than the + /// caller's working directory (#1194). Empty when no manifest + /// contributed any glob. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) manifest_exclude: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) exclude_from: Option, + /// Set when `exclude_from` above came from the manifest rather than + /// from `--exclude-from`; absent when a CLI flag replaced it. The + /// pair answers where the effective file came from (#1194). + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) manifest_exclude_from: Option, /// Resolved `[check.exclude]` globs (#378): files analysed and /// reported but exempt from the gate. Empty when unset. #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) check_exclude: Vec, + /// The manifest-origin subset of `check_exclude` — the gate's half + /// of `manifest_exclude` above, and the case #1194 was filed about. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) manifest_check_exclude: Vec, /// Source file for additional `check_exclude` globs /// (`--check-exclude-from` / `[check] exclude_from`), if any. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) check_exclude_from: Option, + /// The manifest's own `[check] exclude_from` file; see + /// `manifest_exclude_from`. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) manifest_check_exclude_from: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) paths_from: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -199,28 +253,25 @@ fn resolved_limits(set: &ThresholdSet) -> BTreeMap { .collect() } -impl EffectiveConfig { - /// Project the resolved `ThresholdSet` + the original CLI args into - /// a serializable view. Paths are rendered with [`Path::display`] - /// because the printed config is informational; `--config` only - /// reads the `[thresholds]` table back, where keys/values are pure - /// ASCII metric names + numbers and round-trip exactly. - pub(crate) fn from_resolved( +impl EffectiveCheck { + /// Project the CLI args and any applied manifest into the + /// serializable `[check]` view. + /// + /// Its own constructor rather than a block inside + /// [`EffectiveConfig::from_resolved`]: that function built two + /// independent types, and this half is a flat projection whose + /// Halstead effort is entirely operand vocabulary — one unique + /// operand per field name, and no decisions at all. Splitting on + /// the boundary a reader would already draw puts each type’s + /// construction with its own type. + fn from_resolved( globals: &GlobalOpts, args: &CheckArgs, - resolved: &LanguageThresholds, manifest: Option<&Manifest>, tier: TierSpec, tiered_exit_codes: bool, ) -> Self { - let thresholds = EffectiveThresholds { - global: resolved_limits(resolved.global()), - lang: resolved - .languages() - .map(|(slug, set)| (slug, resolved_limits(set))) - .collect(), - }; - let check = EffectiveCheck { + Self { paths: globals .paths .iter() @@ -232,18 +283,22 @@ impl EffectiveConfig { // reader wants reported is the resolved set both halves add // up to. exclude: reported_globs(&globals.exclude, globals.manifest_excludes.as_ref()), + manifest_exclude: manifest_globs(globals.manifest_excludes.as_ref()), exclude_from: display_globs_from( globals.exclude_from.as_deref(), globals.manifest_excludes.as_ref(), ), + manifest_exclude_from: manifest_globs_from(globals.manifest_excludes.as_ref()), check_exclude: reported_globs( &args.check_exclude, args.manifest_check_exclude.as_ref(), ), + manifest_check_exclude: manifest_globs(args.manifest_check_exclude.as_ref()), check_exclude_from: display_globs_from( args.check_exclude_from.as_deref(), args.manifest_check_exclude.as_ref(), ), + manifest_check_exclude_from: manifest_globs_from(args.manifest_check_exclude.as_ref()), paths_from: globals.paths_from.as_ref().map(|p| p.display().to_string()), baseline: args.baseline.as_ref().map(|p| p.display().to_string()), config: args.config.as_ref().map(|p| p.display().to_string()), @@ -265,7 +320,32 @@ impl EffectiveConfig { }, baseline_line_tolerance: args.baseline_line_tolerance, baseline_fuzzy_match: args.baseline_fuzzy_match.unwrap_or(false), + } + } +} + +impl EffectiveConfig { + /// Project the resolved `ThresholdSet` + the original CLI args into + /// a serializable view. Paths are rendered with [`Path::display`] + /// because the printed config is informational; `--config` only + /// reads the `[thresholds]` table back, where keys/values are pure + /// ASCII metric names + numbers and round-trip exactly. + pub(crate) fn from_resolved( + globals: &GlobalOpts, + args: &CheckArgs, + resolved: &LanguageThresholds, + manifest: Option<&Manifest>, + tier: TierSpec, + tiered_exit_codes: bool, + ) -> Self { + let thresholds = EffectiveThresholds { + global: resolved_limits(resolved.global()), + lang: resolved + .languages() + .map(|(slug, set)| (slug, resolved_limits(set))) + .collect(), }; + let check = EffectiveCheck::from_resolved(globals, args, manifest, tier, tiered_exit_codes); Self { thresholds, check } } } diff --git a/big-code-analysis-cli/src/commands_tests.rs b/big-code-analysis-cli/src/commands_tests.rs index f34389bd..f106a90b 100644 --- a/big-code-analysis-cli/src/commands_tests.rs +++ b/big-code-analysis-cli/src/commands_tests.rs @@ -22,6 +22,7 @@ fn violation(path: &str, function: &str, value: f64, limit: f64) -> Violation { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -571,9 +572,13 @@ fn effective_config_toml_roundtrips_through_threshold_config_schema() { paths: vec!["src/".to_owned()], include: vec!["*.rs".to_owned()], exclude: vec!["target/".to_owned()], + manifest_exclude: Vec::new(), exclude_from: None, + manifest_exclude_from: None, check_exclude: Vec::new(), + manifest_check_exclude: Vec::new(), check_exclude_from: None, + manifest_check_exclude_from: None, paths_from: None, baseline: None, config: None, @@ -625,9 +630,13 @@ fn effective_config_json_serializes_threshold_overrides() { paths: Vec::new(), include: Vec::new(), exclude: Vec::new(), + manifest_exclude: Vec::new(), exclude_from: None, + manifest_exclude_from: None, check_exclude: Vec::new(), + manifest_check_exclude: Vec::new(), check_exclude_from: None, + manifest_check_exclude_from: None, paths_from: None, baseline: None, config: None, diff --git a/big-code-analysis-cli/src/html_report.rs b/big-code-analysis-cli/src/html_report.rs index d86bc95d..130bc10e 100644 --- a/big-code-analysis-cli/src/html_report.rs +++ b/big-code-analysis-cli/src/html_report.rs @@ -36,7 +36,7 @@ use std::fmt::Write; use big_code_analysis::{SpaceKind, SuppressionPolicy}; use crate::markdown_report::advisory::{AdvisoryCounts, AdvisoryThresholds}; -use crate::markdown_report::hotspot::{self, Align, Cell, HotspotSpec, SPECS, Source}; +use crate::markdown_report::hotspot::{self, Align, Cell, HotspotSpec, SPECS}; use crate::markdown_report::{ FunctionSummary, is_class_like, language_display_name, mi_rating, mi_weight_numerator, sloc_weighted_avg_mi, thousands, diff --git a/big-code-analysis-cli/src/html_report/sections.rs b/big-code-analysis-cli/src/html_report/sections.rs index 9efe1f35..052c6164 100644 --- a/big-code-analysis-cli/src/html_report/sections.rs +++ b/big-code-analysis-cli/src/html_report/sections.rs @@ -389,24 +389,6 @@ pub(crate) fn write_legend_html(out: &mut String, entries: &[(&str, &str)]) { let _ = out.write_str("\n\n"); } -/// Split a per-language slice into its unit (file) and function buckets -/// in a single pass. Class-likes are intentionally dropped from both -/// buckets — the WMC hotspot sources them straight from `entries`. -fn partition_by_kind<'a>( - entries: &[&'a FunctionSummary], -) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { - let mut units: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); - let mut funcs: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); - for &s in entries { - match s.kind { - SpaceKind::Unit => units.push(s), - SpaceKind::Function => funcs.push(s), - _ => {} - } - } - (units, funcs) -} - /// File-level roll-up backing one language's `

Summary

` note. /// Only `SpaceKind::Unit` summaries feed it. struct LanguageTotals { @@ -462,27 +444,44 @@ fn write_language_header(out: &mut String, headings: &mut Headings, lang_name: & let _ = out.write_str("\n"); } -fn write_language_summary( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, - units: &[&FunctionSummary], -) { +/// Where an HTML section is being written: the buffer, the heading +/// emitter that assigns and records ids, and the language slug those ids +/// are namespaced under. +/// +/// The three always travel together — every section writer below took +/// all three as its first arguments, and no caller has one without the +/// others, because they jointly answer "which language's section am I +/// appending to". Bundling them is what the parameter count was pointing +/// at: `nargs` counted six on three of these, and four of the six were +/// this one idea spelled out longhand. +struct HtmlSection<'a> { + out: &'a mut String, + headings: &'a mut Headings, + /// Slug-based id prefix for this language's `h3` headings, so a + /// hotspot table deep-links to e.g. `#rust-cyclomatic-complexity-hotspots`. + id_prefix: &'a str, +} + +fn write_language_summary(section: &mut HtmlSection<'_>, units: &[&FunctionSummary]) { let totals = LanguageTotals::from_units(units); let cr = totals.comment_ratio(); let avg_mi = totals.avg_mi(); let rating = mi_rating(avg_mi); - headings.emit_h3(out, &format!("{id_prefix}-summary"), "Summary"); + section.headings.emit_h3( + section.out, + &format!("{}-summary", section.id_prefix), + "Summary", + ); let _ = writeln!( - out, + section.out, "

Files: {} | SLOC: {} | PLOC: {} | Comment ratio: {cr:.1}%

", escape_html(&thousands(totals.files)), escape_html(&thousands(totals.sloc)), escape_html(&thousands(totals.ploc)), ); let _ = writeln!( - out, + section.out, "

{}: {avg_mi:.1} ({rating})

", crate::markdown_report::AVG_MI_LABEL ); @@ -495,9 +494,7 @@ fn write_language_summary( /// [`hotspot::HotspotTitle::render`]), it is a no-op for today's /// metachar-free titles. fn emit_html_section( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, spec: &HotspotSpec, top_n: usize, rows: &[&FunctionSummary], @@ -507,12 +504,12 @@ fn emit_html_section( // (" hotspots", without the `(top N by …)` clause), so the // fragment (`#rust-cyclomatic-complexity-hotspots`) does not shift with // `--top` (issue #677). The displayed text is the full rendered title. - headings.emit_h3( - out, - &format!("{id_prefix}-{}", spec.title.id_basis()), + section.headings.emit_h3( + section.out, + &format!("{}-{}", section.id_prefix, spec.title.id_basis()), &escape_html(&title), ); - write_hotspot_table(out, spec, rows); + write_hotspot_table(section.out, spec, rows); } /// The cyclomatic summary note under the CC hotspot table. A caption over the @@ -593,36 +590,36 @@ fn write_actionable_bullets( } fn write_actionable_summary( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, funcs: &[&FunctionSummary], policy: SuppressionPolicy, advisory: AdvisoryThresholds, ) { let counts = advisory.count_over(funcs); - headings.emit_h3( - out, - &format!("{id_prefix}-actionable-summary"), + section.headings.emit_h3( + section.out, + &format!("{}-actionable-summary", section.id_prefix), "Actionable Summary", ); // Provenance so the cutoffs are always attributable (issue #630). let _ = writeln!( - out, + section.out, "

{}

", escape_html(advisory.provenance_line()) ); let breakdown = hotspot::suppressed_metric_breakdown(funcs, policy, advisory); let _ = writeln!( - out, + section.out, "

{}

", escape_html(&hotspot::actionable_summary_caption(&breakdown)) ); if counts.all_clear() { - let _ = out.write_str("

No major quality concerns detected.

\n"); + let _ = section + .out + .write_str("

No major quality concerns detected.

\n"); return; } - write_actionable_bullets(out, counts, advisory); + write_actionable_bullets(section.out, counts, advisory); } /// Emit the section heading (`

` + id) followed by the "table omitted: all @@ -634,9 +631,7 @@ fn write_actionable_summary( /// summary bullet never points at a table absent from the document (issue /// #616). A no-op when `count == 0`. fn emit_fully_suppressed_note_html( - out: &mut String, - headings: &mut Headings, - id_prefix: &str, + section: &mut HtmlSection<'_>, spec: &HotspotSpec, top_n: usize, count: usize, @@ -649,13 +644,13 @@ fn emit_fully_suppressed_note_html( // `emit_html_section` uses, so a fully-suppressed section keeps its place // in the heading/id sequence and deep links resolve regardless of // suppression state (issue #681). The omission note is the section body. - headings.emit_h3( - out, - &format!("{id_prefix}-{}", spec.title.id_basis()), + section.headings.emit_h3( + section.out, + &format!("{}-{}", section.id_prefix, spec.title.id_basis()), &escape_html(&title), ); let _ = writeln!( - out, + section.out, "

{}

", escape_html(&hotspot::fully_suppressed_caption(&title, count)) ); @@ -675,51 +670,44 @@ pub(crate) fn write_language_section( // table deep-links to e.g. `#rust-cyclomatic-complexity-hotspots`. Built // from the raw language slug, matching the `h2` section id. let id_prefix = slugify(lang_name); - let (units, funcs) = partition_by_kind(entries); - write_language_summary(out, headings, &id_prefix, &units); + let section = &mut HtmlSection { + out, + headings, + id_prefix: &id_prefix, + }; + let (units, funcs) = hotspot::partition_by_kind(entries); + write_language_summary(section, &units); // The Actionable Summary leads the section (directly after Summary, // before any hotspot table) so a reader sees the highest-altitude counts // first (issue #678). - write_actionable_summary(out, headings, &id_prefix, &funcs, policy, advisory); + write_actionable_summary(section, &funcs, policy, advisory); // Drive every hotspot section from the shared `SPECS` table so the HTML // and Markdown reports cannot diverge in membership/order/suppression. - // WMC draws from the full slice, MI from units, the rest from functions. + // `select_for` owns the whole format-independent half, leaving the four + // emit calls below as the only thing this renderer decides (#1190). for spec in SPECS { - let base: &[&FunctionSummary] = match spec.source { - Source::Units => &units, - Source::Funcs => &funcs, - Source::All => entries, - }; - // Select first, emit second — same shape as the Markdown writer, and - // for the same reason: both selectors feed identical empty-table - // handling, so hoisting it leaves one copy of that rule instead of two. - let (rows, cc_stats) = if spec.cc_note { - let (rows, stats) = hotspot::select_cc(spec, base, top_n, policy, advisory); - (rows, Some(stats)) - } else { - (hotspot::select(spec, base, top_n, policy, advisory), None) - }; - if rows.is_empty() { - // An empty table is either a genuinely-absent metric or a table - // whose every matching row was suppressed; only the latter earns - // a caption, or the Actionable Summary's bullets dangle (#616). - let suppressed = hotspot::fully_suppressed_count(spec, base, policy, advisory); - emit_fully_suppressed_note_html(out, headings, &id_prefix, spec, top_n, suppressed); - continue; - } - emit_html_section(out, headings, &id_prefix, spec, top_n, &rows); - if let Some(stats) = cc_stats { - emit_cc_note_html(out, &stats, policy); - } else if spec.mi_note { - let _ = writeln!( - out, - "

{}

", - escape_html(hotspot::MI_NOTE) - ); + match hotspot::select_for(spec, &units, &funcs, entries, top_n, policy, advisory) { + hotspot::SpecOutcome::FullySuppressed(suppressed) => { + emit_fully_suppressed_note_html(section, spec, top_n, suppressed); + } + hotspot::SpecOutcome::Rows { rows, cc_stats } => { + emit_html_section(section, spec, top_n, &rows); + if let Some(stats) = cc_stats { + emit_cc_note_html(section.out, &stats, policy); + } else if spec.mi_note { + // HTML wraps the note in a styled `

`; the Markdown + // writer has a dedicated emitter for the same text. + let _ = writeln!( + section.out, + "

{}

", + escape_html(hotspot::MI_NOTE) + ); + } + } } } - let _ = out.write_str("\n"); + let _ = section.out.write_str("\n"); } diff --git a/big-code-analysis-cli/src/markdown_report.rs b/big-code-analysis-cli/src/markdown_report.rs index 6fd147f4..86c60a86 100644 --- a/big-code-analysis-cli/src/markdown_report.rs +++ b/big-code-analysis-cli/src/markdown_report.rs @@ -196,7 +196,14 @@ fn extract_summaries_inner( mi_original: m.mi.original(), mi_sei: m.mi.sei(), mi_visual_studio: m.mi.visual_studio(), - nargs: m.nargs.total() as usize, + // The space's OWN parameters, matching what + // `bca check --threshold nargs=N` gates on (#1196). Both + // surfaces read the same `[thresholds] nargs` key — the + // report through `AdvisoryThresholds::from_manifest_hard` — + // so reading different quantities let `bca check` exit 0 + // while `bca report` flagged the same function under "Many + // parameters" for a number the gate no longer enforced. + nargs: (m.nargs.function_args() + m.nargs.closure_args()) as usize, nexits: m.nexits.nexits_sum() as usize, nom: m.nom.total() as usize, abc: m.abc.magnitude(), @@ -1000,7 +1007,7 @@ fn write_language_section( let display_name = language_display_name(lang_name); let _ = writeln!(out, "\n## {display_name}\n"); - let (units, funcs) = sections::split_units_and_functions(entries); + let (units, funcs) = hotspot::partition_by_kind(entries); sections::write_summary(out, &units); // The Actionable Summary is the highest-altitude block (the @@ -1012,36 +1019,24 @@ fn write_language_section( // Drive every hotspot section from the shared `SPECS` table (the same // table the HTML report uses) so the two formats cannot diverge in - // membership/order/suppression. WMC draws from the full slice, MI from - // units, the rest from functions. + // membership/order/suppression. `select_for` owns the whole + // format-independent half, leaving the four emit calls below as the + // only thing this renderer decides (#1190). for spec in hotspot::SPECS { - let base: &[&FunctionSummary] = match spec.source { - hotspot::Source::Units => &units, - hotspot::Source::Funcs => &funcs, - hotspot::Source::All => entries, - }; - // Select first, emit second. Both selectors feed the same - // empty-table handling, so hoisting it out of the CC/non-CC split - // leaves one copy of that rule instead of two identical ones. - let (rows, cc_stats) = if spec.cc_note { - let (rows, stats) = hotspot::select_cc(spec, base, top_n, policy, advisory); - (rows, Some(stats)) - } else { - (hotspot::select(spec, base, top_n, policy, advisory), None) - }; - if rows.is_empty() { - // An empty table is either a genuinely-absent metric or a table - // whose every matching row was suppressed; only the latter earns - // a caption, or the Actionable Summary's bullets dangle (#616). - let suppressed = hotspot::fully_suppressed_count(spec, base, policy, advisory); - sections::emit_fully_suppressed_note_md(out, &spec.title.render(top_n), suppressed); - continue; - } - sections::emit_section_md(out, spec, top_n, &rows); - if let Some(stats) = cc_stats { - sections::emit_cc_note_md(out, &stats, policy); - } else if spec.mi_note { - sections::emit_mi_note_md(out); + match hotspot::select_for(spec, &units, &funcs, entries, top_n, policy, advisory) { + hotspot::SpecOutcome::FullySuppressed(suppressed) => { + // Markdown renders the caption from the title text; the + // HTML writer needs the spec itself, for the heading id. + sections::emit_fully_suppressed_note_md(out, &spec.title.render(top_n), suppressed); + } + hotspot::SpecOutcome::Rows { rows, cc_stats } => { + sections::emit_section_md(out, spec, top_n, &rows); + if let Some(stats) = cc_stats { + sections::emit_cc_note_md(out, &stats, policy); + } else if spec.mi_note { + sections::emit_mi_note_md(out); + } + } } } } @@ -2468,6 +2463,84 @@ mod tests { ); } + /// The two renderers must agree on *which* sections a language gets + /// and which are fully suppressed — the selection half, which + /// `hotspot::select_for` now owns (#1190). + /// + /// The existing cross-format guard below checks column membership + /// only. That would not have noticed the two copies of the selection + /// logic diverging, which is the divergence the extraction removes + /// the possibility of; a structural guard should not rest on a + /// property no test covers. + /// + /// Both outcomes have to appear in one fixture, or the assertion + /// holds for a renderer that emits every section or none: `hot` + /// surfaces in the function tables, while `quiet` is suppressed and + /// is the sole member of its own, so that section comes back + /// `FullySuppressed`. + #[test] + fn both_formats_select_the_same_sections() { + use crate::html_report::generate_html_report; + + let unit = make_summary("lib.rs", "src/lib.rs", SpaceKind::Unit, LANG::Rust); + let mut hot = make_summary("hot", "src/lib.rs", SpaceKind::Function, LANG::Rust); + hot.halstead_effort = 200.0; + hot.nargs = 4; + hot.cyclomatic = 12.0; + let mut quiet = make_summary("quiet", "src/lib.rs", SpaceKind::Function, LANG::Rust); + quiet.nexits = 9; + quiet.suppressed = + big_code_analysis::SuppressionScope::Some(std::collections::BTreeSet::from([ + big_code_analysis::Metric::Nexits, + ])); + let fixture = [unit, hot, quiet]; + + let md = generate_report(&fixture, 20, SuppressionPolicy::Honor); + let html = generate_html_report(&fixture, 20, SuppressionPolicy::Honor); + + let mut seen_table = 0; + let mut seen_suppressed = 0; + for spec in hotspot::SPECS { + let title = spec.title.render(20); + let md_has_table = md.contains(&format!("### {title}")); + // The HTML heading id is derived per language, so match the + // rendered title inside an `h3` close rather than the id. + let html_has_table = html.match_indices(&format!(">{title}")).any(|(i, _)| { + html[i..] + .find('<') + .is_some_and(|lt| html[i + lt..].starts_with("

")) + }); + assert_eq!( + md_has_table, html_has_table, + "formats disagree on whether {title:?} renders a table" + ); + + let caption = hotspot::fully_suppressed_caption(&title, 1); + let md_suppressed = md.contains(&caption); + let html_suppressed = html.contains(&caption); + assert_eq!( + md_suppressed, html_suppressed, + "formats disagree on whether {title:?} is fully suppressed" + ); + + // A fully-suppressed section emits the same `### {title}` + // heading (`emit_fully_suppressed_note_md`), so the heading + // alone cannot tell the two arms apart — count a rendered + // *table* only when the section is not the suppressed kind, + // or this guard passes on a fixture that exercised only the + // `FullySuppressed` arm. + seen_table += usize::from(md_has_table && !md_suppressed); + seen_suppressed += usize::from(md_suppressed); + } + // Without both outcomes present the comparisons above are + // vacuously equal for a renderer that emits nothing at all. + assert!(seen_table > 0, "fixture produced no hotspot table"); + assert!( + seen_suppressed > 0, + "fixture produced no fully-suppressed section, so that arm is untested" + ); + } + /// Issue #628: the Halstead Effort and Many-Parameters tables were the /// only per-function hotspots missing the `Line` column. Both renderers /// draw from the shared `hotspot::SPECS`, so one fix covers both — assert diff --git a/big-code-analysis-cli/src/markdown_report/hotspot.rs b/big-code-analysis-cli/src/markdown_report/hotspot.rs index cfdaaa1f..cb973e6c 100644 --- a/big-code-analysis-cli/src/markdown_report/hotspot.rs +++ b/big-code-analysis-cli/src/markdown_report/hotspot.rs @@ -967,6 +967,87 @@ pub(crate) fn suppressed_metric_breakdown( .collect() } +/// Partition `entries` by `SpaceKind` into (units, functions) in a +/// single pass. +/// +/// Class-likes land in neither bucket — the WMC hotspot sources them +/// straight from `entries` via [`Source::All`]. Lived twice, once per +/// renderer, with the same signature and semantics and two different +/// implementations (#1190). +pub(crate) fn partition_by_kind<'a>( + entries: &[&'a FunctionSummary], +) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { + let mut units: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); + let mut funcs: Vec<&FunctionSummary> = Vec::with_capacity(entries.len()); + for &s in entries { + match s.kind { + big_code_analysis::SpaceKind::Unit => units.push(s), + big_code_analysis::SpaceKind::Function => funcs.push(s), + _ => {} + } + } + (units, funcs) +} + +/// What one [`HotspotSpec`] resolved to for a language: rows to emit, or +/// nothing to emit and why. +/// +/// The distinction is the whole reason this is an enum rather than an +/// empty `Vec`. An empty table is either a genuinely-absent metric or a +/// table whose every matching row was suppressed, and only the latter +/// earns a caption — without it the Actionable Summary's bullets dangle +/// (#616). +pub(crate) enum SpecOutcome<'a> { + /// Rows survived selection. `cc_stats` is `Some` exactly for a spec + /// carrying `cc_note`, computed over the full suppression-filtered + /// set before truncation. + Rows { + rows: Vec<&'a FunctionSummary>, + cc_stats: Option, + }, + /// Every matching row was suppressed; the payload is how many. + FullySuppressed(usize), +} + +/// Resolve one spec against a language's entries — the whole +/// format-independent half of rendering a hotspot section. +/// +/// Both renderers ran this identically: pick the source slice, run the +/// CC or plain selector, and on an empty result count the suppressed +/// rows. A comment in each claimed the two could not diverge; this makes +/// that structural rather than asserted (#1190). What stays per-format +/// is the emit half, which really is the only part that differs. +pub(crate) fn select_for<'a>( + spec: &HotspotSpec, + units: &[&'a FunctionSummary], + funcs: &[&'a FunctionSummary], + entries: &[&'a FunctionSummary], + top_n: usize, + policy: SuppressionPolicy, + advisory: AdvisoryThresholds, +) -> SpecOutcome<'a> { + // WMC draws from the full slice, MI from units, the rest from + // functions. + let base: &[&FunctionSummary] = match spec.source { + Source::Units => units, + Source::Funcs => funcs, + Source::All => entries, + }; + // Select first, emit second: both selectors feed the same empty-table + // handling, so hoisting it out of the CC/non-CC split leaves one copy + // of that rule rather than two identical ones. + let (rows, cc_stats) = if spec.cc_note { + let (rows, stats) = select_cc(spec, base, top_n, policy, advisory); + (rows, Some(stats)) + } else { + (select(spec, base, top_n, policy, advisory), None) + }; + if rows.is_empty() { + return SpecOutcome::FullySuppressed(fully_suppressed_count(spec, base, policy, advisory)); + } + SpecOutcome::Rows { rows, cc_stats } +} + /// Like [`select`] but also returns the cyclomatic stats over the FULL /// suppression-filtered set (before truncation), for the CC note. pub(crate) fn select_cc<'a>( diff --git a/big-code-analysis-cli/src/markdown_report/sections.rs b/big-code-analysis-cli/src/markdown_report/sections.rs index 33f3a410..1d1dacff 100644 --- a/big-code-analysis-cli/src/markdown_report/sections.rs +++ b/big-code-analysis-cli/src/markdown_report/sections.rs @@ -9,7 +9,7 @@ use std::fmt::Write as _; -use big_code_analysis::{SpaceKind, SuppressionPolicy}; +use big_code_analysis::SuppressionPolicy; use super::advisory::AdvisoryThresholds; use super::hotspot::{Cell, CyclomaticStats, HotspotSpec}; @@ -231,22 +231,3 @@ pub(super) fn emit_fully_suppressed_note_md(out: &mut String, title: &str, count super::hotspot::fully_suppressed_caption(title, count) ); } - -/// Partition `entries` by `SpaceKind` into (units, functions). The -/// `units` slice drives the file-level summary and MI section; the -/// `funcs` slice drives all per-function hotspot tables. -pub(super) fn split_units_and_functions<'a>( - entries: &[&'a FunctionSummary], -) -> (Vec<&'a FunctionSummary>, Vec<&'a FunctionSummary>) { - let units: Vec<&FunctionSummary> = entries - .iter() - .filter(|s| s.kind == SpaceKind::Unit) - .copied() - .collect(); - let funcs: Vec<&FunctionSummary> = entries - .iter() - .filter(|s| s.kind == SpaceKind::Function) - .copied() - .collect(); - (units, funcs) -} diff --git a/big-code-analysis-cli/src/thresholds.rs b/big-code-analysis-cli/src/thresholds.rs index 64d91c0a..0d499bc8 100644 --- a/big-code-analysis-cli/src/thresholds.rs +++ b/big-code-analysis-cli/src/thresholds.rs @@ -75,13 +75,22 @@ struct MetricExtractor { /// /// - *Per-space own* value — `cognitive()`, `cyclomatic()`, /// `cyclomatic_modified()`, the `halstead.*`, `mi.*`, `loc.*`, and -/// `abc` accessors. These read the value for the single function space -/// under test, without rolling up nested children. +/// `abc` accessors, plus `nargs`'s `function_args() + closure_args()`. +/// These read the value for the single function space under test, +/// without rolling up nested children. /// - *Sum / total over the subtree* — `tokens_sum()`, `nexits_sum()`, -/// `nom.total()`, `nargs.total()`, `wmc.total_wmc()`, `npm.total_npm()`, +/// `nom.total()`, `wmc.total_wmc()`, `npm.total_npm()`, /// `npa.total_npa()`. These aggregate the space and its descendants, so /// a threshold on, e.g., `nom` bounds the whole subtree's method count. /// +/// `nargs` moved from the second group to the first in #1196. It is the +/// one metric whose subtree sum was actively misleading: a closure's +/// parameters are not part of the enclosing function's signature, and +/// every comparable tool counts one callable at a time. Note the +/// serialized `nargs` keys are still subtree sums — only the gate's +/// reading changed — so this is also the one entry where the extractor +/// and the JSON field of the same name disagree. +/// /// The split follows each metric's library accessor and its natural unit /// of measurement; it is intentional, not an oversight. When adding a new /// extractor, pick the accessor that matches the metric's reported figure @@ -175,7 +184,31 @@ const EXTRACTORS: &[MetricExtractor] = &[ }, MetricExtractor { name: "nargs", - extract: |m| m.nargs.total() as f64, + // The space's OWN parameters, not `total()` (#1196). `total()` is + // `function_args_sum() + closure_args_sum()` — subtree sums — so a + // function was gated on its own parameters *plus every nested + // closure's*. `write_top_offenders` has three parameters and was + // reported at 6 because a sort comparator and a format closure + // contributed three more, and the remediation the number implied + // (fewer parameters) was not the one that would clear it. + // + // Nothing escapes. A closure that opens its own space — Rust, + // the JS family, C#, Go, PHP, Perl, Ruby, Lua, Elixir — is gated + // on its own row, which is also where its fix goes. Where the + // closure's arguments fold into the enclosing function instead + // (Python, Java, Kotlin, Groovy, C++/Mozcpp) they land in that + // space's own `closure_args`, which is the only attribution + // available and is why the term is added here rather than + // dropped. + // + // This is what every comparable tool measures — RuboCop + // `Metrics/ParameterLists`, ESLint `max-params`, Clippy + // `too_many_arguments`, lizard, SonarQube S107, Pylint R0913 all + // count a callable's own formal parameters. Two of those are the + // anchors `default_thresholds.rs` derives the shipped limit from, + // so before this the default was calibrated against a different + // quantity than the gate enforced. + extract: |m| (m.nargs.function_args() + m.nargs.closure_args()) as f64, metric: Metric::Nargs, }, MetricExtractor { @@ -428,6 +461,29 @@ pub(crate) fn threshold_scalar( } } +/// The `own + lambda` split behind an `nargs` value. +/// +/// Carried only where it is not already obvious. In the grammars whose +/// closures open their own space — Rust, JavaScript, TypeScript, TSX, +/// MozJS, C#, Go, PHP, Perl, Ruby, Lua and Elixir — a closure is gated +/// on its own row and the row's number *is* its signature, so there is +/// nothing to split. In Python, Java, Kotlin, Groovy and C++/Mozcpp the +/// closure's arguments fold into the enclosing function instead, which +/// is the only attribution available: `small` there can declare one +/// parameter and be reported at 8. +/// +/// A struct rather than a `(u64, u64)`, because the two are same-typed, +/// not interchangeable, and transposing them would print a fluent lie +/// (`AGENTS.md`, "do not pass two same-typed primitives where they could +/// be confused"). +#[derive(Debug, Clone, Copy)] +pub(crate) struct NargsSplit { + /// Parameters the offending function declares itself. + pub(crate) own: u64, + /// Parameters contributed by lambdas that open no space of their own. + pub(crate) lambda: u64, +} + /// One offending `(function, metric)` pair. #[derive(Debug, Clone)] pub(crate) struct Violation { @@ -493,6 +549,12 @@ pub(crate) struct Violation { /// `false` under the default policy (suppressed offenders are dropped) /// and under `--no-suppress` (markers ignored, so nothing is suppressed). pub(crate) suppressed: bool, + /// For an `nargs` violation whose value includes spaceless-lambda + /// arguments, the split to show the reader (#1196). `None` for every + /// other metric, and for an `nargs` value that is purely the + /// function's own parameter list — which is the usual case, and the + /// one where a parenthetical would be noise. + pub(crate) nargs_split: Option, } impl Violation { @@ -505,11 +567,21 @@ impl Violation { /// this method so their message body stays in lockstep with the /// human stderr line. pub(crate) fn summary_tail(&self) -> String { + // The split is appended, never substituted for the value: the + // number stays the thing compared against the limit, and tooling + // that parses the row keeps finding it in the same position. + let split = match self.nargs_split { + Some(NargsSplit { own, lambda }) => { + format!(" ({own} own + {lambda} lambda)") + } + None => String::new(), + }; format!( - "{}: {} = {} (limit {})", + "{}: {} = {}{} (limit {})", self.function, self.metric, MetricScalar(self.value), + split, MetricScalar(self.limit), ) } @@ -729,6 +801,31 @@ impl ResolvedThreshold { if suppressed && !ctx.report_suppressed { return None; } + // Only `nargs`, and only where the number mixes two sources the + // reader would otherwise conflate (#1196). + // + // The discriminator cannot come from the counts. A closure's own + // space has `function_args() == 0, closure_args() == N` — and so + // does a *zero-parameter function* containing spaceless lambdas. + // The first is not a mix at all: `N` is that closure's own + // parameter list, and `(0 own + N lambda)` on it would be noise. + // The second is exactly the misleading row this exists to fix, + // and gating on `own > 0` hid it precisely there. + // + // What separates them is the subject, not the arithmetic: a + // closure space carries no name of its own. `` is that + // signal — the synthesised names #1184 added (``, + // ``, …) are function-like and do want the split. + let nargs_split = (self.extractor.name == "nargs") + .then(|| { + let (own, lambda) = ( + space.metrics.nargs.function_args(), + space.metrics.nargs.closure_args(), + ); + let is_own_closure_space = space.name.as_deref() == Some(""); + (lambda > 0 && !is_own_closure_space).then_some(NargsSplit { own, lambda }) + }) + .flatten(); Some(Violation { path: path.to_path_buf(), start_line: space.start_line, @@ -737,6 +834,7 @@ impl ResolvedThreshold { metric: self.extractor.name, value, limit: self.limit, + nargs_split, hard_limit: self.hard_limit, lower_is_worse: self.lower_is_worse, body_hash: None, diff --git a/big-code-analysis-cli/src/thresholds_tests.rs b/big-code-analysis-cli/src/thresholds_tests.rs index 2466a507..560cf333 100644 --- a/big-code-analysis-cli/src/thresholds_tests.rs +++ b/big-code-analysis-cli/src/thresholds_tests.rs @@ -353,6 +353,7 @@ fn violation_display_is_stable() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; assert_eq!( v.to_string(), @@ -374,6 +375,7 @@ fn violation_display_keeps_fractional_precision() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; assert!(v.to_string().contains("= 12.5"), "{v}"); assert!(v.to_string().contains("limit 10)"), "{v}"); @@ -410,6 +412,7 @@ fn violation_path_preserves_non_utf8_bytes() { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, }; // Raw bytes round-trip identically — no lossy substitution. @@ -692,6 +695,7 @@ fn sample_violation() -> Violation { lower_is_worse: false, body_hash: None, suppressed: false, + nargs_split: None, } } @@ -1009,6 +1013,7 @@ fn mi_ratio_inverts_so_lower_value_ranks_worse() { lower_is_worse: true, body_hash: None, suppressed: false, + nargs_split: None, }; let mild = Violation { value: 45.0, diff --git a/big-code-analysis-cli/src/walk.rs b/big-code-analysis-cli/src/walk.rs index a89b3b76..3b505acf 100644 --- a/big-code-analysis-cli/src/walk.rs +++ b/big-code-analysis-cli/src/walk.rs @@ -105,18 +105,12 @@ pub(crate) fn valid_languages() -> String { /// convention (empty globset = no-op) with the patterns it applies to. pub(crate) struct WalkFilters<'a> { include: &'a GlobSet, - exclude: &'a ExcludeGlobs, - /// The manifest's own `exclude` globs, compiled separately from the - /// caller's so they can keep the manifest directory as their anchor - /// (#1164). A directory walk matches both against the walk-root - /// form — for the canonical `paths = ["."]` the two roots are the - /// same directory — but an explicitly-named file seed has no walk - /// root, so [`Self::warn_exclude_overridden`] anchors this set at - /// `manifest_dir` instead of at the caller's working directory. - manifest_exclude: &'a ExcludeGlobs, - /// Directory holding the `bca.toml` that supplied - /// `manifest_exclude`; `None` when no manifest applied. - manifest_dir: Option<&'a Path>, + /// Both exclude sets plus the roots they resolve against. One type + /// rather than a `{cli, manifest, manifest_dir}` triple per call + /// site: the three sites that spell this rule used to give three + /// different answers about anchoring, which is what let a manifest + /// glob stop applying under a directory seed (#1189). + excludes: walk_seed::AnchoredExcludes<'a>, /// Whether `--language` forces a language, which makes every named /// file analyzable regardless of its extension. Only the /// exclude-override warning consults it — see @@ -132,23 +126,22 @@ impl WalkFilters<'_> { /// to the file's walk root (#489), so `./`-anchored patterns match /// regardless of how the seed was spelled. /// - /// Both exclude sets are matched against that same walk-root form. - /// Only an explicitly-named seed needs the manifest anchoring - /// [`Self::warn_exclude_overridden`] applies — see the - /// `manifest_exclude` field for why the two roots coincide on a - /// directory walk. + /// Each exclude set is matched at its own root — see + /// [`walk_seed::AnchoredExcludes`]. Matching the manifest set at the + /// walk root instead is correct only when the two coincide, which + /// they do for the canonical `paths = ["."]` and do not for a + /// directory seed: `bca metrics -p sub` moved the walk root to `sub` + /// and every manifest glob stopped applying (#1189). /// - /// The `is_empty()` arms are not redundant with the match itself: - /// `GlobSet::is_match` builds its `Candidate` *before* its own - /// empty check, and on Windows that allocates. Every walked file - /// reaches this, so an unconfigured set should cost nothing. - fn passes(&self, match_path: &Path) -> bool { + /// `path` is the file as the walk found it, needed because manifest + /// anchoring resolves against the `bca.toml` directory rather than + /// against the walk root the `match_path` form carries. + fn passes(&self, path: &Path, match_path: &Path) -> bool { // Strip a leading `./` so bare-relative patterns (`dir/**`) match // the `./`-anchored walk-root form just like `./dir/**` does (#726). let match_path = walk_seed::strip_cur_dir(match_path); (self.include.is_empty() || self.include.is_match(match_path)) - && (self.exclude.is_empty() || !self.exclude.is_match(match_path)) - && (self.manifest_exclude.is_empty() || !self.manifest_exclude.is_match(match_path)) + && !self.excludes.excludes(path, walk_seed::CwdForm(match_path)) } /// Does `match_path` satisfy only the include allow-list (empty = @@ -198,22 +191,12 @@ impl WalkFilters<'_> { return; } let cwd_form = walk_seed::CwdForm(walk_seed::strip_cur_dir(match_path)); - // `match_path` is anchored to the working directory, which is - // the right root only for the globs the caller typed there. A - // manifest glob is written against the manifest's directory, so - // matching it against the CWD form left this warning silent for - // every caller standing anywhere but the project root — silent - // exactly when the override it exists to announce happens - // (#1164). - // Computed inside the `or_else` because anchoring costs a - // `current_dir()` syscall and a normalising allocation, and the - // CLI set answering first makes both unnecessary. - if let Some(glob) = self.exclude.first_match(cwd_form.0).or_else(|| { - let cwd = std::env::current_dir().unwrap_or_default(); - let anchor = walk_seed::ManifestAnchor::resolve(self.manifest_dir, &cwd); - let manifest_form = walk_seed::manifest_match_path(anchor, seed, cwd_form); - self.manifest_exclude.first_match(&manifest_form) - }) { + // Each set at its own root, via the same rule `passes` applies — + // a manifest glob is written against the manifest's directory, + // so matching it against the CWD form left this warning silent + // for every caller standing anywhere but the project root, which + // is exactly when the override it announces happens (#1164). + if let Some(glob) = self.excludes.first_match(seed, cwd_form) { eprintln!( "bca: warning: {} matches an exclude pattern ({glob}) \ but was named explicitly; analyzing anyway", @@ -491,7 +474,7 @@ fn visit_walk_entry( // Anchor the glob match to the walk root rather than the emitted (possibly // absolute) path, so `./`-anchored excludes match regardless of how the // seed resolved — including a manifest root above the CWD (#489). - if filters.passes(&walk_seed::match_path_for(seed, &path)) { + if filters.passes(&path, &walk_seed::match_path_for(seed, &path)) { // The receiver outlives the walk (it is drained by the caller), so // this cannot fail. let _ = tx.send(path); @@ -605,14 +588,28 @@ pub(crate) fn resolve_walk_files(globals: GlobalOpts) -> (ResolvedFiles, usize) "bca.toml exclude_from", ); let num_jobs = globals.num_jobs.resolve(); + // Read once, not per walked file: `passes` runs for every entry and + // `current_dir()` is a syscall. It cannot be cached process-wide + // either — `bca diff`'s directory guard moves the cwd — so once per + // walk is the right granularity (#1189). + let cwd = std::env::current_dir().unwrap_or_default(); + // An empty set never matches, so there is no anchor worth supplying — + // and the no-manifest default above has no directory to offer in the + // first place. + let manifest_dir = (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()); + // Once per walk, not once per file: the exclude match runs for every + // entry, and a project reached through a symlink would otherwise pay + // two `canonicalize` syscalls per file inside `relative_tail`. + let canonical_manifest_dir = manifest_dir.and_then(|dir| dir.canonicalize().ok()); let filters = WalkFilters { include: &include, - exclude: &exclude, - manifest_exclude: &manifest_exclude, - // An empty set never matches, so there is no anchor worth - // supplying — and the no-manifest default above has no - // directory to offer in the first place. - manifest_dir: (!manifest_exclude.is_empty()).then_some(manifest_excludes.dir.as_path()), + excludes: walk_seed::AnchoredExcludes::new( + &exclude, + &manifest_exclude, + manifest_dir, + &cwd, + canonical_manifest_dir.as_deref(), + ), language_forced: globals.language.is_some(), }; let resolved = expand_seed_paths( diff --git a/big-code-analysis-cli/src/walk_seed.rs b/big-code-analysis-cli/src/walk_seed.rs index 7a8998ca..e88ea987 100644 --- a/big-code-analysis-cli/src/walk_seed.rs +++ b/big-code-analysis-cli/src/walk_seed.rs @@ -155,14 +155,43 @@ pub(crate) fn reanchor_seed(seed: PathBuf) -> PathBuf { /// remainder, and a `bca.toml` directory for /// [`root_relative_match_path`]. fn relative_tail(path: &std::path::Path, root: &std::path::Path) -> Option { + relative_tail_with(path, root, None) +} + +/// [`relative_tail`], with the caller's already-canonicalised `root`. +/// +/// The plain form canonicalises `root` per call, which is per *walked +/// file* on the exclude path — and `root` is fixed for a whole run. A +/// caller that can hoist it (see [`ManifestAnchor::resolve`]) passes it +/// here, which turns the common symlinked-root case — a macOS +/// `/var/folders/…` `TempDir`, or any project reached through a symlink +/// — into a second lexical `strip_prefix` rather than two `canonicalize` +/// syscalls per file. +/// +/// The `path.canonicalize()` fallback stays last: only it can rescue a +/// symlink *inside* the path, and it is now reached only when both +/// lexical attempts fail. +fn relative_tail_with( + path: &std::path::Path, + root: &std::path::Path, + canonical_root: Option<&std::path::Path>, +) -> Option { if let Ok(rel) = path.strip_prefix(root) { return Some(rel.to_path_buf()); } + if let Some(canonical_root) = canonical_root + && let Ok(rel) = path.strip_prefix(canonical_root) + { + return Some(rel.to_path_buf()); + } let canonical = path.canonicalize().ok()?; if let Ok(rel) = canonical.strip_prefix(root) { return Some(rel.to_path_buf()); } - let canonical_root = root.canonicalize().ok()?; + let canonical_root = match canonical_root { + Some(root) => root.to_path_buf(), + None => root.canonicalize().ok()?, + }; canonical .strip_prefix(&canonical_root) .ok() @@ -203,8 +232,12 @@ fn root_relative_match_path(anchor: ManifestAnchor<'_>, path: &std::path::Path) } else { path.to_path_buf() }; - relative_tail(&crate::baseline::lexical_normalize(&absolute), anchor.root?) - .filter(|rel| !rel.as_os_str().is_empty()) + relative_tail_with( + &crate::baseline::lexical_normalize(&absolute), + anchor.root?, + anchor.canonical_root, + ) + .filter(|rel| !rel.as_os_str().is_empty()) } /// Where a manifest-anchored glob set resolves from: the `bca.toml` @@ -224,14 +257,32 @@ fn root_relative_match_path(anchor: ManifestAnchor<'_>, path: &std::path::Path) pub(crate) struct ManifestAnchor<'a> { pub(crate) root: Option<&'a std::path::Path>, pub(crate) cwd: &'a std::path::Path, + /// `root` resolved through symlinks, when a caller hoisted it. + /// + /// `root` is fixed for a whole run while the exclude match runs per + /// *walked file*, so canonicalising it here rather than inside + /// [`relative_tail`] keeps two syscalls per file off the path a + /// symlinked project root would otherwise take. + pub(crate) canonical_root: Option<&'a std::path::Path>, } impl<'a> ManifestAnchor<'a> { /// Resolve the working directory once. `None` when it cannot be /// read, which makes every manifest match fall back to the /// cwd-anchored form exactly as an absent manifest does. - pub(crate) fn resolve(root: Option<&'a std::path::Path>, cwd: &'a PathBuf) -> Self { - Self { root, cwd } + /// `root` may be pre-canonicalised by the caller — see the + /// `canonical_root` field for why that matters on the per-walked-file + /// path. + pub(crate) fn resolve( + root: Option<&'a std::path::Path>, + cwd: &'a std::path::Path, + canonical_root: Option<&'a std::path::Path>, + ) -> Self { + Self { + root, + cwd, + canonical_root, + } } } @@ -271,6 +322,89 @@ pub(crate) fn manifest_match_path<'a>( ) } +/// The two exclude sets a run carries, each matched at its own root. +/// +/// The rule is one sentence — *a path is excluded when the CLI set +/// matches it at the working directory, or the manifest set matches it +/// at the manifest directory* — and before #1189 it was spelled three +/// separate times with three different answers. [`WalkFilters::passes`] +/// matched both sets at the walk root, which is only the manifest root +/// for the canonical `paths = ["."]`; a directory seed +/// (`bca metrics -p sub`) moved the walk root and the manifest globs +/// silently stopped applying. The other two sites anchored correctly. +/// +/// Stating it once is the point. There is exactly one deliberate +/// exception, and it is a *separate method on the caller* rather than a +/// divergence inside this rule: [`crate::walk::WalkFilters::includes`] +/// consults the include allow-list and skips excludes entirely, because +/// a project's ignore rules shape *directory-walk* scope and must not +/// silently discard a file the user named on the command line (#726). +pub(crate) struct AnchoredExcludes<'a> { + /// Globs the caller typed, resolved against the working directory. + cli: &'a crate::ExcludeGlobs, + /// Globs from a `bca.toml`, resolved against that file's directory. + manifest: &'a crate::ExcludeGlobs, + anchor: ManifestAnchor<'a>, +} + +impl<'a> AnchoredExcludes<'a> { + /// `manifest_dir` is `None` when no manifest applied, which makes + /// every manifest match fall back to the cwd-anchored form. + pub(crate) fn new( + cli: &'a crate::ExcludeGlobs, + manifest: &'a crate::ExcludeGlobs, + manifest_dir: Option<&'a std::path::Path>, + cwd: &'a std::path::Path, + canonical_manifest_dir: Option<&'a std::path::Path>, + ) -> Self { + Self { + cli, + manifest, + anchor: ManifestAnchor::resolve(manifest_dir, cwd, canonical_manifest_dir), + } + } + + /// The first pattern `path` matches in either set, or `None`. + /// + /// `path` is the file as the run knows it; `cwd_form` its + /// working-directory-anchored spelling. The CLI set is consulted + /// first and the manifest anchoring computed only if it declines, + /// because [`manifest_match_path`] allocates and this runs for every + /// walked file. + pub(crate) fn first_match( + &self, + path: &std::path::Path, + cwd_form: CwdForm<'_>, + ) -> Option<&'a str> { + if !self.cli.is_empty() + && let Some(glob) = self.cli.first_match(cwd_form.0) + { + return Some(glob); + } + if self.manifest.is_empty() { + return None; + } + self.manifest + .first_match(manifest_match_path(self.anchor, path, cwd_form)) + } + + /// Whether either set excludes `path`. + /// + /// The `is_empty` arms are not redundant with the match itself: + /// `GlobSet::is_match` builds its `Candidate` *before* its own empty + /// check, and on Windows that allocates. Every walked file reaches + /// this, so an unconfigured set must cost nothing. + pub(crate) fn excludes(&self, path: &std::path::Path, cwd_form: CwdForm<'_>) -> bool { + if !self.cli.is_empty() && self.cli.is_match(cwd_form.0) { + return true; + } + !self.manifest.is_empty() + && self + .manifest + .is_match(manifest_match_path(self.anchor, path, cwd_form)) + } +} + /// The path to match `--include` globs against for an *explicitly named /// file seed*: its CWD-relative tail when the file lies under the CWD, /// otherwise the seed as spelled. diff --git a/big-code-analysis-cli/src/walk_seed_tests.rs b/big-code-analysis-cli/src/walk_seed_tests.rs index c1206bc0..24ef7d4c 100644 --- a/big-code-analysis-cli/src/walk_seed_tests.rs +++ b/big-code-analysis-cli/src/walk_seed_tests.rs @@ -50,8 +50,8 @@ fn relative_tail_canonicalizes_both_sides_when_forms_diverge() { /// Build a [`super::ManifestAnchor`] for a test. Exists so the tests /// read as `(root, cwd)` at each call rather than repeating the struct /// literal, and so a future field lands in one place. -fn anchor<'a>(root: Option<&'a Path>, cwd: &'a std::path::PathBuf) -> super::ManifestAnchor<'a> { - super::ManifestAnchor::resolve(root, cwd) +fn anchor<'a>(root: Option<&'a Path>, cwd: &'a Path) -> super::ManifestAnchor<'a> { + super::ManifestAnchor::resolve(root, cwd, None) } #[test] diff --git a/big-code-analysis-cli/src/walk_tests.rs b/big-code-analysis-cli/src/walk_tests.rs index 2ae4e520..625fdef1 100644 --- a/big-code-analysis-cli/src/walk_tests.rs +++ b/big-code-analysis-cli/src/walk_tests.rs @@ -40,11 +40,16 @@ fn walk_directory_seed_returns_sorted_paths() { let empty_include = mk_globset(Vec::new()).expect("empty globset"); let empty_exclude = build_exclude_globset(Vec::new(), None, "--exclude-from"); + let cwd = std::env::current_dir().unwrap_or_default(); let filters = WalkFilters { include: &empty_include, - exclude: &empty_exclude, - manifest_exclude: &empty_exclude, - manifest_dir: None, + excludes: crate::walk_seed::AnchoredExcludes::new( + &empty_exclude, + &empty_exclude, + None, + &cwd, + None, + ), language_forced: false, }; let mut errors = WalkErrors::default(); diff --git a/big-code-analysis-cli/tests/check/check_exclude.rs b/big-code-analysis-cli/tests/check/check_exclude.rs index 3e12b212..f3e6fe73 100644 --- a/big-code-analysis-cli/tests/check/check_exclude.rs +++ b/big-code-analysis-cli/tests/check/check_exclude.rs @@ -544,4 +544,103 @@ fn print_effective_config_unions_the_manifest_check_exclude() { !check.contains_key("exclude_from"), "no walker exclude_from was configured", ); + + // The flattened `check_exclude` above cannot say which of its two + // globs resolves against which root, and after #1164 that is what + // decides whether a glob matches at all. The sibling key names the + // manifest-origin subset, so the resolved list stays where it is — + // and keeps round-tripping through `--config` — while the + // provenance becomes answerable (#1194). Its anchor is the + // `manifest` key's directory, which is populated whenever this is. + let manifest_check_exclude: Vec<&str> = check["manifest_check_exclude"] + .as_array() + .expect("manifest_check_exclude is an array") + .iter() + .map(|v| v.as_str().expect("glob is a string")) + .collect(); + assert_eq!( + manifest_check_exclude, + ["./generated/**"], + "only the manifest's glob is manifest-anchored; `tests/**` came from the CLI", + ); + assert!( + check.contains_key("manifest"), + "the manifest-origin subset is meaningless without the anchor it resolves against", + ); + + // The effective `check_exclude_from` came from the manifest here, + // so the provenance key mirrors it. + assert_eq!( + check["manifest_check_exclude_from"].as_str(), + check["check_exclude_from"].as_str(), + "no CLI --check-exclude-from was passed, so the manifest's file is the one in effect", + ); + + // The walker's exclude surface configured nothing, so its + // provenance key is absent rather than empty. + assert!( + !check.contains_key("manifest_exclude"), + "an unconfigured surface must not emit an empty provenance key", + ); +} + +/// A CLI `--check-exclude-from` *replaces* the manifest's file rather +/// than unioning with it, so the provenance key must go absent — saying +/// the manifest's file is in effect when it is not would be worse than +/// saying nothing. +/// +/// #1194 reads the manifest's file as being "dropped entirely" here. +/// It is not dropped in the report; it is not in effect, by the +/// documented `replaced_by` rule that governs the *file* while the +/// inline glob list unions. +#[test] +fn print_effective_config_omits_a_replaced_manifest_exclude_from() { + let dir = TempDir::new().unwrap(); + fs::write(dir.path().join("kept.rs"), branchy("kept_offender")).unwrap(); + fs::write(dir.path().join("more-globs.txt"), "vendor/**\n").unwrap(); + fs::write(dir.path().join("cli-globs.txt"), "cli-vendor/**\n").unwrap(); + fs::write( + dir.path().join("bca.toml"), + "paths = [\".\"]\n\ + [check]\n\ + exclude = [\"./generated/**\"]\n\ + exclude_from = \"more-globs.txt\"\n", + ) + .unwrap(); + + let assert = cli(dir.path()) + .args([ + "check", + "--paths", + dir.path().to_str().unwrap(), + "--threshold", + "cyclomatic=1", + "--check-exclude-from", + "cli-globs.txt", + "--print-effective-config", + "toml", + ]) + .assert() + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let parsed: toml::Table = toml::from_str(&stdout).expect("effective config is valid TOML"); + let check = parsed["check"].as_table().expect("[check] is a table"); + + assert_eq!( + check["check_exclude_from"].as_str(), + Some("cli-globs.txt"), + "the CLI file is the one in effect", + ); + assert!( + !check.contains_key("manifest_check_exclude_from"), + "the manifest's file was replaced, so it must not be reported as in effect", + ); + // The inline glob list still unions, which is what makes the two + // rules distinguishable rather than a single "CLI wins" story. + assert!( + check["manifest_check_exclude"] + .as_array() + .is_some_and(|a| a.len() == 1), + "the manifest's inline glob is still in effect", + ); } diff --git a/big-code-analysis-cli/tests/check/check_thresholds.rs b/big-code-analysis-cli/tests/check/check_thresholds.rs index b93733ec..957d6525 100644 --- a/big-code-analysis-cli/tests/check/check_thresholds.rs +++ b/big-code-analysis-cli/tests/check/check_thresholds.rs @@ -2263,3 +2263,175 @@ fn check_multi_metric_config_gates_on_every_named_metric() { ); } } + +/// The `nargs` gate measures a function's **own** parameters, not the +/// subtree sum (#1196). +/// +/// It gated on `nargs.total()` — `function_args_sum() + closure_args_sum()` +/// — so a function was charged for every nested closure's parameters +/// too. A three-parameter function with a two-parameter sort comparator +/// was reported at 5, and the remediation its number implied (fewer +/// parameters) was not the one that would clear it. Every comparable +/// tool — `RuboCop` `Metrics/ParameterLists`, `ESLint` `max-params`, +/// Clippy `too_many_arguments`, lizard, `SonarQube` S107, Pylint `R0913` +/// — counts a callable's own formal parameters, and two of those are the +/// anchors the shipped default is derived from. +/// +/// `outer` declares two parameters and contains a three-parameter nested +/// function plus a two-parameter closure. Under the old rule it scored +/// 7; the fixture is built so the old value and the new one fall on +/// opposite sides of the limit, which is what makes this discriminate. +#[test] +fn nargs_gates_a_function_on_its_own_parameters() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "own.rs", + "fn outer(a: u32, b: u32) -> u32 {\n\ + \x20 fn inner(p: u32, q: u32, r: u32) -> u32 { p + q + r }\n\ + \x20 let c = |x: u32, y: u32| x + y;\n\ + \x20 inner(a, b, 1) + c(a, b)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=2"]) + .assert() + .code(2) + // `inner` really does declare three parameters. + .stdout(predicate::str::contains("outer::inner: nargs = 3")) + // `outer` declares two and must not be charged for its children. + // Asserting on the qualified name alone would match the `inner` + // row above, which is prefixed with it. + .stdout(predicate::str::contains("outer: nargs =").not()); +} + +/// Nothing escapes the narrower gate: a closure that opens its own space +/// is flagged on its own row, which is also where its fix goes (#1196). +/// +/// This is the guarantee that makes own-parameter gating safe rather +/// than a loophole, and it holds in the ten grammars whose closures are +/// `is_func_space` — Rust here. +#[test] +fn nargs_flags_a_fat_closure_on_its_own_row() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "fat.rs", + "fn small(a: u32) -> u32 {\n\ + \x20 let fat = |p: u32, q: u32, r: u32, s: u32, t: u32, u: u32| p+q+r+s+t+u;\n\ + \x20 fat(a, a, a, a, a, a)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains(": nargs = 6")) + // The enclosing function declares one parameter and is clean. + .stdout(predicate::str::contains("small: nargs =").not()); +} + +/// Where a lambda opens no space, its arguments still fold into the +/// enclosing function — and the row shows the split so the reader knows +/// which lever to pull (#1196). +/// +/// Python, Java, Kotlin and C++/Mozcpp are the four grammars in this +/// position: their lambdas are `is_closure` without being +/// `is_func_space`, so the enclosing function is the only attribution +/// available. Without the parenthetical the row reads as though `small` +/// declared eight parameters, which is the confusion this whole change +/// is about. +#[test] +fn nargs_shows_the_split_where_a_lambda_opens_no_space() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "lam.py", + "def small(a):\n\ + \x20 fat = lambda p, q, r, s, t, u, v: p+q+r+s+t+u+v\n\ + \x20 return fat(a, a, a, a, a, a, a)\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains( + "small: nargs = 8 (1 own + 7 lambda)", + )); +} + +/// A function that declares *no* parameters still gets the split when a +/// spaceless lambda put it over (#1196). +/// +/// This is the case the first guard hid. It required `own > 0`, so +/// `zero` rendered a bare `nargs = 7` — reading as a seven-parameter +/// signature for a function with none, which is the misleading row the +/// split exists to remove. Its one-parameter sibling got the annotation, +/// which made the omission look deliberate. +#[test] +fn nargs_shows_the_split_even_when_the_function_declares_nothing() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "zero.py", + "def zero():\n\ + \x20 fat = lambda p, q, r, s, t, u, v: p\n\ + \x20 return fat\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains( + "zero: nargs = 7 (0 own + 7 lambda)", + )); +} + +/// ...but a closure's *own* space never does, even though its counts are +/// shaped identically (`0` own, `N` lambda). +/// +/// `N` there is that closure's own parameter list, not a mix, so +/// `(0 own + N lambda)` would be noise. The counts cannot tell the two +/// apart — the subject can: a closure space carries no name. +#[test] +fn nargs_omits_the_split_on_a_closures_own_row() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "clo.rs", + "fn small(a: u32) -> u32 {\n\ + \x20 let f = |p: u32, q: u32, r: u32, s: u32, t: u32, u: u32| p;\n\ + \x20 f(a, a, a, a, a, a)\n\ + }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains(": nargs = 6 (limit 5)")) + .stdout(predicate::str::contains("own +").not()); +} + +/// The parenthetical stays off an ordinary offender, where it would just +/// restate the value (#1196). +#[test] +fn nargs_omits_the_split_for_a_plain_parameter_list() { + let dir = TempDir::new().unwrap(); + let path = write_fixture( + &dir, + "plain.rs", + "fn wide(a: u32, b: u32, c: u32, d: u32, e: u32, f: u32) -> u32 { a+b+c+d+e+f }\n", + ); + + cli(dir.path()) + .args(["check", "--paths", &path, "--threshold", "nargs=5"]) + .assert() + .code(2) + .stdout(predicate::str::contains("wide: nargs = 6 (limit 5)")) + .stdout(predicate::str::contains("own +").not()); +} diff --git a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs index 2804d5f2..5848338a 100644 --- a/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs +++ b/big-code-analysis-cli/tests/discovery/explicit_path_excludes.rs @@ -109,6 +109,87 @@ fn same_manifest_exclude_still_drops_the_file_on_a_directory_walk() { .stderr(predicate::str::contains("named explicitly").not()); } +/// A manifest exclude must keep applying when the walk root is a +/// *subdirectory* of the manifest root (#1189). +/// +/// The glob is written against the `bca.toml` directory, but a directory +/// seed moves the walk root: `bca check nested` emits `nested/skipme/a.rs` +/// where `./nested/skipme/**` was authored for the project root, so +/// matching the manifest set against the walk-root form silently stopped +/// excluding anything. `same_manifest_exclude_still_drops_the_file_on_a_directory_walk` +/// above cannot detect this — it runs from the fixture root with no +/// seed, where the two roots coincide. +/// +/// `nested/kept.rs` is the positive half: without a surviving offender, +/// "the excluded file is absent" is indistinguishable from a run that +/// resolved no files at all. +fn nested_fixture() -> TempDir { + let dir = fixture("exclude = [\"./nested/skipme/**\"]\n"); + fs::create_dir_all(dir.path().join("nested").join("skipme")).unwrap(); + fs::write( + dir.path().join("nested").join("skipme").join("a.rs"), + branchy("nested_skipme_offender"), + ) + .unwrap(); + fs::write( + dir.path().join("nested").join("kept.rs"), + branchy("nested_kept_offender"), + ) + .unwrap(); + dir +} + +#[test] +fn manifest_exclude_applies_under_a_directory_seed() { + let dir = nested_fixture(); + + cli(dir.path()) + .args(["check", "nested", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + +/// The same seed named from *inside* the subdirectory, where the walk +/// root, the working directory and the manifest root are three different +/// places. This is the harder half: the manifest is discovered upwards, +/// so the glob still carries the project root's spelling while every +/// path the walk emits is relative to `nested`. +#[test] +fn manifest_exclude_applies_to_a_directory_seed_from_inside_it() { + let dir = nested_fixture(); + + cli(&dir.path().join("nested")) + .args(["check", ".", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + +/// The control: the canonical `paths = ["."]` walk from the manifest +/// root, which was always correct, must stay correct. Anchoring the +/// manifest set at the manifest directory rather than the walk root is a +/// no-op here precisely because the two coincide — and if that stopped +/// holding, this is the test that says so. +#[test] +fn manifest_exclude_still_applies_to_the_canonical_root_walk() { + let dir = nested_fixture(); + + cli(dir.path()) + .args(["check", "--no-summary", "--no-remediation"]) + .assert() + .code(2) + .stdout(predicate::str::contains("nested_kept_offender")) + // The root fixture's own survivor, matched on its file rather + // than its function name: `kept_offender` is a substring of + // `nested_kept_offender`, so asserting the name alone is implied + // by the line above and cannot fail independently. + .stdout(predicate::str::contains("kept.rs")) + .stdout(predicate::str::contains("nested_skipme_offender").not()); +} + /// The `-X` / `--exclude` CLI surface behaves as the manifest key does, /// and the reported glob is the *matching* pattern rather than whichever /// one happens to sit first. diff --git a/big-code-analysis-py/tests/test_vcs.py b/big-code-analysis-py/tests/test_vcs.py index 1c8c6dac..8a39ba26 100644 --- a/big-code-analysis-py/tests/test_vcs.py +++ b/big-code-analysis-py/tests/test_vcs.py @@ -104,17 +104,26 @@ def test_analyze_vcs_true_attaches_block(tmp_path: Path) -> None: assert hotspot > 0.0 -def test_analyze_vcs_true_releases_gil_for_walk(tmp_path: Path) -> None: - """The ``analyze(vcs=True)`` history walk runs off-GIL (#620). - - Coarse no-deadlock / progress check, not a wall-clock threshold (CI - timing is too flaky for a hard budget): run the VCS-injecting analyze - in a worker thread and confirm the main thread keeps making progress — - it counts up while the walk runs and the walk returns its block. If the - walk held the GIL for its whole duration the worker would still finish, - so this cannot guarantee true parallelism, but it pins the contract - that the off-GIL call completes and produces the same result a serial - call does. +def test_analyze_vcs_true_completes_off_the_main_thread(tmp_path: Path) -> None: + """The ``analyze(vcs=True)`` history walk does not deadlock (#620). + + The off-GIL *interleaving* is deliberately not asserted here, and the + name says so. This test used to start a worker, then count loop + iterations while it was alive and require ``progressed > 0`` — but + "the worker finished before I looked" and "the GIL was held so I + could not run" are indistinguishable from the main thread's side. + The fixture repo has a single commit, so the walk is sub-millisecond + and the main thread routinely lost that race: it failed under `make + pre-commit`, which saturates every core, and passed 5/5 on an + unchanged tree immediately after (#1191). A tighter loop or shorter + sleep changes the odds, not the logic. + + What remains is the regression that actually matters and that a + worker thread *can* observe: the walk completes rather than + deadlocking the interpreter, and it returns the same block a serial + call does. An assertion that fires on load and not on breakage is + worse than no assertion, because it teaches people to re-run the + gate — which is the habit that lets a real failure through. """ import threading @@ -122,28 +131,31 @@ def test_analyze_vcs_true_releases_gil_for_walk(tmp_path: Path) -> None: target = repo / "work.rs" result: dict[str, Any] = {} - started = threading.Event() + failure: list[BaseException] = [] def run() -> None: - started.set() - out = bca.analyze(target, vcs=True) - assert out is not None - result["vcs"] = out["metrics"]["vcs"] + # A bare `assert` here would be swallowed by the thread and + # resurface in the main thread as a confusing KeyError on + # `result`, hiding the real cause. + try: + out = bca.analyze(target, vcs=True) + assert out is not None + result["vcs"] = out["metrics"]["vcs"] + except BaseException as exc: + failure.append(exc) worker = threading.Thread(target=run) worker.start() - started.wait() - # The main thread advances while the worker walks history; with the GIL - # released for the walk this loop interleaves rather than blocking until - # the worker is fully done. - progressed = 0 - while worker.is_alive(): - progressed += 1 - time.sleep(0.001) + # `join` is the wait, so the timeout is load-bearing: a walk that + # deadlocks the interpreter fails here rather than hanging the suite. worker.join(timeout=30.0) - assert not worker.is_alive(), "VCS walk worker thread deadlocked" - assert progressed > 0 + if failure: + raise failure[0] + + serial = bca.analyze(target, vcs=True) + assert serial is not None + assert result["vcs"] == serial["metrics"]["vcs"] assert result["vcs"]["commits_long"] == 1 diff --git a/docs/development/benchmarking.md b/docs/development/benchmarking.md index cb9a9266..593c6729 100644 --- a/docs/development/benchmarking.md +++ b/docs/development/benchmarking.md @@ -199,12 +199,17 @@ finds at the call site. All four probes fitted 1.99-2.06 before and That change also confirmed #1088's lesson a second time from the other direction. The ABC condition walkers reach a slot's parent to decide -whether it sits in boolean context, and the same walkers ask +whether it sits in boolean context, and the same walkers used to ask `Node::previous_sibling` whether a ternary's `?` / `:` precedes the operand — and `ts_node__prev_sibling` opens with `ts_node_parent`, so it carries the same `O(depth)`. Passing the parent in and scanning its -children (`Node::previous_sibling_under`) was needed for the fix to -hold on a ternary shape, not only on the shapes the probes render. +children (`Node::previous_sibling_under`) was what made the fix hold on +a ternary shape, not only on the shapes the probes render. + +That helper no longer exists: #1181 moved every ternary slot onto +`child_by_field_name`, which needs no sibling scan at all, and the +method was removed with its last caller. The sibling cost described +above is still the reason to reach for a field lookup first. The `Ancestors::unknown()` call sites that remain are deliberate rather than deferred: the two synthetic-`Unit`-root pushes hand it a node that diff --git a/man/bca-check.1 b/man/bca-check.1 index 3a3175f2..6c16e4c8 100644 --- a/man/bca-check.1 +++ b/man/bca-check.1 @@ -175,7 +175,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-count.1 b/man/bca-count.1 index a560695b..1a3e8df1 100644 --- a/man/bca-count.1 +++ b/man/bca-count.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-diff.1 b/man/bca-diff.1 index 2438bfae..a85f88c6 100644 --- a/man/bca-diff.1 +++ b/man/bca-diff.1 @@ -65,7 +65,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-dump.1 b/man/bca-dump.1 index 469d6981..cbe620f8 100644 --- a/man/bca-dump.1 +++ b/man/bca-dump.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-exemptions.1 b/man/bca-exemptions.1 index 302d4160..bea26a92 100644 --- a/man/bca-exemptions.1 +++ b/man/bca-exemptions.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-find.1 b/man/bca-find.1 index 173fe19a..9f3b3675 100644 --- a/man/bca-find.1 +++ b/man/bca-find.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-functions.1 b/man/bca-functions.1 index 03f27510..f4897240 100644 --- a/man/bca-functions.1 +++ b/man/bca-functions.1 @@ -24,7 +24,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-init.1 b/man/bca-init.1 index c17596dc..b34d102d 100644 --- a/man/bca-init.1 +++ b/man/bca-init.1 @@ -33,7 +33,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-metrics.1 b/man/bca-metrics.1 index 76f6f081..5392047b 100644 --- a/man/bca-metrics.1 +++ b/man/bca-metrics.1 @@ -63,7 +63,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-ops.1 b/man/bca-ops.1 index 5d4544fb..79bbf3af 100644 --- a/man/bca-ops.1 +++ b/man/bca-ops.1 @@ -54,7 +54,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-preproc.1 b/man/bca-preproc.1 index ba03aadf..0cad3c13 100644 --- a/man/bca-preproc.1 +++ b/man/bca-preproc.1 @@ -27,7 +27,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-report.1 b/man/bca-report.1 index bac2e2e5..9c949b54 100644 --- a/man/bca-report.1 +++ b/man/bca-report.1 @@ -62,7 +62,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-strip-comments.1 b/man/bca-strip-comments.1 index c449bf9d..2ac5cfcd 100644 --- a/man/bca-strip-comments.1 +++ b/man/bca-strip-comments.1 @@ -30,7 +30,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/man/bca-vcs.1 b/man/bca-vcs.1 index d35e7700..d222d804 100644 --- a/man/bca-vcs.1 +++ b/man/bca-vcs.1 @@ -124,7 +124,7 @@ Glob to exclude files. Repeat the flag to add multiple globs (`\-X \*(Aq*.tmp\*( Shapes directory\-walk scope only: a file named directly on the command line overrides every exclude glob and is analyzed anyway (the ripgrep/fd convention), with a warning on stderr naming the glob it overrode. `\-I` / `\-\-include` is not overridden. To exempt something from `bca check`\*(Aqs threshold gate whichever way it is named, use `\-\-check\-exclude` / `[check] exclude` instead. -A relative glob given here is resolved against the directory you ran `bca` from. One written in `bca.toml` is matched against each file\*(Aqs path relative to the *walk root*, which is the manifest\*(Aqs own directory only when the walk starts there — as it does for the usual `paths = ["."]`. Naming a subdirectory explicitly (`bca metrics \-p sub`) moves the walk root and can stop a manifest glob matching. `[check] exclude` does not have this caveat: it anchors at the manifest whatever the caller names. +The two sources anchor differently, and it is worth knowing which you are writing. A glob given *here* is matched against each file\*(Aqs path as the walk emits it — relative to the seed you named — so `bca metrics \-p sub \-X \*(Aq./skipme/**\*(Aq` excludes `sub/skipme`, while `\*(Aq./sub/skipme/**\*(Aq` matches nothing. A glob written in `bca.toml` is resolved against the manifest\*(Aqs own directory instead, whatever path you name and wherever you run from, so the bare `bca metrics` and `bca metrics \-p sub` honour the same manifest exclusions. `[check] exclude` anchors at the manifest the same way. .TP \fB\-l\fR, \fB\-\-language\fR \fI\fR Force a language instead of inferring from extension. Accepts a canonical language name (`rust`, `python`, `cpp`, …) or a file extension (`rs`, `py`, …). An unrecognized value is a hard error diff --git a/src/checker.rs b/src/checker.rs index d51fb2ad..fdeddb5f 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -71,64 +71,134 @@ macro_rules! js_ancestor_walk { }; } +// Is this expression *bound* to a name, or used positionally? +// +// `check_if_func!` and `check_if_arrow_func!` answer that question for +// the two anonymous-function forms, and until #1188 they answered it +// with two different ancestor walks. Four divergences, each measured: +// +// 1. `$stop` was `Arguments` for one and `CallExpression` for the other +// — different tree levels, since `call_expression > arguments > +// function_expression`. An IIFE's chain is `parenthesized_expression +// -> call_expression` with no `arguments` on it at all, so the func +// walk ran past the call and reached the binding: `(function(c){})(1)` +// was a closure while `const v = (function(c){})(1)` was a function. +// The same construct, classified by what happened one level up past +// the call. +// 2. `Pair` was in the func `$up` only, so `({ "k": function(){} })` was +// a function and `({ "k": () => {} })` a closure. The arrow reached +// the same verdict for a *bare* key only, and by a different +// mechanism — `$extra`'s `property_identifier` sibling — which a +// string, number or computed key does not provide. +// 3. `has_sibling(PropertyIdentifier)` was in the arrow `$extra` only, +// the exact mirror: `class C { p = () => {}; }` was a function and +// `class C { p = function(){}; }` a closure. +// 4. `is_child(Identifier)` is in the func `$extra` only, and **must +// stay there**. For a function expression the only possible +// `identifier` *child* is its optional name, so it means "carries its +// own name" and `run(function g(){})` is rightly a function. An arrow +// stores its un-parenthesised single parameter in exactly that +// position, so unifying it would make `run(x => x)` — the commonest +// callback shape in any JS corpus — a function. +// +// The first three are unified below, which makes an IIFE a closure in +// both spellings: that is what a reader sees, and it matches the +// behaviour the arrow form already had. `$up` and `$stop` are +// token-identical and only `$extra` differs, for the reason in (4). +// +// The field-definition kind is threaded in per language rather than +// named here because JS and TS spell it differently (`field_definition` +// vs `public_field_definition`). Both macros take it: `$extra`'s +// `property_identifier` sibling covers a class field only when its name +// *is* an identifier, so without the `$up` entry +// `class C { ["k"] = () => 1 }`, `{ "s" = … }` and `{ #p = … }` stayed +// closures while their `function` spellings were already functions — +// divergence (3) surviving in the shapes the first fix did not reach. macro_rules! check_if_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { js_ancestor_walk!( $node, $ancestors, - [VariableDeclarator | AssignmentExpression | LabeledStatement | Pair], - [StatementBlock | ReturnStatement | NewExpression | Arguments], + [VariableDeclarator + | AssignmentExpression + | LabeledStatement + | Pair + | $field_definition], + [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], $node.is_child(Identifier as u16), ) }; } macro_rules! check_if_arrow_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { js_ancestor_walk!( $node, $ancestors, - [VariableDeclarator | AssignmentExpression | LabeledStatement], - [StatementBlock | ReturnStatement | NewExpression | CallExpression], + [VariableDeclarator + | AssignmentExpression + | LabeledStatement + | Pair + | $field_definition], + [StatementBlock | ReturnStatement | NewExpression | CallExpression | Arguments], $node.has_sibling($ancestors, PropertyIdentifier as u16), ) }; } +// A generator is a function, not a closure (#1186). `function* g() {}` +// is lexically a function *declaration* — the `*` is a modifier on the +// declaration, not a different binding form — and `get_space_kind` +// already calls both generator kinds `SpaceKind::Function`. The Checker +// disagreeing with the Getter is what made `nom` report a generator as a +// closure, bill its parameters to `closure_args`, and (reaching no +// cognitive boundary arm) let it inherit the enclosing nesting. +// +// The two kinds are **not** symmetrical, which the issue's "move both" +// framing misses. `generator_function_declaration` has a required `name` +// field and mirrors `function_declaration`, so it is unconditionally a +// function. `generator_function` has an *optional* one and mirrors +// `function_expression`, so it routes through the same +// binding-site walk: making it unconditional would classify +// `run(function*(){})` as a function while `run(function(){})` stays a +// closure. macro_rules! is_js_func { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { match $node.kind_id().into() { - FunctionDeclaration | MethodDefinition => true, - FunctionExpression => check_if_func!($node, $ancestors), - ArrowFunction => check_if_arrow_func!($node, $ancestors), + FunctionDeclaration | GeneratorFunctionDeclaration | MethodDefinition => true, + FunctionExpression | GeneratorFunction => { + check_if_func!($node, $ancestors, $field_definition) + } + ArrowFunction => check_if_arrow_func!($node, $ancestors, $field_definition), _ => false, } }; } macro_rules! is_js_closure { - ($node: ident, $ancestors: ident) => { + ($node: ident, $ancestors: ident, $field_definition: ident) => { match $node.kind_id().into() { - GeneratorFunction | GeneratorFunctionDeclaration => true, - FunctionExpression => !check_if_func!($node, $ancestors), - ArrowFunction => !check_if_arrow_func!($node, $ancestors), + FunctionExpression | GeneratorFunction => { + !check_if_func!($node, $ancestors, $field_definition) + } + ArrowFunction => !check_if_arrow_func!($node, $ancestors, $field_definition), _ => false, } }; } macro_rules! is_js_func_and_closure_checker { - ($language: ident) => { + ($language: ident, $field_definition: ident) => { #[inline] fn is_func<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool { use $language::*; - is_js_func!(node, ancestors) + is_js_func!(node, ancestors, $field_definition) } #[inline] fn is_closure<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>) -> bool { use $language::*; - is_js_closure!(node, ancestors) + is_js_closure!(node, ancestors, $field_definition) } }; } @@ -292,6 +362,26 @@ pub(crate) trait Checker { fn is_non_arg(_: &Node) -> bool { false } + /// Whether `node` — the value of a function's `parameters` field — + /// is itself a *single formal parameter* rather than a parameter + /// *list*. + /// + /// `compute_args` walks the field node's children, which is right + /// for a list and yields zero for a lone parameter that has none. + /// Java's `lambda_expression` spells its `parameters` field three + /// ways — `formal_parameters` and `inferred_parameters` are lists, + /// but the un-parenthesised `x -> …` form puts a bare, childless + /// `identifier` there — and C#'s puts a childless + /// `implicit_parameter`. Both reported `nargs = 0` for a lambda + /// that plainly has one argument (#1185). + /// + /// Gated per language rather than inferred from `child_count() == 0` + /// so a MISSING or zero-width ERROR node under `parameters` on a + /// broken parse cannot silently conjure an argument. + #[inline] + fn is_bare_param(_: &Node) -> bool { + false + } #[inline] fn is_string(_: &Node) -> bool { false diff --git a/src/checker/csharp.rs b/src/checker/csharp.rs index 19fa451b..c16870c2 100644 --- a/src/checker/csharp.rs +++ b/src/checker/csharp.rs @@ -75,6 +75,13 @@ impl Checker for CsharpCode { matches!(node.kind_id().into(), csharp_invocation_expr_kinds!()) } + /// `x => x + 1` puts a childless `implicit_parameter` in the + /// `parameters` field, where `(x) => …` puts a `parameter_list` + /// (#1185). Same shape as Java's bare-identifier lambda. + fn is_bare_param(node: &Node) -> bool { + node.kind_id() == Csharp::ImplicitParameter as u16 + } + fn is_non_arg(node: &Node) -> bool { matches!( node.kind_id().into(), diff --git a/src/checker/elixir.rs b/src/checker/elixir.rs index fa7a4b56..daeb5cee 100644 --- a/src/checker/elixir.rs +++ b/src/checker/elixir.rs @@ -71,25 +71,18 @@ impl Checker for ElixirCode { code: &[u8], ancestors: Ancestors<'a, '_>, ) -> bool { - use crate::metrics::cognitive::{ - elixir_call_keyword, elixir_is_class_macro, elixir_is_inside_quote_block, - elixir_is_method_macro, - }; - // Cheap path: byte-less `is_func_space` matches `Source` and - // `AnonymousFunction` without needing any text inspection. - if Self::is_func_space(node) { - return true; - } - // Otherwise one `elixir_call_keyword` lookup answers the - // combined question instead of the two the default impl would - // have made via `is_func_with_code || is_func_space_with_code`. - let Some(kw) = elixir_call_keyword(node, code) else { - return false; - }; - if elixir_is_class_macro(kw) { - return true; - } - elixir_is_method_macro(kw) && !elixir_is_inside_quote_block(node, code, ancestors) + // The default is `is_func_with_code || is_func_space_with_code`, + // which costs two `elixir_call_keyword` lookups. Here the second + // predicate already subsumes the first — its final clause *is* + // `is_func_with_code`, since a method macro outside a `quote` + // block satisfies both — so forwarding to it alone answers the + // combined question with one lookup (#310 follow-on perf) and + // keeps a single copy of the rule. Spelling the body out a + // second time is what let this override and + // `is_func_space_with_code` drift apart unnoticed: only the + // override was reachable from the walk, so no test could see + // the copy it was not using. + Self::is_func_space_with_code(node, code, ancestors) } fn is_closure<'a>(node: &Node<'a>, _ancestors: Ancestors<'a, '_>) -> bool { diff --git a/src/checker/groovy.rs b/src/checker/groovy.rs index b69e0f8c..62cc67f1 100644 --- a/src/checker/groovy.rs +++ b/src/checker/groovy.rs @@ -30,6 +30,9 @@ impl Checker for GroovyCode { | Groovy::EnumDeclaration | Groovy::RecordDeclaration | Groovy::AnnotationTypeDeclaration + // `static { … }` — see `JavaCode::is_func_space`, which + // this mirrors (#1184). + | Groovy::StaticInitializer ) } diff --git a/src/checker/java.rs b/src/checker/java.rs index 8cba3eaa..048607fc 100644 --- a/src/checker/java.rs +++ b/src/checker/java.rs @@ -36,6 +36,13 @@ impl Checker for JavaCode { | Java::EnumDeclaration | Java::RecordDeclaration | Java::AnnotationTypeDeclaration + // `static { … }` carries executable code — conventionally + // the class's initialisation logic — but was referenced + // nowhere outside the generated enum, so it opened no + // space and its control flow was charged to the class + // (#1184). `is_func_space` rather than `is_func`: it is + // not a callable anyone names at a call site. + | Java::StaticInitializer ) } @@ -66,6 +73,13 @@ impl Checker for JavaCode { node.kind_id() == Java::MethodInvocation } + /// `x -> x + 1` puts a bare `identifier` in the `parameters` field; + /// `(x) -> …` and `(x, y) -> …` put an `inferred_parameters` list + /// and `() -> …` a `formal_parameters` one (#1185). + fn is_bare_param(node: &Node) -> bool { + node.kind_id() == Java::Identifier as u16 + } + fn is_non_arg(node: &Node) -> bool { // Java's explicit receiver parameter (`void m(S this, int a)`, JLS // 8.4.1) parses as a `receiver_parameter` child of diff --git a/src/checker/javascript.rs b/src/checker/javascript.rs index 3d97f36d..638ae0b5 100644 --- a/src/checker/javascript.rs +++ b/src/checker/javascript.rs @@ -25,10 +25,15 @@ impl Checker for JavascriptCode { | Javascript::GeneratorFunctionDeclaration | Javascript::ClassDeclaration | Javascript::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Javascript::ClassStaticBlock ) } - is_js_func_and_closure_checker!(Javascript); + is_js_func_and_closure_checker!(Javascript, FieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Javascript::CallExpression diff --git a/src/checker/kotlin.rs b/src/checker/kotlin.rs index 0e3f69bc..93f18636 100644 --- a/src/checker/kotlin.rs +++ b/src/checker/kotlin.rs @@ -19,6 +19,28 @@ impl Checker for KotlinCode { | Kotlin::ObjectDeclaration | Kotlin::CompanionObject | Kotlin::ObjectLiteral + // Property accessors and `init { … }` carry executable + // code but were referenced nowhere outside the generated + // enum, so they opened no space at all: their control + // flow was charged to the enclosing class and `bca check` + // could never flag one however complex it got (#1184). + // + // Note this does *not* change `nom.functions`, which + // keys on `is_func` — an accessor-only file still + // reports 0 there, deliberately, for the reason below. + // The defect fixed here is the missing metric scope. + // + // `is_func_space` and not `is_func`: none of the three is + // a callable a user names at a call site — in Kotlin you + // write `p.foo`, not `p.getFoo()` — and putting an + // accessor in `is_func` would have `npm` count the same + // property once as an attribute and again as a method, + // skewing the NPA/NPM ratio the OOP metrics exist to + // report. This is the same split the JS family already + // uses for `FunctionExpression`. + | Kotlin::Getter + | Kotlin::Setter + | Kotlin::AnonymousInitializer ) } diff --git a/src/checker/mozjs.rs b/src/checker/mozjs.rs index 89140f08..65546ebc 100644 --- a/src/checker/mozjs.rs +++ b/src/checker/mozjs.rs @@ -24,10 +24,15 @@ impl Checker for MozjsCode { | Mozjs::GeneratorFunctionDeclaration | Mozjs::ClassDeclaration | Mozjs::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Mozjs::ClassStaticBlock ) } - is_js_func_and_closure_checker!(Mozjs); + is_js_func_and_closure_checker!(Mozjs, FieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Mozjs::CallExpression diff --git a/src/checker/tsx.rs b/src/checker/tsx.rs index eb8a256d..19e288ae 100644 --- a/src/checker/tsx.rs +++ b/src/checker/tsx.rs @@ -24,10 +24,15 @@ impl Checker for TsxCode { | Tsx::AbstractClassDeclaration | Tsx::InterfaceDeclaration | Tsx::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Tsx::ClassStaticBlock ) } - is_js_func_and_closure_checker!(Tsx); + is_js_func_and_closure_checker!(Tsx, PublicFieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Tsx::CallExpression diff --git a/src/checker/typescript.rs b/src/checker/typescript.rs index 99b3a368..decf0a82 100644 --- a/src/checker/typescript.rs +++ b/src/checker/typescript.rs @@ -27,10 +27,15 @@ impl Checker for TypescriptCode { | Typescript::AbstractClassDeclaration | Typescript::InterfaceDeclaration | Typescript::ArrowFunction + // ES2022 `static { … }`. Referenced nowhere + // outside the generated enum before #1184, so a + // class static block opened no space and its + // control flow was charged to the class. + | Typescript::ClassStaticBlock ) } - is_js_func_and_closure_checker!(Typescript); + is_js_func_and_closure_checker!(Typescript, PublicFieldDefinition); fn is_call(node: &Node) -> bool { node.kind_id() == Typescript::CallExpression diff --git a/src/getter.rs b/src/getter.rs index 94791a26..c5924813 100644 --- a/src/getter.rs +++ b/src/getter.rs @@ -165,6 +165,26 @@ macro_rules! impl_js_family_get_op_type { }; } +/// The default space name: the node's `name` field, else ``. +/// +/// A free function as well as the trait default so a language that needs +/// to name a *few* kinds specially can delegate the rest rather than +/// restate the rule — `` / `` / `` / `` in +/// Kotlin, Java and Groovy all do (#1184). Calling `Self::…` there would +/// recurse. +pub(crate) fn default_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + _ancestors: Ancestors<'tree, '_>, +) -> Option<&'a str> { + // we're in a function or in a class + if let Some(name) = node.child_by_field_name("name") { + node_text(code, &name) + } else { + Some("") + } +} + /// Per-language accessors the space walker and the Halstead /// operator/operand classification dispatch through. /// @@ -199,14 +219,9 @@ pub(crate) trait Getter { fn get_func_space_name<'a, 'tree>( node: &Node<'tree>, code: &'a [u8], - _ancestors: Ancestors<'tree, '_>, + ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { - // we're in a function or in a class - if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - Some("") - } + default_func_space_name(node, code, ancestors) } fn get_space_kind(_node: &Node) -> SpaceKind { diff --git a/src/getter/groovy.rs b/src/getter/groovy.rs index 6db7794e..5537ad12 100644 --- a/src/getter/groovy.rs +++ b/src/getter/groovy.rs @@ -4,6 +4,38 @@ use super::*; impl Getter for GroovyCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` rather than the JVM's ``: the same + /// construct exists in JavaScript, where `` would mean + /// nothing, and one spelling across languages is worth more here + /// than JVM precision. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + if node.kind_id() == Groovy::StaticInitializer as u16 { + return Some(""); + } + crate::getter::default_func_space_name(node, code, ancestors) + } + fn get_space_kind(node: &Node) -> SpaceKind { use Groovy::{ AnnotationTypeDeclaration, ClassDeclaration, Closure, ConstructorDeclaration, @@ -37,7 +69,12 @@ impl Getter for GroovyCode { InterfaceDeclaration | TraitDeclaration | AnnotationTypeDeclaration => { SpaceKind::Interface } - MethodDeclaration | ConstructorDeclaration | Closure => SpaceKind::Function, + // `Groovy::` qualified: this module imports an explicit list + // rather than glob-importing, so a bare name here would parse + // as a binding and match everything. + MethodDeclaration | ConstructorDeclaration | Closure | Groovy::StaticInitializer => { + SpaceKind::Function + } SourceFile => SpaceKind::Unit, _ => SpaceKind::Unknown, } diff --git a/src/getter/java.rs b/src/getter/java.rs index 0e161633..672b7fdc 100644 --- a/src/getter/java.rs +++ b/src/getter/java.rs @@ -4,6 +4,38 @@ use super::*; impl Getter for JavaCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` rather than the JVM's ``: the same + /// construct exists in JavaScript, where `` would mean + /// nothing, and one spelling across languages is worth more here + /// than JVM precision. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + if node.kind_id() == Java::StaticInitializer as u16 { + return Some(""); + } + crate::getter::default_func_space_name(node, code, ancestors) + } + fn get_space_kind(node: &Node) -> SpaceKind { use Java::*; @@ -22,10 +54,12 @@ impl Getter for JavaCode { // `name` field holding the record's simple name, so the // default `get_func_space_name` already names the space `R` — // identical to what the canonical spelling would produce. + // `StaticInitializer` is `static { … }` (#1184). MethodDeclaration | ConstructorDeclaration | CompactConstructorDeclaration - | LambdaExpression => SpaceKind::Function, + | LambdaExpression + | StaticInitializer => SpaceKind::Function, InterfaceDeclaration | AnnotationTypeDeclaration => SpaceKind::Interface, // An anonymous class (`new Runnable() { ... }`) is an // `object_creation_expression` carrying a `class_body` child; diff --git a/src/getter/javascript.rs b/src/getter/javascript.rs index 6bcb884a..34a85b3c 100644 --- a/src/getter/javascript.rs +++ b/src/getter/javascript.rs @@ -8,12 +8,14 @@ impl Getter for JavascriptCode { use Javascript::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, _ => SpaceKind::Unknown, @@ -25,28 +27,28 @@ impl Getter for JavascriptCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Javascript::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Javascript::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Javascript::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Javascript::Pair => "key", + Javascript::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } impl_js_family_get_op_type!( diff --git a/src/getter/kotlin.rs b/src/getter/kotlin.rs index 0d7110af..b4ba8623 100644 --- a/src/getter/kotlin.rs +++ b/src/getter/kotlin.rs @@ -123,6 +123,39 @@ fn kotlin_string_has_interp(node: &Node, code: &[u8]) -> bool { } impl Getter for KotlinCode { + /// Names the space, synthesising one for constructs that carry no + /// name token (#1184). + /// + /// `get_func_space_name` returns `Option<&'a str>` borrowed from + /// `code`, so the only name available for a nameless construct is a + /// `&'static str` — a per-property spelling like `` would + /// need a signature change. Angle brackets follow the existing + /// `` convention and cannot collide with a real + /// identifier in any of these grammars. + /// + /// Sibling collisions are accepted, exactly as multiple + /// `` siblings already are: two properties each with a + /// getter, or two `static { }` blocks in one class, produce two + /// spaces with the same name. Nothing enforces name uniqueness + /// among siblings, and inventing an index would make the name + /// unstable under an unrelated edit. + /// + /// `` / `` are Kotlin's own spelling of the accessor + /// keywords; `` is what the JVM calls the constructor an + /// `init` block compiles into. + fn get_func_space_name<'a, 'tree>( + node: &Node<'tree>, + code: &'a [u8], + ancestors: Ancestors<'tree, '_>, + ) -> Option<&'a str> { + match node.kind_id().into() { + Kotlin::Getter => Some(""), + Kotlin::Setter => Some(""), + Kotlin::AnonymousInitializer => Some(""), + _ => crate::getter::default_func_space_name(node, code, ancestors), + } + } + fn get_space_kind(node: &Node) -> SpaceKind { use Kotlin::*; @@ -165,6 +198,13 @@ impl Getter for KotlinCode { FunctionDeclaration | SecondaryConstructor | LambdaLiteral | AnonymousFunction => { SpaceKind::Function } + // Property accessors and `init { … }` (#1184). Fully + // qualified deliberately: the enclosing `use Kotlin::*` would + // otherwise put the `Getter` *variant* and the `Getter` + // *trait* (in scope from `use super::*`) in the same + // namespace. `src/getter/ruby.rs` hit the same collision and + // solved it with `use Ruby as R;`. + Kotlin::Getter | Kotlin::Setter | Kotlin::AnonymousInitializer => SpaceKind::Function, SourceFile => SpaceKind::Unit, _ => SpaceKind::Unknown, } diff --git a/src/getter/mozjs.rs b/src/getter/mozjs.rs index 90b1bcca..2b4e6a37 100644 --- a/src/getter/mozjs.rs +++ b/src/getter/mozjs.rs @@ -8,12 +8,14 @@ impl Getter for MozjsCode { use Mozjs::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration => SpaceKind::Class, Program => SpaceKind::Unit, _ => SpaceKind::Unknown, @@ -25,28 +27,28 @@ impl Getter for MozjsCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Mozjs::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Mozjs::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Mozjs::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Mozjs::Pair => "key", + Mozjs::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } impl_js_family_get_op_type!( diff --git a/src/getter/tsx.rs b/src/getter/tsx.rs index b57fd88e..2c56b4df 100644 --- a/src/getter/tsx.rs +++ b/src/getter/tsx.rs @@ -8,12 +8,14 @@ impl Getter for TsxCode { use Tsx::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, Program => SpaceKind::Unit, @@ -26,28 +28,28 @@ impl Getter for TsxCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Tsx::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Tsx::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Tsx::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Tsx::Pair => "key", + Tsx::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } // TSX exposes two anonymous `"string"` aliases: `String2` (the diff --git a/src/getter/typescript.rs b/src/getter/typescript.rs index bf06e51c..f7facaa3 100644 --- a/src/getter/typescript.rs +++ b/src/getter/typescript.rs @@ -8,12 +8,14 @@ impl Getter for TypescriptCode { use Typescript::*; match node.kind_id().into() { + // `ClassStaticBlock` is ES2022 `static { … }` (#1184). FunctionExpression | MethodDefinition | GeneratorFunction | FunctionDeclaration | GeneratorFunctionDeclaration - | ArrowFunction => SpaceKind::Function, + | ArrowFunction + | ClassStaticBlock => SpaceKind::Function, Class | ClassDeclaration | AbstractClassDeclaration => SpaceKind::Class, InterfaceDeclaration => SpaceKind::Interface, Program => SpaceKind::Unit, @@ -26,28 +28,28 @@ impl Getter for TypescriptCode { code: &'a [u8], ancestors: Ancestors<'tree, '_>, ) -> Option<&'a str> { + // A class static block has no name token and no naming parent to + // fall back on, so it would otherwise land on `` + // alongside every arrow and function expression (#1184). + if node.kind_id() == Typescript::ClassStaticBlock as u16 { + return Some(""); + } if let Some(name) = node.child_by_field_name("name") { - node_text(code, &name) - } else { - // We can be in a pair: foo: function() {} - // Or in a variable declaration: var aFun = function() {} - if let Some(parent) = ancestors.parent(node) { - match parent.kind_id().into() { - Typescript::Pair => { - if let Some(name) = parent.child_by_field_name("key") { - return node_text(code, &name); - } - } - Typescript::VariableDeclarator => { - if let Some(name) = parent.child_by_field_name("name") { - return node_text(code, &name); - } - } - _ => {} - } - } - Some("") + return node_text(code, &name); } + // Otherwise the name comes from the binding site: a pair + // (`foo: function () {}`) or a variable declaration + // (`var aFun = function () {}`). The two differ only in which + // field carries the name, so they collapse to one lookup. + let bound_name = ancestors.parent(node).and_then(|parent| { + let field = match parent.kind_id().into() { + Typescript::Pair => "key", + Typescript::VariableDeclarator => "name", + _ => return None, + }; + parent.child_by_field_name(field) + }); + bound_name.map_or(Some(""), |name| node_text(code, &name)) } // TS exposes `String2` as the anonymous `"string"` alias for the diff --git a/src/metric_catalog.rs b/src/metric_catalog.rs index 9c75cea7..454bbe4e 100644 --- a/src/metric_catalog.rs +++ b/src/metric_catalog.rs @@ -47,8 +47,17 @@ pub enum MetricScope { /// Gate only individual function spaces ([`SpaceKind::Function`] — /// free functions, methods, closures). The per-function complexity /// metrics (cognitive, cyclomatic, abc, mi.*) and the subtree sums - /// that describe one function and its nested closures (halstead.*, - /// nargs, nexits, tokens) live here. + /// that describe one function (halstead.*, nargs, nexits, tokens) + /// live here. + /// + /// Whether "one function" includes its nested closures is per + /// metric, not per scope. `halstead.*`, `nexits` and `tokens` read + /// subtree sums, because a closure's tokens and exits really are + /// part of the enclosing body a reader must follow. `nargs` reads + /// the space's own parameters instead (#1196): a closure that opens + /// its own space is gated on its own row, and summing its arguments + /// into the enclosing signature made the offender's number describe + /// something its remediation could not change. Function, /// Gate only container spaces that own methods (class / struct / /// trait / impl / namespace / interface) — the object-oriented size diff --git a/src/metrics/abc.rs b/src/metrics/abc.rs index 8a53c33f..a708c44a 100644 --- a/src/metrics/abc.rs +++ b/src/metrics/abc.rs @@ -8671,8 +8671,10 @@ function f(int $a, int $b): int { #[test] fn tcl_ternary_counts_condition() { - // `$a ? $b : $c` inside an `expr` is one `ternary_expr` - // node → 1 condition. + // The `ternary_expr` node is one condition and its condition + // slot `$a` — a bare truthy test — is another, matching C++'s + // `int r = a ? b : c;` (also 2). The two branch operands are + // unnegated and so contribute nothing (#1180). check_metrics::( "proc f {a b c} {\n\ set r [expr {$a ? $b : $c}]\n\ @@ -8681,12 +8683,123 @@ function f(int $a, int $b): int { |metric| { assert_eq!(metric.abc.assignments_sum(), 1); assert_eq!(metric.abc.branches_sum(), 0); - assert_eq!(metric.abc.conditions_sum(), 1); + assert_eq!(metric.abc.conditions_sum(), 2); insta::assert_json_snapshot!(metric.abc); }, ); } + /// The Tcl half of the ternary slot-location guard (#1180). + /// + /// `ternary_expr` exposes no grammar fields, so the slots are found + /// relative to the `?` and `:` tokens. A *parenthesised* condition is + /// the input that discriminates that from a fixed-index reading: + /// `_expr` inlines `( … )` as anonymous children of `ternary_expr`, + /// so `($a) ? !$b : !$c` shifts every operand right by one and + /// `child(0)` / `child(2)` / `child(4)` land on `(`, `)` and `?`. + /// Without this case the whole fixed-index revert passes. + #[test] + fn tcl_parenthesised_ternary_condition_matches_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + let bare = conditions("proc f {a b c} {\n set r [expr {$a ? !$b : !$c}]\n}"); + assert_eq!(bare, 4, "the bare form is the documented reference value"); + assert_eq!( + conditions("proc f {a b c} {\n set r [expr {($a) ? !$b : !$c}]\n}"), + bare, + "parenthesising the condition must not change the count" + ); + } + + /// A parenthesised operand under `!` counts the same as a bare one. + /// + /// `_expr` inlines `( … )` as anonymous children, so `!($a)` puts + /// `(` where a positional read expects the operand. The walker's + /// negation branch kept a fixed `child(1)` through the first draft of + /// #1180 and scored these 0 while the unparenthesised forms scored 1 + /// — an inconsistency the fix itself introduced, since before it + /// neither form counted. Found in review, not by the tests: the + /// parenthesised fixtures added with #1180 covered the ternary + /// *condition* slot only. + #[test] + fn tcl_parenthesised_negated_operands_match_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + for (bare, parenthesised) in [ + ( + "proc f {a} {\n if {!$a} { puts x }\n}", + "proc f {a} {\n if {!($a)} { puts x }\n}", + ), + ( + "proc f {a b} {\n if {$a && !$b} { puts x }\n}", + "proc f {a b} {\n if {$a && !($b)} { puts x }\n}", + ), + ( + "proc f {a b c} {\n set r [expr {$a ? !$b : !$c}]\n}", + "proc f {a b c} {\n set r [expr {$a ? !($b) : !$c}]\n}", + ), + ] { + let want = conditions(bare); + assert!(want > 0, "the bare form must count something: {bare}"); + assert_eq!( + conditions(parenthesised), + want, + "parenthesising the negated operand changed the count\n bare: {bare}\n paren: {parenthesised}" + ); + } + } + + #[test] + fn irules_abc_parenthesised_negated_operands_match_the_bare_form() { + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Irules, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + let want = conditions("when X {\n if { !$a } { log local0. hi }\n}\n"); + assert_eq!(want, 1); + assert_eq!( + conditions("when X {\n if { !($a) } { log local0. hi }\n}\n"), + want + ); + } + + #[test] + fn tcl_bare_truthy_and_negated_predicates_count_one_condition() { + // The headline #1180 fix, on the Tcl side: both were 0 before. + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Tcl, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + assert_eq!(conditions("proc f {a} {\n if {$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n if {!$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n while {$a} { puts x }\n}"), 1); + assert_eq!(conditions("proc f {a} {\n while {!$a} { puts x }\n}"), 1); + } + #[test] fn tcl_elseif_and_else_count_conditions() { // `if` / `elseif` / `else` clause productions each @@ -8728,8 +8841,14 @@ function f(int $a, int $b): int { }", "foo.tcl", |metric| { - // The two `expr` predicates feed the walker: 4 + 3 = 7. - assert_eq!(metric.abc.conditions_sum(), 7); + // The two chains feed the walker: 4 + 3 = 7. Each `if` + // predicate is additionally a bare truthy test of a + // command substitution — `{[expr {…}]}` is structurally + // `if {[somecmd]}`, which counts 1 exactly as `if {$a}` + // does — so 7 + 2 = 9 (#1180). Written without the + // redundant `[expr …]` wrapper, `if {$a || $b}` scores + // 2, matching C++'s `if (a || b)`. + assert_eq!(metric.abc.conditions_sum(), 9); insta::assert_json_snapshot!(metric.abc); }, ); @@ -8747,7 +8866,10 @@ function f(int $a, int $b): int { }", "foo.tcl", |metric| { - assert_eq!(metric.abc.conditions_sum(), 4); + // 2 + 2 from the chains, plus one bare truthy test per + // `while` predicate — see `tcl_if_multiple_conditions` + // (#1180). + assert_eq!(metric.abc.conditions_sum(), 6); insta::assert_json_snapshot!(metric.abc); }, ); @@ -8895,7 +9017,11 @@ function f(int $a, int $b): int { "foo.irule", |metric| { assert_eq!(metric.abc.assignments_sum(), 3); - assert_eq!(metric.abc.conditions_sum(), 2); + // The `elseif` and `else` clauses are one condition each, + // as before; #1180 adds the two bare truthy predicates + // (`{ $a }`, `{ $b }`). C++'s + // `if(a){} else if(b){} else {}` also scores 4. + assert_eq!(metric.abc.conditions_sum(), 4); }, ); } @@ -8930,21 +9056,360 @@ function f(int $a, int $b): int { ); } - /// A bare-truthy `if {$a}` condition carries no comparison, ternary, - /// or short-circuit operator, so the unary-conditional walker is - /// never invoked and conditions stay 0 — iRules does not wire the - /// broader Phase 2B slot routing that counts bare-truthy operands. - /// The `log` command is the single branch. Pins the metrics-book - /// claim in the ABC per-language deviation table. - #[test] - fn irules_abc_bare_truthy_reports_zero() { + /// A bare-truthy `if {$a}` predicate is one condition (#1180). + /// + /// It carries no comparison, ternary or short-circuit operator, so + /// before the Phase 2B slot routing landed nothing invoked the + /// unary-conditional walker and the whole predicate scored 0 — this + /// test previously pinned that absence, and the metrics book's ABC + /// deviation table said so. Now the `if` node routes its `expr` + /// predicate and the count matches C++'s `if (a)`, which is also 1. + /// The `log` command remains the single branch. + #[test] + fn irules_abc_bare_truthy_counts_one_condition() { check_metrics::( "when X {\n if { $a } { log local0. hi }\n}\n", "foo.irule", |metric| { assert_eq!(metric.abc.branches_sum(), 1); - assert_eq!(metric.abc.conditions_sum(), 0); + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + + /// The negated form of the same predicate, which was *also* 0 before + /// #1180: `!$a` reached the walker but no parent seeded boolean + /// context, so the terminal operand was never counted. Distinct from + /// `irules_abc_negated_operands_in_chain`, whose `&&` supplied the + /// seed the bare form lacked. + #[test] + fn irules_abc_negated_bare_truthy_counts_one_condition() { + check_metrics::( + "when X {\n if { !$a } { log local0. hi }\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.branches_sum(), 1); + assert_eq!(metric.abc.conditions_sum(), 1); + }, + ); + } + + /// The ternary's three operand slots, located relative to the `?` + /// and `:` tokens because the grammar exposes no fields (#1180). + /// + /// `$a ? !$b : !$c` is four: the `ternary_expr` node, the bare + /// truthy condition, and one per negated branch — the same value + /// Java, C#, Groovy, the C family, the JS family, PHP, Perl, Ruby + /// and Python report for the identical expression. + #[test] + fn irules_abc_ternary_routes_its_operand_slots() { + check_metrics::( + "when X {\n set y [expr { $a ? !$b : !$c }]\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 4); }, ); } + + /// The #1161 control: a ternary whose condition is a *comparison* + /// must not move. The `>` already supplied its condition and the + /// branches are unnegated, so routing the slots adds nothing. + #[test] + fn irules_abc_comparison_ternary_is_unchanged_by_slot_routing() { + check_metrics::( + "when X {\n set y [expr { $a > 0 ? 1 : 0 }]\n}\n", + "foo.irule", + |metric| { + assert_eq!(metric.abc.conditions_sum(), 2); + }, + ); + } + + /// A parenthesised ternary condition scores the same as a bare one. + /// + /// The grammar inlines `( … )` as anonymous children of + /// `ternary_expr` rather than wrapping them in a node, so a + /// fixed-index reading of the slots would shift right by one and + /// mis-assign every operand. This is the input that discriminates + /// the token-relative location the fix uses. + #[test] + fn irules_abc_parenthesised_ternary_condition_matches_the_bare_form() { + // `check_metrics` takes a bare `fn`, so it cannot carry the + // first measurement into the second comparison; `metrics_verbatim` + // returns a value instead. + let conditions = |source: &str| { + crate::test_support::metrics_verbatim( + crate::LANG::Irules, + source.as_bytes(), + crate::MetricsOptions::default(), + ) + .abc + .conditions_sum() + }; + let bare = conditions("when X {\n set y [expr { $a ? !$b : !$c }]\n}\n"); + assert_eq!(bare, 4, "the bare form is the documented reference value"); + assert_eq!( + conditions("when X {\n set y [expr { ($a) ? !$b : !$c }]\n}\n"), + bare, + "parenthesising the condition must not change the count" + ); + } +} + +/// A comment inside a ternary must not change its ABC conditions +/// (#1181). +/// +/// Two opposite defects, one cause: tree-sitter counts a comment among a +/// node's children, so it is the operand's previous sibling *and* it +/// shifts every positional index. +/// +/// * Languages whose seed asked "is my previous sibling `?` or `:`" +/// (C family, PHP, Perl, JS family) read the comment as "not a +/// ternary token", flipped the boolean-context seed on for a *branch* +/// slot, and **over**-counted: `a ? /*n*/ (b) : c` scored 3 where +/// `a ? (b) : c` scores 2. +/// * Languages whose branch walk read `child(2)` / `child(4)` (Java, +/// C#, Groovy) landed on the comment instead of the operand, never +/// inspected it, and **under**-counted: `a ? /*n*/ !b : c` scored 2 +/// where `a ? !b : c` scores 3. +/// +/// Both slots are now addressed by grammar field. The parenthesised +/// operand is the only input that discriminates the first defect and +/// the negated operand the only one that discriminates the second — +/// existing ternary fixtures use neither. +#[cfg(test)] +mod ternary_comment_invariance { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(base, with_comment)` for a parenthesised and a negated branch + /// operand, per language. + fn cases(lang: LANG) -> Option<[(String, String); 2]> { + // `{}` marks the consequence slot; `/*n*/` the inserted comment. + let (template, paren, negated, comment) = match lang { + LANG::Cpp | LANG::C | LANG::Objc | LANG::Mozcpp => { + ("int f(){ int x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Java => ( + "class K{ void f(){ int x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Csharp => ( + "class K{ void f(){ var x = a ? {} : c; } }", + "(b)", + "!b", + "/*n*/ ", + ), + LANG::Groovy => ("def f(){ def x = a ? {} : c }", "(b)", "!b", "/*n*/ "), + LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => { + ("function f(){ var x = a ? {} : c; }", "(b)", "!b", "/*n*/ ") + } + LANG::Php => ( + " ("sub f { my $x = $a ? {} : $c; }", "($b)", "!$b", "# n\n "), + _ => return None, + }; + let build = |operand: &str, with_comment: bool| { + let slot = if with_comment { + format!("{comment}{operand}") + } else { + operand.to_owned() + }; + template.replace("{}", &slot) + }; + Some([ + (build(paren, false), build(paren, true)), + (build(negated, false), build(negated, true)), + ]) + } + + #[test] + fn a_comment_before_a_branch_operand_changes_nothing() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = cases(lang) else { continue }; + checked += 1; + for (base, commented) in pairs { + assert_eq!( + conditions(lang, &commented), + conditions(lang, &base), + "{lang:?}: a comment changed the ABC conditions of a ternary\n \ + without: {base}\n with: {commented}" + ); + } + } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); + } + + /// The absolute values the invariance test compares against, so a + /// regression that moved *both* sides equally still fails. + /// + /// expected: `a ? (b) : c` counts the `?` marker plus the condition + /// `a` in boolean context = 2. Negating the consequence adds one + /// more, since `!b` establishes boolean content for that slot = 3. + #[test] + fn the_baseline_values_are_two_and_three() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some([(paren, _), (negated, _)]) = cases(lang) else { + continue; + }; + assert_eq!( + conditions(lang, &paren), + 2, + "{lang:?}: parenthesised branch" + ); + assert_eq!(conditions(lang, &negated), 3, "{lang:?}: negated branch"); + checked += 1; + } + assert!( + checked > 0, + "no ternary language enabled; this test asserted nothing" + ); + } +} + +/// A keyword negation must score like its symbolic twin (#1182). +/// +/// `not b` and `!b` are the same negation — they differ in precedence, +/// not in meaning, and ABC counts the negation rather than the parse. +/// Ruby and Perl tested only the `!` token, so `if not b` scored 0 +/// against `if !b`'s 1, and a `not` ternary scored 2 against the `!` +/// form's 4. +/// +/// Lua and Elixir were checked in the same sweep and were already +/// correct: Lua's only negation keyword *is* `not` and it was the token +/// being tested, and Elixir reaches the same count by another path. +/// They are exercised here so a future edit cannot regress them +/// silently. Python counts `not` through its own dispatcher arm and has +/// no `!` spelling to compare against. +#[cfg(test)] +mod keyword_negation_parity { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + fn conditions(lang: LANG, source: &str) -> u64 { + metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + .abc + .conditions_sum() + } + + /// `(bang_form, keyword_form)` pairs that must score identically. + fn pairs(lang: LANG) -> Option> { + let build = + |t: &str| -> (String, String) { (t.replace("{NOT}", "!"), t.replace("{NOT}", "not ")) }; + let templates: &[&str] = match lang { + LANG::Ruby => &[ + "def f(b)\n if {NOT}b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? ({NOT}b) : ({NOT}c)\nend\n", + "def f(a, b, c)\n x = a ? b : ({NOT}c)\nend\n", + ], + LANG::Perl => &[ + "sub f { if ({NOT}$b) { 1; } }", + "sub f { my $x = $a ? ({NOT}$b) : ({NOT}$c); }", + "sub f { my $x = $a ? $b : ({NOT}$c); }", + ], + // Already correct before #1182; pinned so they stay that way. + LANG::Elixir => &["def f(b) do\n if {NOT}b do\n 1\n end\nend\n"], + _ => return None, + }; + Some(templates.iter().map(|t| build(t)).collect()) + } + + #[test] + fn the_not_keyword_scores_like_bang() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some(pairs) = pairs(lang) else { continue }; + checked += 1; + for (bang, keyword) in pairs { + assert_eq!( + conditions(lang, &keyword), + conditions(lang, &bang), + "{lang:?}: `not` and `!` scored differently\n bang: {bang}\n keyword: {keyword}" + ); + } + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } + + /// The absolute values, so a regression that moved both spellings + /// equally still fails. + /// + /// expected: `if !b` is one condition — the negated bare operand. + /// `a ? (!b) : (!c)` is four: the `?` marker, the condition `a` in + /// boolean context, and one per negated branch operand. + #[test] + fn the_baseline_values_are_one_and_four() { + let mut checked = 0; + for (lang, guard, ternary) in [ + ( + LANG::Ruby, + "def f(b)\n if not b\n 1\n end\nend\n", + "def f(a, b, c)\n x = a ? (not b) : (not c)\nend\n", + ), + ( + LANG::Perl, + "sub f { if (not $b) { 1; } }", + "sub f { my $x = $a ? (not $b) : (not $c); }", + ), + ] { + if !lang.is_enabled() { + continue; + } + assert_eq!(conditions(lang, guard), 1, "{lang:?}: `if not b`"); + assert_eq!(conditions(lang, ternary), 4, "{lang:?}: `not` ternary"); + checked += 1; + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } + + /// Lua's only negation keyword is `not`, so it has no `!` twin to + /// compare against — its guard is that the keyword counts at all. + #[test] + fn lua_counts_its_only_negation_keyword() { + if !LANG::Lua.is_enabled() { + return; + } + assert_eq!( + conditions( + LANG::Lua, + "function f(b)\n if not b then return 1 end\nend\n" + ), + 1 + ); + } } diff --git a/src/metrics/abc/cpp.rs b/src/metrics/abc/cpp.rs index ae919876..7dc4f8ec 100644 --- a/src/metrics/abc/cpp.rs +++ b/src/metrics/abc/cpp.rs @@ -39,9 +39,9 @@ pub(super) fn cpp_inspect_container(container_node: &Node, parent: &Node, condit parent_kind, "binary_expression" | "if_statement" | "while_statement" | "do_statement" | "for_statement" ) || (parent_kind == "conditional_expression" - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind(), "?" | ":"))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { // `condition_clause` is the C++-grammar wrapper around an diff --git a/src/metrics/abc/csharp.rs b/src/metrics/abc/csharp.rs index cacc796c..e3e1ccf3 100644 --- a/src/metrics/abc/csharp.rs +++ b/src/metrics/abc/csharp.rs @@ -26,9 +26,9 @@ fn csharp_inspect_container(container_node: &Node, parent: &Node, conditions: &m // expression evaluates as a condition. let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement => true, - ConditionalExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON)), + ConditionalExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -260,19 +260,28 @@ fn csharp_walk_for_conditions<'a>( } } -// `cond ? a : b` — children are [cond, ?, a, :, b]. The cond-classifier -// match is shared with `csharp_walk_for_conditions`'s `if`/`while`/`do` -// arms via `csharp_count_condition`; the two branch slots delegate to -// `csharp_inspect_container` via `csharp_inspect_child` so a parenthesised -// or `!`-prefixed branch contributes one condition just like a bare +// `cond ? a : b`, addressed by the grammar's `condition` / +// `consequence` / `alternative` fields rather than by index (#1181 — a +// comment between a token and its operand shifted every positional +// read). The cond-classifier match is shared with +// `csharp_walk_for_conditions`'s `if`/`while`/`do` arms via +// `csharp_count_condition`; the two branch slots go straight to +// `csharp_inspect_container`, so a parenthesised or `!`-prefixed branch +// contributes one condition just like a bare // invocation/identifier/boolean would. fn csharp_walk_conditional(node: &Node, stats: &mut Stats) { let conds = &mut stats.conditions; - if let Some(condition) = node.child(0) { + // By grammar FIELD, not index — see `java_walk_ternary` for why the + // positional form dropped a negated branch operand behind a comment + // (#1181). + if let Some(condition) = node.child_by_field_name("condition") { csharp_count_condition(&condition, node, conds); } - csharp_inspect_child(node, 2, conds); - csharp_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + csharp_inspect_container(&branch, node, conds); + } + } } // Counts unary / single-token conditions inside `for` statements. The diff --git a/src/metrics/abc/groovy.rs b/src/metrics/abc/groovy.rs index cc6f968f..5a266dd5 100644 --- a/src/metrics/abc/groovy.rs +++ b/src/metrics/abc/groovy.rs @@ -21,9 +21,9 @@ fn groovy_inspect_container(container_node: &Node, parent: &Node, conditions: &m let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoWhileStatement | ForStatement => true, - TernaryExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev_node| !matches!(prev_node.kind_id().into(), QMARK | COLON)), + TernaryExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -220,11 +220,17 @@ fn groovy_walk_for_conditions<'a>( fn groovy_walk_ternary(node: &Node, stats: &mut Stats) { let conds = &mut stats.conditions; - if let Some(condition) = node.child(0) { + // By grammar FIELD, not index — see `java_walk_ternary` for why the + // positional form dropped a negated branch operand behind a comment + // (#1181). + if let Some(condition) = node.child_by_field_name("condition") { groovy_count_condition(&condition, node, conds); } - groovy_inspect_child(node, 2, conds); - groovy_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + groovy_inspect_container(&branch, node, conds); + } + } } // Two shapes: a present condition lives at child(3); an empty condition diff --git a/src/metrics/abc/irules.rs b/src/metrics/abc/irules.rs index fafd73b5..bd1ea3ca 100644 --- a/src/metrics/abc/irules.rs +++ b/src/metrics/abc/irules.rs @@ -13,6 +13,76 @@ use super::{Abc, Stats}; use crate::macros::irules_bool_terminal_kinds; use crate::*; +/// The three operand slots of a `ternary_expr`, located relative to the +/// `?` and `:` tokens rather than by fixed index. +/// +/// The grammar exposes **no fields** on `ternary_expr` (verified against +/// `node-types.json`: `fields: []`), so the `child_by_field_name` model +/// every other language's ABC ternary uses does not transfer here. Nor +/// does a fixed index: `_expr` inlines `seq('(', $._expr, ')')`, so +/// `($a) ? $b : $c` puts the anonymous parens directly under +/// `ternary_expr` and shifts every operand right (#1180, +/// grammar-dispatch item 3). +/// +/// Slots are therefore the *named* children adjacent to the two marker +/// tokens, which is stable under both parenthesisation and comments. +fn irules_ternary_slots<'a>( + node: &Node<'a>, +) -> (Option>, Option>, Option>) { + let (mut condition, mut consequence, mut alternative) = (None, None, None); + let mut seen_question = false; + let mut seen_colon = false; + + for child in node.children() { + match child.kind_id().into() { + Irules::QMARK => seen_question = true, + Irules::COLON => seen_colon = true, + _ if !child.is_named() => {} + _ if seen_colon => alternative = alternative.or(Some(child)), + _ if seen_question => consequence = consequence.or(Some(child)), + // The condition is the *last* named child before `?`, so a + // parenthesised condition resolves to the inner expression + // rather than to whatever preceded it. + _ => condition = Some(child), + } + } + (condition, consequence, alternative) +} + +/// Routes the three ternary slots (#1180). +/// +/// Mirrors `cpp_walk_ternary`: the condition is counted directly when it +/// is already a terminal bool, and otherwise handed to the wrapper-peeling +/// walker; each branch goes to the walker, which counts it only if a `!` +/// establishes boolean content for that slot. +fn irules_walk_ternary(node: &Node, conditions: &mut f64) { + let (condition, consequence, alternative) = irules_ternary_slots(node); + if let Some(condition) = condition { + if matches!(condition.kind_id().into(), irules_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + irules_inspect_container(&condition, node, conditions); + } + } + for branch in [consequence, alternative].into_iter().flatten() { + irules_inspect_container(&branch, node, conditions); + } +} + +/// The `expr` wrapper holding an `if` / `elseif` / `while` predicate. +/// +/// `while` exposes no `condition` field at all — the grammar is +/// `seq('while', $.expr, $._word)` — so the slot is found by kind. Match +/// `Irules::Expr` (the braced `{ … }` expression node) and not +/// `Irules::Expr2`, which is the `expr` *command keyword* under +/// `expr_cmd` and renders to the same name (grammar-dispatch item 1). +/// `Irules::Expr3` is the hidden `_expr` supertype the parser never emits +/// (item 2). +fn irules_condition_expr<'a>(node: &Node<'a>) -> Option> { + node.children() + .find(|child| matches!(child.kind_id().into(), Irules::Expr)) +} + impl Abc for IrulesCode { fn compute<'a>( node: &Node<'a>, @@ -57,11 +127,26 @@ impl Abc for IrulesCode { | Irules::MatchesGlob | Irules::In | Irules::Ni - | Irules::TernaryExpr - | Irules::Elseif | Irules::Else => { stats.conditions += 1.; } + // Phase 2B slot routing (#1180) — see `tcl.rs`, which this + // mirrors arm for arm. + Irules::If | Irules::While => { + if let Some(expr) = irules_condition_expr(node) { + irules_inspect_container(&expr, node, &mut stats.conditions); + } + } + Irules::Elseif => { + stats.conditions += 1.; + if let Some(expr) = irules_condition_expr(node) { + irules_inspect_container(&expr, node, &mut stats.conditions); + } + } + Irules::TernaryExpr => { + stats.conditions += 1.; + irules_walk_ternary(node, &mut stats.conditions); + } // Fitzpatrick Rule 9: the short-circuit operators are not counted // directly (cross-language policy, #395); instead each operand of // a `&&`/`||`/`and`/`or` chain is one condition (#403). iRules' @@ -97,21 +182,73 @@ fn irules_command_is_assignment(node: &Node, code: &[u8]) -> bool { // iRules counterpart of `tcl_inspect_container` (Fitzpatrick Rule 9): a // negated bare operand (`!$flag`) inside a boolean chain is one condition. fn irules_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { + // bca: suppress(cognitive) — wrapper-peeling state machine, clearest whole + // The same shape as `cpp_inspect_container`, and it carries the same + // marker for the same reason: one loop peels the `expr` / `!` layers + // while carrying a single boolean-context flag, the flag must be + // readable at every step so any split would have to thread it back + // out, and the parts have no names a reader would draw. It crossed + // the limit when #1180 wired the Phase 2B slot routing — the seed + // grew a ternary-slot disjunct and the loop gained the wrapper peel + // every sibling already had. let mut node = *container_node; let mut node_kind = node.kind_id().into(); - let has_boolean_content = matches!(parent.kind_id().into(), Irules::BinopExpr); + let parent_kind = parent.kind_id().into(); + // Phase 2B slot routing (#1180). Before it, only a `&&` / `||` chain + // seeded boolean context, so `if {!$a}` and every ternary operand + // scored zero. The `if` / `elseif` / `while` predicate is a boolean + // context by construction; a ternary seeds only its *condition* + // slot, since the two branches are type-free and an unnegated branch + // must contribute nothing. + let mut has_boolean_content = matches!( + parent_kind, + Irules::BinopExpr | Irules::If | Irules::Elseif | Irules::While + ); + // No ternary-condition disjunct here, unlike `cpp_inspect_container`. + // It would be dead: the flag is read only *after* a peel, and the + // loop peels exactly two kinds. `Expr` cannot sit under + // `ternary_expr` — the grammar's `_expr` alternatives are + // `unary_expr | binop_expr | ternary_expr | escaped_character | + // '(' _expr ')' | _expr_atom_no_brace | braced_word_simple` — and a + // `!`-unary sets the flag itself two lines below. Every other + // condition-slot kind breaks before the flag is read, and every + // terminal is counted by the ternary walker directly. Verified by + // deletion: the whole suite passes without it. + // + // The C-family version *is* live because C keeps `(a)` as a + // `parenthesized_expression` for the loop to peel, where this + // grammar inlines the parens. Porting it verbatim also cost two full + // child scans per ternary to produce `false`. loop { + // The `expr` wrapper is this grammar's `{ … }` predicate node — + // the analogue of the C family's `condition_clause`, and peeled + // the same way. + let is_parens = matches!(node_kind, Irules::Expr); let is_not = matches!(node_kind, Irules::UnaryExpr) && node .child(0) .is_some_and(|c| c.kind_id() == Irules::BANG as u16); - if !is_not { + if !is_parens && !is_not { break; } + // A `!` proves the operand is boolean even where the parent did + // not — every sibling language carries this line; its absence was + // why a negated operand outside a `&&` chain could never count. + if !has_boolean_content && is_not { + has_boolean_content = true; + } - let Some(child) = node.child(1) else { break }; + // The first *named* child, for both wrappers. `!` and the `{` + // / `(` delimiters are all anonymous, so this is the operand in + // every shape — and unlike a fixed index it survives `_expr` + // inlining its parens: `!($a)` puts `(` at child 1, where a + // positional read lands on the delimiter and the walk stops + // without counting the negation at all. + let Some(child) = node.children().find(Node::is_named) else { + break; + }; node = child; node_kind = node.kind_id().into(); diff --git a/src/metrics/abc/java.rs b/src/metrics/abc/java.rs index f875d1e9..215a2d69 100644 --- a/src/metrics/abc/java.rs +++ b/src/metrics/abc/java.rs @@ -24,9 +24,9 @@ fn java_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // Initializes the flag to true if the container is known to contain a boolean value let mut has_boolean_content = match parent.kind_id().into() { BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement => true, - TernaryExpression => node - .previous_sibling_under(parent) - .is_none_or(|prev_node| !matches!(prev_node.kind_id().into(), QMARK | COLON)), + TernaryExpression => parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id()), _ => false, }; @@ -238,9 +238,17 @@ fn java_walk_for_conditions<'a>(node: &Node<'a>, ancestors: Ancestors<'a, '_>, s fn java_walk_ternary(node: &Node, stats: &mut Stats) { use Java::*; let conds = &mut stats.conditions; - // Child 0: condition itself. The terminal set mirrors the one in - // `java_inspect_container` (issue #372 / lesson #19). - if let Some(condition) = node.child(0) { + // Slots are addressed by grammar FIELD, not by index. The positional + // form read children 0 / 2 / 4, and tree-sitter counts comments among + // a node's children, so `a ? /*n*/ !b : c` put the comment at index 2 + // and the negated operand went uninspected — the ternary scored 2 + // where the same expression without the comment scores 3 (#1181). + // That is the mirror image of the over-count the token-based seed + // produced in the C family, from the same cause. + // + // The terminal set mirrors the one in `java_inspect_container` + // (issue #372 / lesson #19). + if let Some(condition) = node.child_by_field_name("condition") { match condition.kind_id().into() { java_bool_terminal_kinds!() => *conds += 1., ParenthesizedExpression | UnaryExpression => { @@ -249,9 +257,11 @@ fn java_walk_ternary(node: &Node, stats: &mut Stats) { _ => {} } } - // Children 2 and 4: the two branch expressions. - java_inspect_child(node, 2, conds); - java_inspect_child(node, 4, conds); + for field in ["consequence", "alternative"] { + if let Some(branch) = node.child_by_field_name(field) { + java_inspect_container(&branch, node, conds); + } + } } // Handles the `for (...)` multi-shape positional cascade: the loop diff --git a/src/metrics/abc/js_family.rs b/src/metrics/abc/js_family.rs index 792e180d..075d49a8 100644 --- a/src/metrics/abc/js_family.rs +++ b/src/metrics/abc/js_family.rs @@ -40,9 +40,9 @@ macro_rules! impl_js_family_unary_walker { parent_kind, BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement ) || (matches!(parent_kind, TernaryExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { let is_parens = matches!(node_kind, ParenthesizedExpression); diff --git a/src/metrics/abc/perl.rs b/src/metrics/abc/perl.rs index 281e7422..4782f310 100644 --- a/src/metrics/abc/perl.rs +++ b/src/metrics/abc/perl.rs @@ -69,9 +69,9 @@ fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut | P::WhileStatement | P::UntilStatement ) || (matches!(parent_kind, P::TernaryExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), P::QMARK | P::COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { // `Array` is tree-sitter-perl's name for the `(...)` shape @@ -86,8 +86,16 @@ fn perl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // has only one inner expression, so child(1) and last-named // are equivalent. let is_parens = matches!(node_kind, P::ParenthesizedArgument | P::Array); + // Both spellings of the same negation — see `ruby_inspect_container` + // for the rationale; Perl has the identical gap (#1182). Read + // through the grammar's `operator` field, whose type list is + // `! + ++ - -- and not ~`. Do NOT match the hidden `_unary_not` + // supertype (`P::UnaryNot`), which the parser never emits + // (grammar-dispatch item 2). let is_not = matches!(node_kind, P::UnaryExpression) - && node.child(0).is_some_and(|c| c.kind_id() == P::BANG as u16); + && node + .child_by_field_name("operator") + .is_some_and(|op| matches!(op.kind_id().into(), P::BANG | P::Not)); if !is_parens && !is_not { break; diff --git a/src/metrics/abc/php.rs b/src/metrics/abc/php.rs index 61d91cf6..26079d40 100644 --- a/src/metrics/abc/php.rs +++ b/src/metrics/abc/php.rs @@ -33,9 +33,9 @@ fn php_inspect_container(container_node: &Node, parent: &Node, conditions: &mut parent_kind, BinaryExpression | IfStatement | WhileStatement | DoStatement | ForStatement ) || (matches!(parent_kind, ConditionalExpression) - && node - .previous_sibling_under(parent) - .is_none_or(|prev| !matches!(prev.kind_id().into(), QMARK | COLON))); + && parent + .child_by_field_name("condition") + .is_some_and(|condition| condition.id() == node.id())); loop { let is_parens = matches!(node_kind, ParenthesizedExpression); diff --git a/src/metrics/abc/ruby.rs b/src/metrics/abc/ruby.rs index e3a38fc6..5a87b924 100644 --- a/src/metrics/abc/ruby.rs +++ b/src/metrics/abc/ruby.rs @@ -55,16 +55,15 @@ fn ruby_inspect_container(container_node: &Node, parent: &Node, conditions: &mut // contribute nothing — see `ruby_walk_ternary` (#1161). // // The slot is identified by grammar FIELD, not by the neighbouring - // `?` / `:` token. `cpp_inspect_container` uses the token form, and - // it has two weaknesses this avoids: a comment between the token and - // the operand is the previous sibling instead, which flips the seed - // on for a branch slot; and the token test inverts on failure, so - // Ruby's second `:` id (`COLON2`, unreachable at tree-sitter-ruby - // 0.23.1 but one grammar bump away) would silently turn every - // parenthesised alternative into a condition. Both are live in the - // C family — tracked in #1181, not fixed here. Reproducer for the - // first: `x = a ? /*n*/ (b) : c;` reports 3 conditions where the - // same line without the comment reports 2. + // `?` / `:` token. The token form had two weaknesses this avoids: a + // comment between the token and the operand is the previous sibling + // instead, which flips the seed on for a branch slot; and the test + // inverts on failure, so Ruby's second `:` id (`COLON2`, unreachable + // at tree-sitter-ruby 0.23.1 but one grammar bump away) would + // silently turn every parenthesised alternative into a condition. + // Both were live across the C family, PHP, Perl and the JS family + // until #1181 moved them all onto this form; the cross-language + // regression test is `ternary_comment_invariance` in `abc.rs`. let mut has_boolean_content = matches!( parent_kind, Binary | Binary2 | Binary3 | If | Unless | While | Until @@ -75,8 +74,16 @@ fn ruby_inspect_container(container_node: &Node, parent: &Node, conditions: &mut loop { let is_parens = matches!(node_kind, ParenthesizedStatements); + // Both spellings of the same negation. `not` and `!` differ in + // precedence but not in meaning, and ABC counts the negation, + // not the parse — testing `BANG` alone scored `if not b` as 0 + // where `if !b` scores 1, and a `not` ternary as 2 where the `!` + // form scores 4 (#1182). Read through the grammar's `operator` + // field rather than child(0), matching the ternary slots (#1181). let is_not = matches!(node_kind, Unary | Unary2 | Unary3 | Unary4 | Unary5) - && node.child(0).is_some_and(|c| c.kind_id() == BANG as u16); + && node + .child_by_field_name("operator") + .is_some_and(|op| matches!(op.kind_id().into(), BANG | Not)); if !is_parens && !is_not { break; diff --git a/src/metrics/abc/tcl.rs b/src/metrics/abc/tcl.rs index ccb01586..57d1d254 100644 --- a/src/metrics/abc/tcl.rs +++ b/src/metrics/abc/tcl.rs @@ -53,21 +53,73 @@ const TCL_ASSIGNMENT_COMMANDS: &[&[u8]] = &[b"incr", b"append", b"lappend"]; // (`$x`), command substitutions (`[cmd]`), the boolean keyword, and // the numeric literal. fn tcl_inspect_container(container_node: &Node, parent: &Node, conditions: &mut f64) { + // bca: suppress(cognitive) — wrapper-peeling state machine, clearest whole + // The same shape as `cpp_inspect_container`, and it carries the same + // marker for the same reason: one loop peels the `expr` / `!` layers + // while carrying a single boolean-context flag, the flag must be + // readable at every step so any split would have to thread it back + // out, and the parts have no names a reader would draw. It crossed + // the limit when #1180 wired the Phase 2B slot routing — the seed + // grew a ternary-slot disjunct and the loop gained the wrapper peel + // every sibling already had. let mut node = *container_node; let mut node_kind = node.kind_id().into(); - let has_boolean_content = matches!(parent.kind_id().into(), Tcl::BinopExpr); + let parent_kind = parent.kind_id().into(); + // Phase 2B slot routing (#1180). Before it, only a `&&` / `||` chain + // seeded boolean context, so `if {!$a}` and every ternary operand + // scored zero. The `if` / `elseif` / `while` predicate is a boolean + // context by construction; a ternary seeds only its *condition* + // slot, since the two branches are type-free and an unnegated branch + // must contribute nothing. + let mut has_boolean_content = matches!( + parent_kind, + Tcl::BinopExpr | Tcl::If | Tcl::Elseif | Tcl::While + ); + // No ternary-condition disjunct here, unlike `cpp_inspect_container`. + // It would be dead: the flag is read only *after* a peel, and the + // loop peels exactly two kinds. `Expr` cannot sit under + // `ternary_expr` — the grammar's `_expr` alternatives are + // `unary_expr | binop_expr | ternary_expr | escaped_character | + // '(' _expr ')' | _expr_atom_no_brace | braced_word_simple` — and a + // `!`-unary sets the flag itself two lines below. Every other + // condition-slot kind breaks before the flag is read, and every + // terminal is counted by the ternary walker directly. Verified by + // deletion: the whole suite passes without it. + // + // The C-family version *is* live because C keeps `(a)` as a + // `parenthesized_expression` for the loop to peel, where this + // grammar inlines the parens. Porting it verbatim also cost two full + // child scans per ternary to produce `false`. loop { + // The `expr` wrapper is this grammar's `{ … }` predicate node — + // the analogue of the C family's `condition_clause`, and peeled + // the same way. + let is_parens = matches!(node_kind, Tcl::Expr); let is_not = matches!(node_kind, Tcl::UnaryExpr) && node .child(0) .is_some_and(|c| c.kind_id() == Tcl::BANG as u16); - if !is_not { + if !is_parens && !is_not { break; } + // A `!` proves the operand is boolean even where the parent did + // not — every sibling language carries this line; its absence was + // why a negated operand outside a `&&` chain could never count. + if !has_boolean_content && is_not { + has_boolean_content = true; + } - let Some(child) = node.child(1) else { break }; + // The first *named* child, for both wrappers. `!` and the `{` + // / `(` delimiters are all anonymous, so this is the operand in + // every shape — and unlike a fixed index it survives `_expr` + // inlining its parens: `!($a)` puts `(` at child 1, where a + // positional read lands on the delimiter and the walk stops + // without counting the negation at all. + let Some(child) = node.children().find(Node::is_named) else { + break; + }; node = child; node_kind = node.kind_id().into(); @@ -104,6 +156,76 @@ fn tcl_count_unary_conditions(list_node: &Node, conditions: &mut f64) { } } +/// The three operand slots of a `ternary_expr`, located relative to the +/// `?` and `:` tokens rather than by fixed index. +/// +/// The grammar exposes **no fields** on `ternary_expr` (verified against +/// `node-types.json`: `fields: []`), so the `child_by_field_name` model +/// every other language's ABC ternary uses does not transfer here. Nor +/// does a fixed index: `_expr` inlines `seq('(', $._expr, ')')`, so +/// `($a) ? $b : $c` puts the anonymous parens directly under +/// `ternary_expr` and shifts every operand right (#1180, +/// grammar-dispatch item 3). +/// +/// Slots are therefore the *named* children adjacent to the two marker +/// tokens, which is stable under both parenthesisation and comments. +fn tcl_ternary_slots<'a>( + node: &Node<'a>, +) -> (Option>, Option>, Option>) { + let (mut condition, mut consequence, mut alternative) = (None, None, None); + let mut seen_question = false; + let mut seen_colon = false; + + for child in node.children() { + match child.kind_id().into() { + Tcl::QMARK => seen_question = true, + Tcl::COLON => seen_colon = true, + _ if !child.is_named() => {} + _ if seen_colon => alternative = alternative.or(Some(child)), + _ if seen_question => consequence = consequence.or(Some(child)), + // The condition is the *last* named child before `?`, so a + // parenthesised condition resolves to the inner expression + // rather than to whatever preceded it. + _ => condition = Some(child), + } + } + (condition, consequence, alternative) +} + +/// Routes the three ternary slots (#1180). +/// +/// Mirrors `cpp_walk_ternary`: the condition is counted directly when it +/// is already a terminal bool, and otherwise handed to the wrapper-peeling +/// walker; each branch goes to the walker, which counts it only if a `!` +/// establishes boolean content for that slot. +fn tcl_walk_ternary(node: &Node, conditions: &mut f64) { + let (condition, consequence, alternative) = tcl_ternary_slots(node); + if let Some(condition) = condition { + if matches!(condition.kind_id().into(), tcl_bool_terminal_kinds!()) { + *conditions += 1.; + } else { + tcl_inspect_container(&condition, node, conditions); + } + } + for branch in [consequence, alternative].into_iter().flatten() { + tcl_inspect_container(&branch, node, conditions); + } +} + +/// The `expr` wrapper holding an `if` / `elseif` / `while` predicate. +/// +/// `while` exposes no `condition` field at all — the grammar is +/// `seq('while', $.expr, $._word)` — so the slot is found by kind. Match +/// `Tcl::Expr` (the braced `{ … }` expression node) and not +/// `Tcl::Expr2`, which is the `expr` *command keyword* under +/// `expr_cmd` and renders to the same name (grammar-dispatch item 1). +/// `Tcl::Expr3` is the hidden `_expr` supertype the parser never emits +/// (item 2). +fn tcl_condition_expr<'a>(node: &Node<'a>) -> Option> { + node.children() + .find(|child| matches!(child.kind_id().into(), Tcl::Expr)) +} + impl Abc for TclCode { fn compute<'a>( node: &Node<'a>, @@ -138,11 +260,36 @@ impl Abc for TclCode { | Tcl::Ne | Tcl::In | Tcl::Ni - | Tcl::TernaryExpr - | Tcl::Elseif | Tcl::Else => { stats.conditions += 1.; } + // Phase 2B slot routing (#1180). `if` / `while` / `elseif` + // carry their predicate in an `expr` wrapper; routing it is + // what makes a bare truthy test (`if {$a}`) and a negated one + // (`if {!$a}`) count at all. A comparison predicate is + // unaffected: its `binop_expr` is neither a wrapper nor a + // negation, so the walker breaks without counting and the + // operator token arm above still supplies the one condition. + Tcl::If | Tcl::While => { + if let Some(expr) = tcl_condition_expr(node) { + tcl_inspect_container(&expr, node, &mut stats.conditions); + } + } + // `elseif` is both a clause (one condition, as before) and a + // predicate owner, matching the C family, where + // `if (a) {} else if (b) {}` scores 3. + Tcl::Elseif => { + stats.conditions += 1.; + if let Some(expr) = tcl_condition_expr(node) { + tcl_inspect_container(&expr, node, &mut stats.conditions); + } + } + // The `?` marker is one condition, as before; its three + // operand slots are new. + Tcl::TernaryExpr => { + stats.conditions += 1.; + tcl_walk_ternary(node, &mut stats.conditions); + } // Fitzpatrick Rule 9 walker: each operand of a `&&` / `||` // chain inside an `expr` slot is one condition (issue #403). Tcl::AMPAMP | Tcl::PIPEPIPE => { diff --git a/src/metrics/cognitive.rs b/src/metrics/cognitive.rs index 28cf7ff0..6b4f4ba7 100644 --- a/src/metrics/cognitive.rs +++ b/src/metrics/cognitive.rs @@ -309,20 +309,29 @@ fn increment_function_depth<'a, T: PartialEq + From>( /// Applies the function-boundary rule at `node`, which every language /// with a syntactic function-definition kind shares (#696). /// -/// It moves two of [`Nesting`]'s three channels. Structural nesting -/// restarts at zero, so control flow written inside this function is -/// charged against its own depth rather than the enclosing function's; -/// and the function-depth surcharge rises when this definition is -/// itself lexically nested in one of `stops`. Byte-equivalent -/// constructs therefore score the same across languages, which is the -/// property the book's per-language deviations list states. +/// It moves all three of [`Nesting`]'s channels. Structural nesting and +/// the lambda surcharge restart at zero, so control flow written inside +/// this function is charged against its own depth rather than against +/// whatever enclosed the definition; and the function-depth surcharge +/// rises when this definition is itself lexically nested in one of +/// `stops`. Byte-equivalent constructs therefore score the same across +/// languages, which is the property the book's per-language deviations +/// list states. +/// +/// The lambda reset was the JS macro's alone until #1187. Every other +/// language carried the enclosing closure's surcharge into a function +/// *declared inside* it, so the same body scored 3 or 2 depending on +/// whether something two levels up happened to be a closure — measured +/// in Rust, Java, C++, PHP and C#, where a `LocalFunctionStatement` +/// inside a lambda is idiomatic. A function declaration is a new lexical +/// scope whatever encloses it, so the reset belongs to every boundary, +/// and living here is what stops a language opting out by accident — +/// which is how the gap arose. /// /// The two statements were spelled out longhand in eighteen modules -/// before #1103. Two callers still spell them out, for opposite -/// reasons: `elixir.rs` takes only the reset and deliberately skips the -/// depth bump, while the `js_cognitive!` macro takes the pair *plus* a -/// `nesting.lambda` reset no other language performs. Each says why at -/// its own site. +/// before #1103. One caller still spells them out: `elixir.rs` takes the +/// resets and deliberately skips the depth bump, and says why at its own +/// site. fn enter_function_boundary<'a, T: PartialEq + From>( nesting: &mut Nesting, node: &Node<'a>, @@ -330,6 +339,7 @@ fn enter_function_boundary<'a, T: PartialEq + From>( stops: &[T], ) { nesting.conditional = 0; + nesting.lambda = 0; increment_function_depth(&mut nesting.function_depth, node, ancestors, stops); } @@ -424,18 +434,13 @@ macro_rules! js_cognitive { matches!(id.into(), AMPAMPEQ | PIPEPIPEEQ | QMARKQMARKEQ) }); } - FunctionDeclaration | MethodDefinition | FunctionExpression + FunctionDeclaration + | MethodDefinition + | FunctionExpression + | GeneratorFunctionDeclaration + | GeneratorFunction if Self::is_func(node, ancestors) => { - // The JS family takes the shared function-boundary - // rule plus one extra channel: `nesting.lambda` is - // reset too, which no other language does. A `function` - // declaration written inside an arrow function starts a - // fresh lexical scope, so it should not inherit that - // arrow's lambda surcharge. That third statement is why - // this arm spells the pair out rather than calling - // `enter_function_boundary` (#1103). - // // The kind set is `is_js_func!` minus `ArrowFunction`, // and the `function_expression` half is re-derived by // asking `Self::is_func` rather than copied flat, because @@ -465,36 +470,63 @@ macro_rules! js_cognitive { // anonymous IIFE is a lexical function scope — // `get_space_kind` maps every `function_expression` to // `SpaceKind::Function` — so a `function` declared inside - // one really is nested in a function. `ArrowFunction` is - // knowingly absent, and because the `nesting.lambda = 0` - // above also wipes the arrow's surcharge, an arrow - // ancestor contributes nothing at all: `(function () { - // function g() {…} })()` charges `g` a depth of 1 where - // `(() => { function g() {…} })()` charges 0. Closing - // that gap means deciding what a lambda ancestor is worth - // across every language — the deferred half of #1159 — - // rather than settling it in one arm. + // one really is nested in a function. + // + // `ArrowFunction` is in the list since #1187, which is + // what makes `(function () { function g() {…} })()` and + // `(() => { function g() {…} })()` both charge `g` a + // depth of 1; the arrow form charged 0 while the kind + // was absent. It cannot double-charge, because the sole + // caller resets `nesting.lambda` first. // - // Generators are the other knowing omission, and the - // argument above applies to them verbatim: - // `GeneratorFunction` / `GeneratorFunctionDeclaration` - // are `SpaceKind::Function` to the Getter too. They - // are absent from both this list and the boundary arm - // because `is_js_func!` excludes them — the Checker - // calls them closures — so `function* g() {}` still - // inherits enclosing nesting and still gives a nested - // `function` no depth surcharge. Fixing that means - // moving them between the two Checker predicates, - // which also moves `nom`'s function/closure split and - // `nargs`' `fn_nargs`/`closure_nargs`; tracked in - // #1186, deliberately not settled here. - nesting.conditional = 0; - nesting.lambda = 0; - increment_function_depth( - &mut nesting.function_depth, + // Both generator kinds are in the arm and in `stops` + // since #1186, which moved them from `is_js_closure!` + // to `is_js_func!`. The two halves are independent + // and had to move together: the arm decides whether + // `function* g()` resets its own inherited nesting, + // while `stops` decides whether a plain `function` + // nested *inside* a generator gets a depth surcharge. + // `GeneratorFunction` is gated by `Self::is_func` for + // the same reason `FunctionExpression` is — it has an + // optional name and covers both a function and a + // closure — while `GeneratorFunctionDeclaration`, + // like `FunctionDeclaration`, is unconditional. + enter_function_boundary( + &mut nesting, node, ancestors, - &[FunctionDeclaration, MethodDefinition, FunctionExpression], + &[ + FunctionDeclaration, + MethodDefinition, + FunctionExpression, + GeneratorFunctionDeclaration, + GeneratorFunction, + ArrowFunction, + ClassStaticBlock, + ], + ); + } + // A class static block is a function boundary but is + // deliberately *not* in `is_func` (#1184), so it needs + // its own ungated arm rather than joining the gated one + // above — gated, it would never fire. It is in the + // `stops` list below for the same reason a + // `function_expression` is: a `function` declared inside + // a `static { … }` really is nested in one. + ClassStaticBlock => { + enter_function_boundary( + &mut nesting, + node, + ancestors, + &[ + FunctionDeclaration, + MethodDefinition, + FunctionExpression, + GeneratorFunctionDeclaration, + GeneratorFunction, + ArrowFunction, + ClassStaticBlock, + ], ); } ArrowFunction => { @@ -9445,6 +9477,17 @@ end", "function_expression", "const inner = function (c) { if (c) { return 1; } };", ), + // Generators reach this arm since #1186. Before it, + // `is_js_func!` called them closures, so neither form + // reached the boundary and each scored 3. + ( + "generator_function_declaration", + "function* inner(c) { if (c) { yield 1; } }", + ), + ( + "generator_function", + "const inner = function* (c) { if (c) { yield 1; } };", + ), ] { let source = format!("function outer(a, b) {{ if (a) {{ if (b) {{ {definition} }} }} }}"); @@ -9476,6 +9519,19 @@ end", "function_expression", "const m = function () { function inner(c) { if (c) { return 1; } } };", ), + // The independent half of #1186: a plain `function` nested + // inside a *generator* got no depth surcharge, because the + // generator was excluded from `stops` by the same + // `is_js_func!` gate. This scored 1 before the fix while the + // non-generator control above scored 2. + ( + "generator_function_declaration", + "function* m() { function inner(c) { if (c) { return 1; } } }", + ), + ( + "generator_function", + "const m = function* () { function inner(c) { if (c) { return 1; } } };", + ), ] { check_func_space::(source, filename, |space| { assert_eq!( @@ -10128,27 +10184,32 @@ end", /// Pins that `function_depth` and `lambda` are distinguishable. /// /// They are summed symmetrically almost everywhere, so most inputs - /// cannot tell them apart. The one asymmetric operation in the whole - /// cognitive family is the JS `FunctionDeclaration` arm's - /// `lambda = 0`, which clears one field alone. + /// cannot tell them apart. The asymmetric operation is + /// `enter_function_boundary`'s `lambda = 0`, which clears one field + /// while `increment_function_depth` raises the other — since #1187 + /// that pair runs for every language, not only the JS macro. /// - /// Whether that separates them depends on the *parity* of the - /// ancestor chain between the innermost arrow and the - /// `function_declaration`, because a swap at the write site - /// transposes the pair at every node on the way down. The plain + /// The doubled arrow is still load-bearing, for the reason it always + /// was: a swap at the write site transposes the pair at every node + /// on the way down, and the plain /// `arrow -> statement_block -> function_declaration` chain has odd - /// parity and stays at the same total either way; a second chained - /// arrow flips it. Hence the doubled arrow here — without it this - /// test passes with the two fields transposed and guards nothing. + /// parity and totals the same either way. A mutant writing + /// `function_depth = 0` in place of `lambda = 0` leaves `lambda 2, + /// function_depth 1` here and charges the `if` 4 rather than 2. #[test] fn javascript_function_depth_and_lambda_are_distinguishable() { - // expected: the inner `function` resets lambda, so only its own - // function-depth level survives and the `if` costs 1. + // expected: `inner` takes the boundary, so `conditional` and + // `lambda` both reset to 0. `ArrowFunction` joined the `stops` + // list in #1187, so `inner` earns a function-depth surcharge of + // 1 — `increment_function_depth` asks whether *any* ancestor is a + // stop, not how many, so two arrows still give 1. The `if` costs + // 1 base + 1 depth = 2, up from 1 before the arrow entered + // `stops`. check_metrics::( "const f = () => () => { function inner() { if (a) { } } };", "nest.js", |metric| { - assert_eq!(metric.cognitive.cognitive_sum(), 1); + assert_eq!(metric.cognitive.cognitive_sum(), 2); }, ); } @@ -10477,3 +10538,104 @@ end", ); } } + +/// The nameless constructs from #1184 are function boundaries, so a +/// deeply-nested one must score what the same body scores as an +/// ordinary method in the same position (#1184). +/// +/// Each opens a `FuncSpace`, and without a cognitive boundary arm it +/// reached none and inherited the enclosing conditional nesting: a +/// Kotlin accessor nested two `if`s deep scored 7 where the method +/// beside it scored 5. +/// +/// **Two levels of nesting are load-bearing.** At one level the fixture +/// reports the same number either way, which is the trap the issue's own +/// checklist warns about — a first draft of this test used one `if` and +/// could not discriminate the fix from its absence. +/// +/// The comparison is against a *sibling method* rather than an absolute +/// number, so the assertion states the property (these are ordinary +/// function boundaries) rather than a value that moves with any +/// unrelated re-tuning. The absolute is pinned too, so a regression +/// moving both equally still fails. +#[cfg(test)] +mod nameless_construct_boundaries { + use crate::test_support::space_verbatim; + use crate::{FuncSpace, LANG, MetricsOptions}; + + fn score(lang: LANG, source: &str, name: &str) -> u64 { + fn find(s: &FuncSpace, name: &str) -> Option { + if s.name.as_deref() == Some(name) { + return Some(s.metrics.cognitive.cognitive()); + } + s.spaces.iter().find_map(|c| find(c, name)) + } + let root = space_verbatim(lang, source.as_bytes(), MetricsOptions::default()); + find(&root, name) + .unwrap_or_else(|| panic!("{lang:?}: no space named {name:?} in the fixture")) + } + + /// `(language, source, construct name, sibling method name)`. Each + /// fixture nests a class two `if`s deep and gives it both the + /// nameless construct and an ordinary method with a byte-identical + /// body. + fn cases() -> Vec<(LANG, &'static str, &'static str, &'static str)> { + vec![ + ( + LANG::Kotlin, + "fun outer(a: Boolean) { if (a) { if (a) { class D {\n\ + \x20 var q: Int = 0\n\ + \x20 get() { if (q > 0) { if (q > 1) { return 2 } }; return 0 }\n\ + \x20 fun m(): Int { if (q > 0) { if (q > 1) { return 2 } }; return 0 }\n\ + } } } }\n", + "", + "m", + ), + ( + LANG::Java, + "class K { void outer(boolean a) { if (a) { if (a) { class D {\n\ + \x20 static int x;\n\ + \x20 static { if (x > 0) { if (x > 1) { x = 2; } } }\n\ + \x20 void m() { if (x > 0) { if (x > 1) { x = 2; } } }\n\ + } } } } }\n", + "", + "m", + ), + ( + LANG::Javascript, + "function outer(a) { if (a) { if (a) { class D {\n\ + \x20 static x;\n\ + \x20 static { if (D.x > 0) { if (D.x > 1) { D.x = 2; } } }\n\ + \x20 m() { if (D.x > 0) { if (D.x > 1) { D.x = 2; } } }\n\ + } } } }\n", + "", + "m", + ), + ] + } + + #[test] + fn a_nested_nameless_construct_scores_like_a_sibling_method() { + let mut checked = 0; + for (lang, source, construct, method) in cases() { + if !lang.is_enabled() { + continue; + } + checked += 1; + let (got, want) = (score(lang, source, construct), score(lang, source, method)); + assert_eq!( + got, want, + "{lang:?}: {construct} scored {got} where the sibling method scored {want}; \ + the construct is inheriting the enclosing nesting", + ); + // expected: two `if`s at +1 and +2 = 3, plus +1 each for the + // function-depth surcharge from `outer` = 5. Pinned so a + // regression that moved both sides equally still fails. + assert_eq!(want, 5, "{lang:?}: the baseline itself moved"); + } + assert!( + checked > 0, + "no language enabled; this test asserted nothing" + ); + } +} diff --git a/src/metrics/cognitive/elixir.rs b/src/metrics/cognitive/elixir.rs index 80f67f37..f2c7d9d0 100644 --- a/src/metrics/cognitive/elixir.rs +++ b/src/metrics/cognitive/elixir.rs @@ -99,11 +99,20 @@ impl Cognitive for ElixirCode { // concern — the lambda channel via // `AnonymousFunction` handles the analogous // higher-order case. Elixir is therefore the one - // language that takes the reset without the depth + // language that takes the resets without the depth // bump, so it resets inline instead of calling the // shared `enter_function_boundary` the other // eighteen modules use (#1103). + // + // Both channels, not just the conditional one. `def` + // inside `fn` is invalid Elixir but the grammar + // parses it, and skipping the lambda reset here is + // exactly the accidental opt-out that #1187 moved + // the line into the shared helper to prevent — so + // the one language that cannot use the helper has to + // stay in step by hand. nesting.conditional = 0; + nesting.lambda = 0; } _ => {} }, diff --git a/src/metrics/cognitive/groovy.rs b/src/metrics/cognitive/groovy.rs index 72cf0334..beb7a6d9 100644 --- a/src/metrics/cognitive/groovy.rs +++ b/src/metrics/cognitive/groovy.rs @@ -80,12 +80,13 @@ impl Cognitive for GroovyCode { // families. Groovy methods can nest inside inner classes; a // nested method previously inherited the enclosing nesting and // missed the SonarSource B-nesting amplification (#696). - MethodDeclaration | ConstructorDeclaration => { + // `static { … }` — see `java.rs`, which this mirrors (#1184). + MethodDeclaration | ConstructorDeclaration | StaticInitializer => { enter_function_boundary( &mut nesting, node, ancestors, - &[MethodDeclaration, ConstructorDeclaration], + &[MethodDeclaration, ConstructorDeclaration, StaticInitializer], ); } _ => {} diff --git a/src/metrics/cognitive/java.rs b/src/metrics/cognitive/java.rs index 8f3ae1e1..e4db1ce8 100644 --- a/src/metrics/cognitive/java.rs +++ b/src/metrics/cognitive/java.rs @@ -65,7 +65,14 @@ impl Cognitive for JavaCode { // `constructor_declaration`, so it needs listing in both the // arm and the `stops` set — otherwise its body's control flow // is charged to the enclosing class space (#1160). - MethodDeclaration | ConstructorDeclaration | CompactConstructorDeclaration => { + // `static { … }` joins them for the same reason (#1184): it + // opens a `FuncSpace`, so without this arm an initialiser + // written inside a nested class inherited the enclosing + // nesting. + MethodDeclaration + | ConstructorDeclaration + | CompactConstructorDeclaration + | StaticInitializer => { enter_function_boundary( &mut nesting, node, @@ -74,6 +81,7 @@ impl Cognitive for JavaCode { MethodDeclaration, ConstructorDeclaration, CompactConstructorDeclaration, + StaticInitializer, ], ); } diff --git a/src/metrics/cognitive/kotlin.rs b/src/metrics/cognitive/kotlin.rs index 7351488d..959fee00 100644 --- a/src/metrics/cognitive/kotlin.rs +++ b/src/metrics/cognitive/kotlin.rs @@ -83,12 +83,26 @@ impl Cognitive for KotlinCode { matches!(id.into(), AMPAMP | PIPEPIPE | QMARKCOLON) }); } - FunctionDeclaration | SecondaryConstructor => { + // Property accessors and `init { … }` are function + // boundaries too (#1184). They open a `FuncSpace`, so without + // this arm an accessor written inside an `if` inherited that + // `if`'s nesting and scored more than the byte-identical + // accessor at top level — the same defect #1160 fixed for + // Java's compact constructor. They are `is_func_space` + // without being `is_func`, which does not matter here: this + // arm matches kinds directly. + FunctionDeclaration | SecondaryConstructor | Getter | Setter | AnonymousInitializer => { enter_function_boundary( &mut nesting, node, ancestors, - &[FunctionDeclaration, SecondaryConstructor], + &[ + FunctionDeclaration, + SecondaryConstructor, + Getter, + Setter, + AnonymousInitializer, + ], ); } LambdaLiteral | AnonymousFunction => { diff --git a/src/metrics/cognitive/python.rs b/src/metrics/cognitive/python.rs index 82e19fbc..39ff9b6b 100644 --- a/src/metrics/cognitive/python.rs +++ b/src/metrics/cognitive/python.rs @@ -222,13 +222,17 @@ impl Cognitive for PythonCode { // this definition is itself nested inside another, so a `def` // written inside an `if` is scored against its own depth rather // than the enclosing function's — matching Java, Rust, and - // every other conforming family (#696, #1149). Python is the - // one family that provably needs no `nesting.lambda = 0` - // companion to go with it — a `def` is a statement and a - // lambda body is a single expression, so no - // `function_definition` can sit under a `lambda`. Elsewhere - // that shape is legal (`let f = || { fn g() {} };`) and only - // the JS macro currently carries the extra line. + // every other conforming family (#696, #1149). + // + // Python is the one family for which the helper's + // `nesting.lambda = 0` is provably a no-op rather than a + // correction: a `def` is a statement and a lambda body is a + // single expression, so no `function_definition` can sit + // under a `lambda`. Elsewhere that shape is legal + // (`let f = || { fn g() {} };`), and until #1187 only the JS + // macro reset the channel — which is why the same body + // scored 3 or 2 in Rust, Java, C++, PHP and C# depending on + // whether something two levels up was a closure. FunctionDefinition => { enter_function_boundary(&mut nesting, node, ancestors, &[FunctionDefinition]); } diff --git a/src/metrics/nargs.rs b/src/metrics/nargs.rs index 31c8d3d9..5fce2745 100644 --- a/src/metrics/nargs.rs +++ b/src/metrics/nargs.rs @@ -211,6 +211,13 @@ impl Stats { #[inline] fn compute_args(node: &Node, nargs: &mut usize) { if let Some(params) = node.child_by_field_name("parameters") { + // The field can hold a lone parameter rather than a list, in + // which case there are no children to walk and the loop below + // yields zero — see `Checker::is_bare_param` (#1185). + if T::is_bare_param(¶ms) { + *nargs += 1; + return; + } let node_params = params; node_params.act_on_child(&mut |n| { if !T::is_non_arg(n) { @@ -4056,3 +4063,123 @@ when HTTP_REQUEST { log local0. \"hit\" } ); } } + +/// A lambda's parameter count must not depend on optional parentheses +/// (#1185). +/// +/// `x -> x + 1` and `(x) -> x + 1` are the same lambda — the parens are +/// optional in the grammar and carry no meaning — so they must score +/// alike, the same "byte-equivalent constructs score identically" +/// contract the book states for cognitive. +/// +/// The cause is shared: the `parameters` field holds a lone, childless +/// parameter node rather than a list, and `compute_args` walks the +/// field's children. The issue names Java; the sweep found **C#** has +/// the identical defect via `implicit_parameter`. Kotlin and Groovy +/// were checked and are correct — each overrides `compute` with its own +/// closure-parameter shape — and the JS family reaches the right answer +/// through the singular `parameter` field. +#[cfg(test)] +mod lambda_parenthesisation_parity { + use crate::test_support::metrics_verbatim; + use crate::{LANG, MetricsOptions}; + + /// `(closure_args, function_args)` — the split matters as much as + /// the count: a lambda must stay in the closure channel. + fn args(lang: LANG, source: &str) -> (u64, u64) { + let m = metrics_verbatim(lang, source.as_bytes(), MetricsOptions::default()); + (m.nargs.closure_args_sum(), m.nargs.function_args_sum()) + } + + /// `(bare, parenthesised, two_params, zero_params)`. + fn cases(lang: LANG) -> Option<[&'static str; 4]> { + Some(match lang { + LANG::Java => [ + "class K{ void f(){ Function a = x -> x + 1; } }", + "class K{ void f(){ Function a = (x) -> x + 1; } }", + "class K{ void f(){ BiFunction c = (x, y) -> x + y; } }", + "class K{ void f(){ Supplier d = () -> 1; } }", + ], + LANG::Csharp => [ + "class K{ void f(){ Func a = x => x + 1; } }", + "class K{ void f(){ Func a = (x) => x + 1; } }", + "class K{ void f(){ Func c = (x, y) => x + y; } }", + "class K{ void f(){ Func d = () => 1; } }", + ], + LANG::Javascript | LANG::Typescript | LANG::Tsx | LANG::Mozjs => [ + "function f(){ var a = x => x + 1; }", + "function f(){ var a = (x) => x + 1; }", + "function f(){ var c = (x, y) => x + y; }", + "function f(){ var d = () => 1; }", + ], + _ => return None, + }) + } + + #[test] + fn optional_parentheses_do_not_change_the_count() { + let mut checked = 0; + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + let Some([bare, paren, two, zero]) = cases(lang) else { + continue; + }; + checked += 1; + + let (bare_args, paren_args) = (args(lang, bare), args(lang, paren)); + assert_eq!( + bare_args, paren_args, + "{lang:?}: the parentheses changed the argument count\n bare: {bare}\n paren: {paren}" + ); + // The absolute value, so a regression that zeroed *both* + // spellings would still fail. + assert_eq!( + bare_args.0 + bare_args.1, + 1, + "{lang:?}: a one-parameter lambda must report one argument" + ); + // A zero-parameter lambda must stay 0: the bare-parameter + // branch must not mistake an empty list for a parameter. + assert_eq!( + args(lang, zero), + (0, 0), + "{lang:?}: `() -> …` has no arguments" + ); + // And the plural path must be undisturbed. + let two_args = args(lang, two); + assert_eq!( + two_args.0 + two_args.1, + 2, + "{lang:?}: a two-parameter lambda must report two arguments" + ); + } + assert!( + checked > 0, + "no lambda language enabled; this test asserted nothing" + ); + } + + /// The lambda stays in the *closure* channel, not the function one. + /// + /// Java and C# route it through `is_closure`; the JS family's arrow + /// is classified by `check_if_arrow_func!` and lands in `fn_args` + /// when bound to a variable, which is a separate question (#1188). + /// Asserting the channel per language rather than globally keeps + /// this test from encoding that as a bug. + #[test] + fn a_bare_lambda_stays_in_the_closure_channel() { + for lang in [LANG::Java, LANG::Csharp] { + if !lang.is_enabled() { + continue; + } + let [bare, ..] = cases(lang).expect("both languages have cases"); + assert_eq!( + args(lang, bare), + (1, 0), + "{lang:?}: the lambda's argument must be billed to closure_args" + ); + } + } +} diff --git a/src/metrics/nom.rs b/src/metrics/nom.rs index 6954d49a..6f13037a 100644 --- a/src/metrics/nom.rs +++ b/src/metrics/nom.rs @@ -612,17 +612,24 @@ mod tests { }", "foo.js", |metric| { - // Number of spaces = 5 - // functions: f, foo, bar - // closures: return function () + // Number of spaces = 5. + // functions: f, foo + // closures: the IIFE assigned to `bar`, and the + // function it returns + // + // The IIFE moved to the closure side in #1188. It used to + // count as the function `bar` purely because its *result* + // was bound to a name: written `(function(){…})()` with + // the assignment dropped, the identical construct was + // already a closure. Both spellings are closures now. insta::assert_json_snapshot!( metric.nom, @r#" { - "functions": 3, - "closures": 1, - "functions_average": 0.6, - "closures_average": 0.2, + "functions": 2, + "closures": 2, + "functions_average": 0.4, + "closures_average": 0.4, "total": 4, "average": 0.8, "functions_min": 0, @@ -636,6 +643,168 @@ mod tests { ); } + /// The two anonymous-function forms must be classified by the same + /// rule (#1188). + /// + /// `check_if_func!` and `check_if_arrow_func!` walk the ancestor + /// chain to decide whether an expression is *bound to a name* or used + /// positionally, and they used to disagree in three ways. Each case + /// below is one measured divergence; the function-expression and + /// arrow spellings must now agree. + /// + /// Uses `metrics_verbatim` rather than the `check_metrics` shim, + /// whose bare-`fn` callback cannot capture the case's label or its + /// expectation. + fn check_js_binding_site_parity(lang: crate::LANG) { + let split = |source: &str| { + let m = crate::test_support::metrics_verbatim( + lang, + source.as_bytes(), + crate::MetricsOptions::default(), + ); + (m.nom.functions_sum(), m.nom.closures_sum()) + }; + + // (label, function-expression form, arrow form, is it a function?) + let cases: &[(&str, &str, &str, bool)] = &[ + // An IIFE is the same construct whether or not its *result* + // is bound. It used to be a function in the bound spelling + // only, because `$stop` was `Arguments` for functions and + // `CallExpression` for arrows — different tree levels, and an + // IIFE's chain carries no `arguments` node at all. + ( + "bare IIFE", + "(function (c) { return c; })(1);", + "(() => 1)(1);", + false, + ), + ( + "bound IIFE", + "const v = (function (c) { return c; })(1);", + "const v = (() => 1)(1);", + false, + ), + // A pair with a *non-identifier* key. The arrow reached the + // right verdict for a bare key only, through `$extra`'s + // `property_identifier` sibling, which a string key does not + // provide; `Pair` was in the function `$up` alone. + ( + "string-keyed pair", + "({ \"k\": function () { return 1; } });", + "({ \"k\": () => 1 });", + true, + ), + // The mirror: `has_sibling(PropertyIdentifier)` was in the + // arrow `$extra` alone, so a class field initialiser was a + // function as an arrow and a closure as a function expression. + ( + "class field initialiser", + "class C { p = function () { return 1; }; }", + "class C { p = () => 1; }", + true, + ), + // The three field-name spellings `$extra`'s + // `property_identifier` sibling does *not* cover. The first + // fix reached the identifier case only, so these stayed + // closures as arrows while their `function` spellings were + // already functions — the same divergence, surviving in the + // shapes the test did not exercise. `p = …` above is the one + // spelling that worked before the field-definition kind + // joined both `$up` lists. + ( + "computed-key class field", + "class C { [\"k\"] = function () { return 1; }; }", + "class C { [\"k\"] = () => 1; }", + true, + ), + ( + "string-named class field", + "class C { \"s\" = function () { return 1; }; }", + "class C { \"s\" = () => 1; }", + true, + ), + ( + "private class field", + "class C { #p = function () { return 1; }; }", + "class C { #p = () => 1; }", + true, + ), + // Plain positional callbacks, unchanged by all of the above. + ( + "positional callback", + "run(function () { return 1; });", + "run(() => 1);", + false, + ), + // A positional callback that is the *object* of a member + // expression. The first fix for the class-field divergence + // used `has_sibling(PropertyIdentifier)`, which tests every + // sibling rather than a binding position — so `.bind(this)` + // supplied the sibling and made this a function while the + // arrow spelling (which needs parentheses, interposing a + // node) stayed a closure. That re-created the very func/arrow + // divergence this test exists to forbid, in a new shape, and + // it moved real pdf.js corpus counts before review caught it. + // The binding site is now named structurally, by kind. + ( + "member-expression object", + "run(function () { return 1; }.bind(this));", + "run((() => 1).bind(this));", + false, + ), + ]; + + for &(label, func_form, arrow_form, expected) in cases { + for (form, source) in [("function", func_form), ("arrow", arrow_form)] { + let (functions, closures) = split(source); + assert_eq!( + functions == 1, + expected, + "{lang:?} {label} / {form}: expected a {}, got functions {functions} closures {closures}", + if expected { "function" } else { "closure" }, + ); + } + } + + // The divergence that must **survive**: for a function expression + // an `identifier` child is its optional name, so + // `run(function g(){})` is a function; for an arrow it is the + // un-parenthesised parameter, so `run(x => x)` must stay a + // closure. Unifying that one would reclassify the commonest + // callback shape in any JS corpus. Asserted in both directions so + // a future "unify the last one too" cannot pass quietly. + assert_eq!( + split("run(function g() { return 1; });").0, + 1, + "{lang:?}: a named function expression carries its own name", + ); + assert_eq!( + split("run(x => x);").1, + 1, + "{lang:?}: an arrow's identifier child is its parameter, not a name", + ); + } + + #[test] + fn javascript_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Javascript); + } + + #[test] + fn mozjs_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Mozjs); + } + + #[test] + fn typescript_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Typescript); + } + + #[test] + fn tsx_binding_site_parity() { + check_js_binding_site_parity(crate::LANG::Tsx); + } + #[test] fn javascript_call_nom() { check_metrics::( @@ -1594,10 +1763,10 @@ mod tests { metric.nom, @r#" { - "functions": 3, - "closures": 1, - "functions_average": 0.6, - "closures_average": 0.2, + "functions": 2, + "closures": 2, + "functions_average": 0.4, + "closures_average": 0.4, "total": 4, "average": 0.8, "functions_min": 0, diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap index 4d98b635..3e45850c 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_if_multiple_conditions.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 0, "branches": 3, - "conditions": 7, - "magnitude": 7.615773105863909, + "conditions": 9, + "magnitude": 9.486832980505138, "value": 0.0, "assignments_average": 0.0, "branches_average": 1.5, - "conditions_average": 3.5, + "conditions_average": 4.5, "assignments_min": 0, "assignments_max": 0, "branches_min": 0, "branches_max": 3, "conditions_min": 0, - "conditions_max": 7 + "conditions_max": 9 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap index 16d0bb81..159a4ffe 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_ternary_counts_condition.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 1, "branches": 0, - "conditions": 1, - "magnitude": 1.4142135623730951, + "conditions": 2, + "magnitude": 2.23606797749979, "value": 0.0, "assignments_average": 0.5, "branches_average": 0.0, - "conditions_average": 0.5, + "conditions_average": 1.0, "assignments_min": 0, "assignments_max": 1, "branches_min": 0, "branches_max": 0, "conditions_min": 0, - "conditions_max": 1 + "conditions_max": 2 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap index dff1ee04..f8c4c383 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__abc__tests__tcl_while_conditions.snap @@ -5,16 +5,16 @@ expression: metric.abc { "assignments": 0, "branches": 2, - "conditions": 4, - "magnitude": 4.47213595499958, + "conditions": 6, + "magnitude": 6.324555320336759, "value": 0.0, "assignments_average": 0.0, "branches_average": 1.0, - "conditions_average": 2.0, + "conditions_average": 3.0, "assignments_min": 0, "assignments_max": 0, "branches_min": 0, "branches_max": 2, "conditions_min": 0, - "conditions_max": 4 + "conditions_max": 6 } diff --git a/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap b/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap index 568878a3..9c8b477b 100644 --- a/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap +++ b/src/metrics/snapshots/big_code_analysis__metrics__wmc__tests__kotlin_init_block.snap @@ -3,7 +3,7 @@ source: src/metrics/wmc.rs expression: metric.wmc --- { - "class_wmc_sum": 1, + "class_wmc_sum": 2, "interface_wmc_sum": 0, - "total": 1 + "total": 2 } diff --git a/src/metrics/wmc.rs b/src/metrics/wmc.rs index 5c5f6f9b..c60abfc7 100644 --- a/src/metrics/wmc.rs +++ b/src/metrics/wmc.rs @@ -2515,19 +2515,27 @@ mod tests { #[test] fn kotlin_init_block() { - // `init` blocks are anonymous initializers, not function spaces; - // they do not add to WMC directly. The class still has whatever - // methods it declares. + // An `init` block opens a function space since #1184, so its + // complexity rolls into the class's WMC — the metric is the sum + // of a class's method-body complexities, and an initializer body + // is real class complexity a reader has to account for. Before + // #1184 the block opened no space at all: its control flow was + // charged to the enclosing class space without WMC ever seeing + // it, and `bca check` could not flag one however complex it got. + // + // WMC keys on `SpaceKind::Function` rather than on `is_func`, + // which is why this moves even though the block is deliberately + // *not* in `is_func` (it is not a callable anyone names). check_metrics::( "class C(val n: Int) { - init { // not counted + init { // +1 since #1184 require(n >= 0) { \"n must be non-negative\" } } fun get(): Int = n // +1 }", "foo.kt", |metric| { - assert_eq!(metric.wmc.class_wmc_sum(), 1); + assert_eq!(metric.wmc.class_wmc_sum(), 2); assert_eq!(metric.wmc.interface_wmc_sum(), 0); insta::assert_json_snapshot!(metric.wmc); }, diff --git a/src/node.rs b/src/node.rs index 016c37eb..630fdc27 100644 --- a/src/node.rs +++ b/src/node.rs @@ -235,32 +235,12 @@ impl<'a> Node<'a> { /// /// **`O(depth)`, not `O(1)`**, for [`Node::parent`]'s reason: /// `ts_node__prev_sibling` opens with `ts_node_parent`. Callers on a - /// walk should use [`previous_sibling_under`] or - /// [`Ancestors::previous_sibling`] instead (#1096). - /// - /// [`previous_sibling_under`]: Self::previous_sibling_under + /// walk should use [`Ancestors::previous_sibling`] instead (#1096). pub(crate) fn previous_sibling(&self) -> Option> { node_resolved_sibling_lookups::record(); self.0.prev_sibling().map(Node) } - /// The sibling immediately before this node among `parent`'s - /// children, or `None` when this node is `parent`'s first child. - /// - /// [`Node::previous_sibling`] would resolve the parent first and pay - /// its `O(depth)` cost; callers that already hold the parent — every - /// ABC condition walker does, because it descended from it — pay a - /// cursor walk over the siblings instead (#1096). - /// - /// A one-element chain is all [`Ancestors::previous_sibling`] reads, - /// so this delegates rather than repeating the scan — including its - /// fallback for a node that is not among `parent`'s children, which - /// is a caller error here but a legitimate chain/node mismatch - /// there. - pub(crate) fn previous_sibling_under(&self, parent: &Node<'a>) -> Option> { - Ancestors::known(std::slice::from_ref(parent)).previous_sibling(self) - } - /// Returns `true` if any direct child has the given grammar /// `kind_id`. See #217 for the motivating perf finding from the /// JS/TS template-literal hot path. @@ -1984,56 +1964,6 @@ mod tests { ); } - /// `previous_sibling_under` must answer exactly what the - /// authoritative `Node::previous_sibling` does, for every child of - /// the parent — including the first, whose answer is `None` for a - /// reason (no earlier sibling) rather than by accident. The ABC - /// container walkers depend on this: they seed their - /// boolean-context flag from whether a ternary's `?` / `:` precedes - /// the operand (#1096). - #[test] - fn previous_sibling_under_agrees_with_the_authoritative_lookup() { - // Anonymous tokens (`(`, `,`, `)`) sit between the named - // arguments, so the sequence exercises both kinds of sibling. - let code = b"int main() { f(a, b, c); }"; - let tree = Tree::new::(code); - let arguments = tree - .get_root() - .preorder() - .find(|n| n.kind() == "argument_list") - .expect("fixture has an argument list"); - let children: Vec> = arguments.children().collect(); - assert!( - children.len() > 3, - "fixture must have several siblings, got {}", - children.len() - ); - - let mut first_is_none = false; - for child in &children { - let expected = child.previous_sibling().map(|p| p.id()); - assert_eq!( - child.previous_sibling_under(&arguments).map(|p| p.id()), - expected, - "disagreed on the sibling before a {} node", - child.kind() - ); - first_is_none |= expected.is_none(); - } - assert!( - first_is_none, - "the first child's `None` is part of what this pins" - ); - // One break this cannot see: a scan that never finds `node` - // among the children falls back to the authoritative lookup and - // so still answers correctly, just at `Node::parent`'s cost. - // That failure mode is a perf regression, not a wrong answer, - // and the `abc/nested-if` probe is what covers it. Every - // *wrong-answer* break does fail here — returning the following - // sibling, or the parent's first child, fails on the first - // child alone. - } - /// `count_specific_ancestors` must return the same count whichever /// way it reaches the ancestors. Uses `loc`'s real C predicate pair /// (`while`/`for`/`if` header, stopping at the enclosing block), so diff --git a/src/preproc.rs b/src/preproc.rs index 4a0acd0f..47725f73 100644 --- a/src/preproc.rs +++ b/src/preproc.rs @@ -77,10 +77,10 @@ impl std::fmt::Display for PreprocDiagnostic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SelfInclusion { file } => { - write!(f, "Warning: possible self inclusion {}", file.display()) + write!(f, "warning: possible self inclusion {}", file.display()) } Self::IncludeCycle { members } => { - writeln!(f, "Warning: possible include cycle:")?; + writeln!(f, "warning: possible include cycle:")?; for member in members { // Explicit quotes preserve whitespace visibility for // paths that contain spaces — important when the cycle @@ -101,7 +101,7 @@ impl std::fmt::Display for PreprocDiagnostic { ), Self::NotPreprocessed { file } => write!( f, - "Warning: included file which has not been preprocessed: {}", + "warning: included file which has not been preprocessed: {}", file.display() ), } diff --git a/src/preproc_tests.rs b/src/preproc_tests.rs index 162278be..c01b55d5 100644 --- a/src/preproc_tests.rs +++ b/src/preproc_tests.rs @@ -846,3 +846,86 @@ fn preprocess_truncated_include_does_not_panic() { .expect("file entry must be inserted"); assert!(pf.direct_includes.is_empty()); } + +/// The `Display` impl is the only rendering of these diagnostics a user +/// ever sees — the CLI writes it to stderr and `bca-web` may surface it +/// verbatim — so each variant's text is pinned exactly rather than +/// probed with `contains`. Asserting the whole string is what catches a +/// dropped path interpolation or a lost prefix; a substring check passes +/// against both. +/// +/// This covers the four single-line variants. The multi-line +/// `IncludeCycle` is pinned by +/// `preproc_diagnostic_display_lists_every_cycle_member` below, which +/// needs a different assertion shape. +/// +/// All five variants use the lowercase `warning:` prefix, matching the +/// CLI's own severity ladder in `big-code-analysis-cli/src/diag.rs` +/// (#609). That is load-bearing rather than cosmetic: `warn(msg)` is +/// `eprintln!("warning: {msg}")`, and `bca preproc` currently emits +/// these through a bare `eprintln!("{diagnostic}")`, so the two +/// spellings now produce identical bytes. Moving the prefix out of +/// `Display` and onto `warn()` is therefore a pure refactor — these +/// assertions are what will prove it emitted nothing new. +#[test] +fn preproc_diagnostic_display_renders_each_single_line_variant() { + assert_eq!( + PreprocDiagnostic::SelfInclusion { + file: PathBuf::from("inc/self ref.h"), + } + .to_string(), + "warning: possible self inclusion inc/self ref.h", + ); + + assert_eq!( + PreprocDiagnostic::NonUtf8CyclePath { + path: "bad/\u{fffd}.h".to_owned(), + } + .to_string(), + "warning: skipping non-UTF-8 path in include cycle: bad/\u{fffd}.h", + ); + + assert_eq!( + PreprocDiagnostic::NonUtf8IndirectInclude { + path: "bad/\u{fffd}.h".to_owned(), + } + .to_string(), + "warning: skipping non-UTF-8 indirect include path: bad/\u{fffd}.h", + ); + + assert_eq!( + PreprocDiagnostic::NotPreprocessed { + file: PathBuf::from("vendor/unseen.h"), + } + .to_string(), + "warning: included file which has not been preprocessed: vendor/unseen.h", + ); +} + +/// `IncludeCycle` is the only multi-line variant: a header line plus one +/// quoted line per member, each newline-terminated. The member list here +/// is deliberately unsorted and contains a path with a space — the +/// quoting exists precisely so that whitespace stays visible, and a +/// member set of one could not distinguish "prints every member" from +/// "prints the first". +#[test] +fn preproc_diagnostic_display_lists_every_cycle_member() { + let rendered = PreprocDiagnostic::IncludeCycle { + members: vec!["z.h".to_owned(), "a b.h".to_owned(), "m.h".to_owned()], + } + .to_string(); + + assert_eq!( + rendered, + "warning: possible include cycle:\n - \"z.h\"\n - \"a b.h\"\n - \"m.h\"\n", + ); + + let empty = PreprocDiagnostic::IncludeCycle { + members: Vec::new(), + } + .to_string(); + assert_eq!( + empty, "warning: possible include cycle:\n", + "an empty member list still renders the header and nothing else" + ); +} diff --git a/src/spaces.rs b/src/spaces.rs index 79fb8096..1fb8059d 100644 --- a/src/spaces.rs +++ b/src/spaces.rs @@ -281,10 +281,27 @@ crate::recursion::impl_iterative_drop!(FuncSpace, spaces); /// `SpaceKind::Unit` instead — as both walks did until #1163 — put /// Perl's trailing `sub` a line past its parent unit and past EOF. pub(crate) fn line_span(node: &Node<'_>, kind: SpaceKind) -> (usize, usize) { - // An empty file occupies no lines at all; the general rule would - // report the inverted `1..0`. - if kind == SpaceKind::Unit && node.child_count() == 0 { - return (0, 0); + if kind == SpaceKind::Unit { + // The file-level unit starts at line 1 by definition — it *is* + // the file — so its start is anchored rather than measured. The + // root node's own `start_row` is not line 1 whenever the file + // opens with blank lines, because tree-sitter starts the root at + // the first token: `"\n\n\nfn a() {}\n"` put the unit at 4..4 of + // a 4-line file, omitting the three lines above it. A leading + // *comment* was always fine, since comments are in the tree. + // + // A childless root is the acute form of the same thing: with no + // token to start at, the node collapses to a point at the end of + // the whitespace, so `start_row` is the *last* row and the + // measured rule inverts outright — `"\n\n"` gives 3..2 (#1195). + // + // An empty file is the one input that genuinely has no lines. + // `end_line` is 0 there and `1..0` would be inverted in turn, so + // it keeps the degenerate `0..0` — and is therefore the one + // input `space_span_containment`'s `(1, line_count)` rule + // cannot be asked about. + let end = node.end_line(); + return if end == 0 { (0, 0) } else { (1, end) }; } (node.start_row() + 1, node.end_line()) } diff --git a/src/spaces_tests.rs b/src/spaces_tests.rs index 0772c0cf..35e2c937 100644 --- a/src/spaces_tests.rs +++ b/src/spaces_tests.rs @@ -2027,3 +2027,252 @@ int helper(int y) { ); }); } + +/// The `line_span` carve-out for a root that parses to no children +/// (#1195). +/// +/// These inputs are unreachable through `check_metrics` and the +/// integration harnesses, both of which append a trailing newline (see +/// `.claude/rules/testing.md`), so they go through `space_verbatim`. +#[cfg(all(test, feature = "rust"))] +mod childless_root_span { + use crate::test_support::space_verbatim; + use crate::{LANG, MetricsOptions}; + + fn span(source: &str) -> (usize, usize) { + let space = space_verbatim(LANG::Rust, source.as_bytes(), MetricsOptions::default()); + (space.start_line, space.end_line) + } + + #[test] + fn a_whitespace_only_file_spans_its_lines() { + // tree-sitter collapses a childless root to a point at the end + // of the whitespace, so `start_row` is the *last* row: before + // #1195 the guard returned `0..0` and the general rule would + // have returned the inverted `3..2`. Neither is the answer every + // other space gives, which is `1..line_count`. + for source in ["\n", "\n\n", " \n \n", " ", "\t\n\n\n"] { + let lines = source.lines().count(); + assert_eq!( + span(source), + (1, lines), + "whitespace-only source {source:?} has {lines} lines" + ); + } + } + + #[test] + fn an_empty_file_keeps_the_degenerate_zero_span() { + // The one input with no lines at all. `1..0` would be inverted, + // which is what the containment invariant forbids, so `0..0` is + // deliberate rather than an oversight. + assert_eq!(span(""), (0, 0)); + assert_eq!("".lines().count(), 0); + } + + #[test] + fn a_file_with_code_is_unaffected() { + // Pins that the anchoring is scoped to the unit: a *nested* + // space still takes `start_row + 1`, so a fix that hard-coded 1 + // everywhere would pass the cases above and fail here. + assert_eq!(span("fn a() {}\n"), (1, 1)); + assert_eq!(span("fn a() {}\nfn b() {}\n"), (1, 2)); + + let space = space_verbatim( + LANG::Rust, + b"\n\nfn a() {\n let x = 1;\n}\n", + MetricsOptions::default(), + ); + assert_eq!( + (space.spaces[0].start_line, space.spaces[0].end_line), + (3, 5), + "the nested function keeps its measured span" + ); + } + + #[test] + fn leading_blank_lines_do_not_shorten_the_unit() { + // The second half of the same defect, found by the control test + // above. tree-sitter starts the root at the first *token*, so a + // file opening with blank lines reported a unit that omitted + // them: 6 lines of source, span 4..6. + for source in ["\n\nfn a() {}\n", "\nfn a() {}\n", "\n\n\nfn a() {}\n\n\n"] { + let lines = source.lines().count(); + assert_eq!( + span(source), + (1, lines), + "source {source:?} has {lines} lines" + ); + } + // A leading comment was never affected: comments are in the + // tree, so the root already started at line 1. + assert_eq!(span("// c\nfn a() {}\n"), (1, 2)); + } +} + +/// Constructs that carry executable code but no name token now open a +/// function space of their own (#1184). +/// +/// Each was referenced nowhere outside the generated language enum, so +/// it opened no `FuncSpace` at all: its control flow was charged to the +/// enclosing class, `nom` / `wmc` under-counted, and `bca check` could +/// never flag one however complex it got. A Kotlin file of nothing but +/// property accessors reported `nom.functions == 0`. +/// +/// Every assertion here is made on **both** the new space and the space +/// it took the value from. Asserting only the new one passes even if the +/// parent kept a duplicate count, which is the defect #1160 found in the +/// first draft of the equivalent Java fix. +#[cfg(test)] +mod nameless_construct_spaces { + use crate::test_support::space_verbatim; + use crate::{FuncSpace, LANG, MetricsOptions, SpaceKind}; + + fn analyse(lang: LANG, source: &str) -> FuncSpace { + space_verbatim(lang, source.as_bytes(), MetricsOptions::default()) + } + + /// The `(name, kind)` of every descendant space, in preorder. + fn shape(space: &FuncSpace) -> Vec<(Option<&str>, SpaceKind)> { + let mut out = vec![(space.name.as_deref(), space.kind)]; + for child in &space.spaces { + out.extend(shape(child)); + } + out + } + + fn child<'a>(space: &'a FuncSpace, name: &str) -> &'a FuncSpace { + fn find<'a>(s: &'a FuncSpace, name: &str) -> Option<&'a FuncSpace> { + if s.name.as_deref() == Some(name) { + return Some(s); + } + s.spaces.iter().find_map(|c| find(c, name)) + } + find(space, name).unwrap_or_else(|| panic!("no space named {name:?} in {:?}", shape(space))) + } + + #[test] + #[cfg(feature = "kotlin")] + fn kotlin_accessors_and_init_open_named_spaces() { + let root = analyse( + LANG::Kotlin, + "class C {\n\ + \x20 var p: Int = 0\n\ + \x20 get() { if (field > 0) { return field } else { return 0 } }\n\ + \x20 set(v) { if (v > 0) { field = v } }\n\ + \x20 init { if (p > 0) { println(\"x\") } }\n\ + }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + (Some(""), SpaceKind::Function), + (Some(""), SpaceKind::Function), + ], + ); + + // The value moved rather than being duplicated: the accessor + // owns its branch, and the class no longer owns it directly. + let class = child(&root, "C"); + assert_eq!( + class.metrics.cognitive.cognitive(), + 0, + "class own cognitive" + ); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 2); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 1); + assert_eq!(child(&root, "").metrics.cognitive.cognitive(), 1); + + // `nom.functions` deliberately stays 0. The issue calls an + // accessor-only file reporting `nom.functions == 0` the worst of + // the set, and this fix does *not* change that number: `Nom` + // keys on `is_func`, and these constructs are `is_func_space` + // only, because a Kotlin accessor is not a callable anyone names + // at a call site (`p.foo`, not `p.getFoo()`) and counting it as + // a method would have `npm` bill the same property once as an + // attribute and again as a method. + // + // What the fix does change is everything the missing *space* + // caused: each accessor now has its own metric scope, so its + // complexity is no longer charged to the class and `bca check` + // can flag it. WMC picks them up because it keys on the space + // kind rather than on `is_func`. + assert_eq!( + root.metrics.nom.functions_sum(), + 0, + "accessors are is_func_space, not is_func" + ); + assert_eq!( + root.metrics.wmc.class_wmc_sum(), + 6, + "WMC keys on SpaceKind::Function, so it does pick them up" + ); + } + + #[test] + #[cfg(feature = "java")] + fn java_static_initializer_opens_a_named_space() { + let root = analyse( + LANG::Java, + "class C { static int x; static { if (x > 0) { x = 1; } else { x = 2; } } }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + ], + ); + assert_eq!(child(&root, "C").metrics.cognitive.cognitive(), 0); + assert_eq!( + child(&root, "").metrics.cognitive.cognitive(), + 2 + ); + } + + #[test] + #[cfg(feature = "javascript")] + fn javascript_class_static_block_opens_a_named_space() { + let root = analyse( + LANG::Javascript, + "class C { static f; static { if (C.f) { C.f = 1; } else { C.f = 2; } } }\n", + ); + assert_eq!( + shape(&root), + vec![ + (None, SpaceKind::Unit), + (Some("C"), SpaceKind::Class), + (Some(""), SpaceKind::Function), + ], + ); + assert_eq!(child(&root, "C").metrics.cognitive.cognitive(), 0); + assert_eq!( + child(&root, "").metrics.cognitive.cognitive(), + 2 + ); + } + + /// Sibling constructs with the same synthesised name are allowed to + /// collide, exactly as multiple `` siblings already do. + /// Pinned so the collision reads as a decision rather than an + /// oversight — inventing an index would make the name unstable under + /// an unrelated edit. + #[test] + #[cfg(feature = "java")] + fn two_static_initializers_produce_two_identically_named_spaces() { + let root = analyse( + LANG::Java, + "class C { static int x; static { x = 1; } static { x = 2; } }\n", + ); + let names: Vec<_> = shape(&root) + .into_iter() + .filter(|(_, k)| *k == SpaceKind::Function) + .map(|(n, _)| n) + .collect(); + assert_eq!(names, vec![Some(""), Some("")]); + } +} diff --git a/src/vcs/git/blame_tests.rs b/src/vcs/git/blame_tests.rs index 025aad60..bdc0551c 100644 --- a/src/vcs/git/blame_tests.rs +++ b/src/vcs/git/blame_tests.rs @@ -4,7 +4,9 @@ use std::cell::Cell; -use super::{LineRun, LineSpan, MAX_BLAME_ATTEMPTS, ObjectId, retry_transient}; +use super::{ + LineRun, LineSpan, MAX_BLAME_ATTEMPTS, ObjectId, is_transient_object_miss, retry_transient, +}; /// Drive `retry_transient` with a synthetic closure whose i-th call /// returns `outcomes[i]` (and counts calls), treating every `Err` as @@ -259,3 +261,43 @@ fn a_session_handle_carries_the_object_cache() { "a blame session must set an object cache on its thread-local handle" ); } + +/// `is_transient_object_miss` decides whether the post-blame commit +/// lookup is retried. Getting it wrong is silent in both directions: too +/// narrow and the #579 race resurfaces as a hard failure, too wide and a +/// genuinely-missing or wrong-kind object burns the whole retry budget +/// before reporting what was actually wrong. +/// +/// The predicate is a two-level `matches!`, so a test that only supplied +/// the retryable value would pass against `|_| true`. Each non-retryable +/// shape is therefore constructed too, one per arm the pattern can +/// reject: the sibling `Find` variant (inner arm) and `Convert` (outer +/// arm). +#[test] +fn only_a_not_found_commit_lookup_is_retried() { + use gix::object::find::existing::Error as ExistingError; + use gix::object::find::existing::with_conversion::Error as LookupError; + + let oid = ObjectId::null(gix::hash::Kind::Sha1); + + assert!( + is_transient_object_miss(&LookupError::Find(ExistingError::NotFound { oid })), + "a NotFound miss is the transient ODB race (#579) and must be retried" + ); + + assert!( + !is_transient_object_miss(&LookupError::Find(ExistingError::Find( + "odb handle is closed".into() + ))), + "a lower-level Find failure is deterministic — retrying it only delays the error" + ); + + assert!( + !is_transient_object_miss(&LookupError::Convert(gix::object::try_into::Error { + actual: gix::objs::Kind::Tree, + expected: gix::objs::Kind::Commit, + id: oid, + })), + "an object that is not a commit will never become one; retrying cannot help" + ); +} diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap index fd7011b1..13899f36 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_cpp_widget.snap @@ -3,7 +3,7 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -widget.cc,widget.cc,unit,2,10,0,0,0,0,5,2.5,1,1,5,2.5,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,1.8,1.8,0.4,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.4,0,2,0.4,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,7.6,0,12,1,0,0,1,0.2,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.16454969426378,83.7420968306897,64.4237132714993 +widget.cc,widget.cc,unit,1,10,0,0,0,0,5,2.5,1,1,5,2.5,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,1.8,1.8,0.4,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.4,0,2,0.4,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,7.6,0,12,1,0,0,1,0.2,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.16454969426378,83.7420968306897,64.4237132714993 widget.cc,ns,namespace,2,10,0,0,0,0,4,2,1,1,4,2,1,1,8,18,6,9,27,39.50977500432694,1.4633250001602571,14,102.79858289555531,6,0.16666666666666666,616.7914973733318,34.266194298518435,0.024152995332196794,9,9,2,0,0,2.25,2.25,0.5,0,0,1,9,0,0,1,9,1,2,0,0,2,0,0.5,0,2,0.5,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,38,9.5,5,12,1,0,0,1,0.25,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,110.3945496942638,83.97209683068972,64.55821619547591 widget.cc,Widget,class,3,9,0,0,0,0,3,1.5,1,1,3,1.5,1,1,8,16,5,8,24,35.60964047443681,1.4837350197682004,13,88.81055323538621,6.4,0.15625,568.3875407064718,31.57708559480399,0.022872226594196698,7,7,2,0,0,2.3333333333333335,2.3333333333333335,0.6666666666666666,0,0,1,7,0,0,1,7,1,2,0,0,2,0,0.6666666666666666,0,2,0.6666666666666666,0,1,0,0,1,0,0.5,0,1,0.5,0,1,0,0,1,0.5,0,1,33,11,10,12,1,0,0,1,0.3333333333333333,0,0,0,1,0,0,0,0,2,0,2,2,0,2,0,1,0,2,2,1,0,0,1,0,0,0,0,1,0,115.45642705977977,91.17302234410326,67.51837839753202 widget.cc,value,function,5,5,0,0,0,0,1,1,1,1,1,1,1,1,5,5,2,2,7,13.60964047443681,1.944234353490973,7,19.651484454403228,2.5,0.4,49.12871113600807,2.7293728408893374,0.004471319041702271,1,1,1,0,0,1,1,1,0,0,1,1,0,0,1,1,1,1,0,0,1,0,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,10,10,10,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,155.2836050228807,148.4278547652622,90.80912574437467 diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap index ef35ac27..358d57c5 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_python_greeter.snap @@ -3,7 +3,7 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -greeter.py,greeter.py,unit,2,9,1,0.5,0,1,5,2.5,1,2,5,2.5,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2,1.75,1,0,0.25,2,8,0,0,2,7,1,4,0,1,2,0,0.5,0,2,0.5,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,10.25,0,25,1,0,1,1.4142135623730951,0.25,0,0.25,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,112.998077434453,87.83001324968197,66.08074703769181 +greeter.py,greeter.py,unit,1,9,1,0.5,0,1,5,2.5,1,2,5,2.5,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2,1.75,1,0,0.25,2,8,0,0,2,7,1,4,0,1,2,0,0.5,0,2,0.5,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,10.25,0,25,1,0,1,1.4142135623730951,0.25,0,0.25,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,112.998077434453,87.83001324968197,66.08074703769181 greeter.py,Greeter,class,2,9,1,0.5,0,1,4,2,1,2,4,2,1,2,6,10,6,14,24,31.019550008653873,1.292481250360578,12,86.03910001730775,7,0.14285714285714285,602.2737001211542,33.45965000673079,0.02377249024853662,8,7,4,0,1,2.6666666666666665,2.3333333333333335,1.3333333333333333,0,0.3333333333333333,2,8,0,0,2,7,1,4,0,1,2,0,0.6666666666666666,0,2,0.6666666666666666,0,1,0,0,3,0,1.5,0,3,1.5,0,2,0,0,2,1,0,2,41,13.666666666666666,3,25,1,0,1,1.4142135623730951,0.3333333333333333,0,0.3333333333333333,0,1,0,0,0,1,3,0,3,2,0,2,0,1,0,2,2,1,1,0,1,0,1,0,1,1,1,113.22807743445301,88.06001324968199,66.21524996166842 greeter.py,__init__,function,3,4,0,0,0,0,1,1,1,1,1,1,1,1,4,4,3,6,10,12.754887502163468,1.2754887502163468,7,28.07354922057604,4,0.25,112.29419688230416,6.2385664934613425,0.007758604623869012,2,2,1,0,0,2,2,1,0,0,2,2,0,0,2,2,1,1,0,0,1,0,1,0,1,1,1,1,0,0,2,0,2,0,2,2,2,2,0,0,0,0,0,0,13,13,13,13,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,142.19991098932817,129.5520742665475,83.15784268381765 greeter.py,greet,function,6,9,1,1,1,1,2,2,2,2,2,2,2,2,4,6,4,7,13,16,1.2307692307692308,8,39,3.5,0.2857142857142857,136.5,7.583333333333333,0.008836911647049637,4,4,3,0,0,4,4,3,0,0,4,4,0,0,4,4,3,3,0,0,1,0,1,0,1,1,1,1,0,0,1,0,1,0,1,1,1,1,0,0,2,2,2,2,25,25,25,25,0,0,1,1,0,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,129.0315107899836,110.65590846191628,75.45702385379158 diff --git a/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap index 5608fbd4..94d5d511 100644 --- a/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap +++ b/tests/output_formats/snapshots/output_formats__csv_test__csv_rust_counter.snap @@ -3,6 +3,6 @@ source: tests/output_formats/csv_test.rs expression: out --- path,space_name,space_kind,start_line,end_line,cognitive.sum,cognitive.average,cognitive.min,cognitive.max,cyclomatic.sum,cyclomatic.average,cyclomatic.min,cyclomatic.max,cyclomatic.modified.sum,cyclomatic.modified.average,cyclomatic.modified.min,cyclomatic.modified.max,halstead.unique_operators,halstead.total_operators,halstead.unique_operands,halstead.total_operands,halstead.length,halstead.estimated_program_length,halstead.purity_ratio,halstead.vocabulary,halstead.volume,halstead.difficulty,halstead.level,halstead.effort,halstead.time,halstead.bugs,loc.sloc,loc.ploc,loc.lloc,loc.cloc,loc.blank,loc.sloc_average,loc.ploc_average,loc.lloc_average,loc.cloc_average,loc.blank_average,loc.sloc_min,loc.sloc_max,loc.cloc_min,loc.cloc_max,loc.ploc_min,loc.ploc_max,loc.lloc_min,loc.lloc_max,loc.blank_min,loc.blank_max,nom.functions,nom.closures,nom.functions_average,nom.closures_average,nom.total,nom.average,nom.functions_min,nom.functions_max,nom.closures_min,nom.closures_max,nargs.function_args,nargs.closure_args,nargs.function_args_average,nargs.closure_args_average,nargs.total,nargs.average,nargs.function_args_min,nargs.function_args_max,nargs.closure_args_min,nargs.closure_args_max,nexits.sum,nexits.average,nexits.min,nexits.max,tokens.sum,tokens.average,tokens.min,tokens.max,abc.assignments,abc.branches,abc.conditions,abc.magnitude,abc.assignments_average,abc.branches_average,abc.conditions_average,abc.assignments_min,abc.assignments_max,abc.branches_min,abc.branches_max,abc.conditions_min,abc.conditions_max,wmc.class_wmc_sum,wmc.interface_wmc_sum,wmc.total,npm.class_npm_sum,npm.interface_npm_sum,npm.class_methods,npm.interface_methods,npm.class_coa,npm.interface_coa,npm.total,npm.total_methods,npm.coa,npa.class_npa_sum,npa.interface_npa_sum,npa.class_attributes,npa.interface_attributes,npa.class_cda,npa.interface_cda,npa.total,npa.total_attributes,npa.cda,mi.original,mi.sei,mi.visual_studio -counter.rs,counter.rs,unit,2,13,2,2,0,2,4,4,1,2,4,4,1,2,15,23,6,15,38,74.11313393845472,1.950345629959335,21,166.9080620655929,18.75,0.05333333333333334,3129.5261637298668,173.86256465165926,0.07131777790263222,12,11,3,0,1,4,3.6666666666666665,1,0,0.3333333333333333,8,12,0,0,8,11,3,3,0,1,1,0,0.3333333333333333,0,1,0.3333333333333333,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,15,4,34,1,0,2,2.23606797749979,0.3333333333333333,0,0.6666666666666666,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,103.21380797524063,73.61247636275048,60.358952032304465 +counter.rs,counter.rs,unit,1,13,2,2,0,2,4,4,1,2,4,4,1,2,15,23,6,15,38,74.11313393845472,1.950345629959335,21,166.9080620655929,18.75,0.05333333333333334,3129.5261637298668,173.86256465165926,0.07131777790263222,12,11,3,0,1,4,3.6666666666666665,1,0,0.3333333333333333,8,12,0,0,8,11,3,3,0,1,1,0,0.3333333333333333,0,1,0.3333333333333333,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,45,15,4,34,1,0,2,2.23606797749979,0.3333333333333333,0,0.6666666666666666,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,103.21380797524063,73.61247636275048,60.358952032304465 counter.rs,Counter,impl,4,13,2,2,0,2,3,3,1,2,3,3,1,2,14,20,6,13,33,68.81274391313339,2.085234664034345,20,142.62362713128297,15.166666666666666,0.06593406593406594,2163.125011491125,120.17361174950695,0.05575277534828428,10,10,3,0,0,5,5,1.5,0,0,8,10,0,0,8,10,3,3,0,0,1,0,0.5,0,1,0.5,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,38,19,4,34,1,0,2,2.23606797749979,0.5,0,1,0,1,0,0,0,2,2,0,2,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,107.21503374494837,79.28320507878054,62.69885014324466 counter.rs,bump,function,5,12,2,2,2,2,2,2,2,2,2,2,2,2,13,18,5,12,30,59.715356810271004,1.9905118936757,18,125.09775004326937,15.6,0.06410256410256411,1951.524900675002,108.41805003750011,0.05205488323759921,8,8,3,0,0,8,8,3,0,0,8,8,0,0,8,8,3,3,0,0,1,0,1,0,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,34,34,34,34,1,0,2,2.23606797749979,1,0,2,1,1,0,0,2,2,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,111.74175077815008,85.71205743468377,65.34605308663747 diff --git a/tests/parity/cognitive_cross_language_parity.rs b/tests/parity/cognitive_cross_language_parity.rs index f3e22978..302e3ad4 100644 --- a/tests/parity/cognitive_cross_language_parity.rs +++ b/tests/parity/cognitive_cross_language_parity.rs @@ -238,3 +238,100 @@ func f(x int) string { assert_eq!(irules, expected, "irules"); assert_eq!(objc, expected, "objc"); } + +/// A function *declared inside a closure* must score the same as the +/// same function declared outside one (#1187). +/// +/// The lambda surcharge is a property of where the closure's *body* sits, +/// not of a new function boundary opened inside it. Until #1187 only the +/// JS macro reset `nesting.lambda` at a boundary, so every other language +/// carried the enclosing closure's surcharge into the nested declaration +/// and the same body scored 3 or 2 depending on whether something two +/// levels up happened to be a closure. +/// +/// The paired fixtures are what make this discriminate: an absolute +/// value alone would move with any unrelated re-tuning, whereas the +/// *difference* between the two is exactly the defect. Both halves are +/// asserted so a regression that changed both equally still fails. +/// +/// Python is deliberately absent: a `def` is a statement and a lambda +/// body is a single expression, so the shape is unconstructible. +#[test] +fn a_function_declared_inside_a_closure_scores_the_same_as_outside() { + /// The innermost `g`'s own cognitive score. + fn inner_g(lang: LANG, source: &str, ext: &str) -> f64 { + fn find(space: &big_code_analysis::FuncSpace) -> Option { + if space.name.as_deref() == Some("g") { + return Some(space.metrics.cognitive.cognitive() as f64); + } + space.spaces.iter().find_map(find) + } + let name = format!("parity.{ext}"); + let space = analyze( + Source::new(lang, source.as_bytes()).with_name(Some(name)), + MetricsOptions::default(), + ) + .expect("parser produced no FuncSpace for parity fixture"); + find(&space).unwrap_or_else(|| panic!("{lang:?}: fixture has no function named `g`")) + } + + // (language, extension, inside a closure, outside one). The inner + // body is byte-identical between the two halves of each pair. + let cases: &[(LANG, &str, &str, &str)] = &[ + ( + LANG::Rust, + "rs", + "fn outer(a: bool, b: bool) { let f = || { if a { fn g(b: bool) { if b { println!(\"x\"); } } g(b); } }; f(); }", + "fn outer(a: bool, b: bool) { if a { fn g(b: bool) { if b { println!(\"x\"); } } g(b); } }", + ), + ( + LANG::Java, + "java", + "class K { void outer(boolean a, boolean b){ Runnable r = () -> { if(a){ class L { void g(boolean b){ if(b){ System.out.print(1); } } } } }; } }", + "class K { void outer(boolean a, boolean b){ if(a){ class L { void g(boolean b){ if(b){ System.out.print(1); } } } } } }", + ), + ( + LANG::Cpp, + "cpp", + "void outer(bool a, bool b){ auto f = [&]{ if(a){ struct L { void g(bool b){ if(b){ } } }; } }; f(); }", + "void outer(bool a, bool b){ if(a){ struct L { void g(bool b){ if(b){ } } }; } }", + ), + ( + LANG::Php, + "php", + " { if(a){ void g(bool b){ if(b){ } } } }; f(); } }", + "class K{ void outer(bool a, bool b){ if(a){ void g(bool b){ if(b){ } } } } }", + ), + ]; + + let mut checked = 0; + for &(lang, ext, inside, outside) in cases { + if !lang.is_enabled() { + continue; + } + checked += 1; + let (inside_score, outside_score) = + (inner_g(lang, inside, ext), inner_g(lang, outside, ext)); + assert_eq!( + inside_score, outside_score, + "{lang:?}: `g` scored {inside_score} inside a closure and {outside_score} outside it", + ); + // expected: `g`'s own `if` is +1 base, plus +1 for `g` being + // lexically nested in another function. The enclosing closure + // must contribute nothing. + assert_eq!( + outside_score, 2.0, + "{lang:?}: the baseline itself moved, so the equality above proves nothing", + ); + } + assert!( + checked > 0, + "at least one language feature must be enabled for this test to mean anything" + ); +} diff --git a/tests/parity/functions_metrics_parity.rs b/tests/parity/functions_metrics_parity.rs index df770d58..56aa699a 100644 --- a/tests/parity/functions_metrics_parity.rs +++ b/tests/parity/functions_metrics_parity.rs @@ -30,8 +30,8 @@ use big_code_analysis::{Ast, FuncSpace, LANG, MetricsOptions, Source, SpaceKind, use super::ops_metrics_space_parity::fixture; -/// The name `Getter::get_func_space_name` reports for a space whose AST -/// node carries no name field — a closure, lambda, or block. +/// Whether `name` is one `Getter::get_func_space_name` synthesised for a +/// space whose AST node carries no name of its own. /// /// These are the one legitimate asymmetry between the two sides. Every /// language promotes such a node to a `SpaceKind::Function` space, but @@ -39,11 +39,27 @@ use super::ops_metrics_space_parity::fixture; /// `FuncLiteral`, Perl's and PHP's anonymous `sub`/`function`, Ruby's /// `lambda` and Lua's `function` expression are `is_func_space` without /// being `is_func`, while a JavaScript arrow function and an iRules -/// `when` block are both. `bca functions` therefore reports some -/// anonymous spaces and not others, which is a per-grammar judgement -/// this test has no business pinning — so the coverage claim below is -/// scoped to spaces that carry a real name. -const ANONYMOUS: &str = ""; +/// `when` block are both. `bca functions` therefore reports some such +/// spaces and not others, which is a per-grammar judgement this test has +/// no business pinning — so the coverage claim below is scoped to spaces +/// that carry a real name. +/// +/// The five names are enumerated rather than matched by shape. A +/// `starts_with('<') && ends_with('>')` predicate reads as equivalent +/// but is not: Ruby's spaceship operator is a real method, and +/// `def <=>(other)` produces a space named literally `<=>`, which such a +/// predicate would exempt from the coverage claim below. Enumerating +/// also makes a sixth synthesised name fail loudly rather than inherit +/// the exemption silently. +/// +/// `` is the trait default; #1184 added the other four for +/// constructs that carry executable code but no name token, each +/// `is_func_space` without being `is_func` for exactly the reason above. +const SYNTHESISED_NAMES: &[&str] = &["", "", "", "", ""]; + +fn is_synthesised_name(name: &str) -> bool { + SYNTHESISED_NAMES.contains(&name) +} /// A space or span, reduced to the fields all three seams report. type Fun = (Option, usize, usize); @@ -122,7 +138,7 @@ fn every_named_function_space_is_reported_by_functions_and_find() { // back out of `functions()`. This is the #1162 direction: before // the fix, Elixir's `bar` was here and nowhere else. for fun in &from_metrics { - if fun.0.as_deref() == Some(ANONYMOUS) { + if fun.0.as_deref().is_some_and(is_synthesised_name) { continue; } assert!( diff --git a/tests/parity/ops_metrics_space_parity.rs b/tests/parity/ops_metrics_space_parity.rs index 4efce90d..ef324e5b 100644 --- a/tests/parity/ops_metrics_space_parity.rs +++ b/tests/parity/ops_metrics_space_parity.rs @@ -45,8 +45,15 @@ pub(super) fn fixture(lang: LANG) -> (&'static str, &'static str) { // per-edit `bca check` hook rather than for the self-scan gate. // bca: suppress(cyclomatic) match lang { + // The generator is deliberate: `function* h()` opened a + // `SpaceKind::Function` space named `h` while `is_func` said + // false, so `functions()` did not report it — a live violation of + // `functions_metrics_parity`'s coverage claim that no fixture + // reached. #1186 fixed the classification; this makes the fixture + // able to catch a regression of it. LANG::Javascript | LANG::Mozjs => ( - "function f(a) {\n const g = (b) => b * 2;\n return g(a) + 1;\n}\n", + "function f(a) {\n const g = (b) => b * 2;\n return g(a) + 1;\n}\n\ + function* h(n) {\n yield n;\n}\n", "js", ), LANG::Typescript => ( diff --git a/tests/parity/space_span_containment.rs b/tests/parity/space_span_containment.rs index b9e01dec..ead80e97 100644 --- a/tests/parity/space_span_containment.rs +++ b/tests/parity/space_span_containment.rs @@ -137,6 +137,42 @@ fn every_space_lies_within_its_parent_in_every_language() { ); } +/// The degenerate sources every language must still span correctly +/// (#1195). +/// +/// The sweep above only ever feeds `check_source` a real code fixture, +/// so the `(1, line_count)` rule it asserts was never tested against a +/// file that opens with blank lines or holds nothing but whitespace — +/// exactly the inputs where it did not hold. A whitespace-only file +/// parses to a childless root, which tree-sitter collapses to a point at +/// the *end* of the whitespace; the unit reported `0..0` for every one +/// of these before the fix. +/// +/// An empty file is deliberately absent: it has no lines at all, so the +/// `(1, line_count)` rule would demand the inverted `1..0`. That carve-out +/// is pinned as a unit test beside `line_span` instead. +#[test] +fn degenerate_sources_still_span_their_lines_in_every_language() { + let mut checked = 0; + + for lang in LANG::into_enum_iter() { + if !lang.is_enabled() { + continue; + } + checked += 1; + + let (_, ext) = fixture(lang); + for source in ["\n", "\n\n", " \n \n", "\t\n\n\n"] { + check_source(lang, source, ext); + } + } + + assert!( + checked > 0, + "at least one language feature must be enabled for this test to mean anything" + ); +} + /// The #1163 reproducer and its two controls, with the spans spelled out /// rather than only checked for containment — the invariant above says /// the tree is *representable*, these say it is *right*. diff --git a/tests/repositories/big-code-analysis-output b/tests/repositories/big-code-analysis-output index d71252eb..7a9c5ec8 160000 --- a/tests/repositories/big-code-analysis-output +++ b/tests/repositories/big-code-analysis-output @@ -1 +1 @@ -Subproject commit d71252eb413c2fe048e0467649e1d6bcdbe5d8bd +Subproject commit 7a9c5ec8fb06d185d09588e8ca7af9468104410a diff --git a/utils/check-snapshot-anchors-test.py b/utils/check-snapshot-anchors-test.py new file mode 100644 index 00000000..7e3955a2 --- /dev/null +++ b/utils/check-snapshot-anchors-test.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Tests for check-snapshot-anchors.py. + +Three kinds of test, matching the check-rustfmt-bail-test.py pattern: + +* Unit tests for the literal lexers, weighted toward the inputs that + made the scanner under-count before #1192: a char literal holding a + double quote, a lifetime that must *not* be read as one, and the + byte-string prefixes the raw-string branch used to miss. +* Unit tests for ``count_bare_in_source`` over synthetic Rust, pinning + both directions — an anchored call stays uncounted, a bare call is + counted even when a quote-bearing literal precedes it. +* ``main()`` tests over a synthetic tree covering the pass, regress and + ratchet branches, plus a smoke test asserting the real repository is + clean against its checked-in baseline. + +The gate scripts are hyphen-named and so cannot be imported normally; +the loader below mirrors the sibling test file. + +Run with: + python3 -m unittest -q utils/check-snapshot-anchors-test.py +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import pathlib +import sys +import tempfile +import unittest + +UTILS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = UTILS_DIR.parent +SCRIPT_SRC = UTILS_DIR / "check-snapshot-anchors.py" + + +def _load_module(): # type: ignore[no-untyped-def] + spec = importlib.util.spec_from_file_location("check_snapshot_anchors", SCRIPT_SRC) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +gate = _load_module() + + +class CharLiteralEndTest(unittest.TestCase): + """Ported from check-rustfmt-bail-test.py, which hit this first. + + Both directions are pinned deliberately: the pre-#1192 behaviour + (no char-literal branch at all) fails the quote-bearing cases, and a + *greedy* variant that treats a lifetime as a literal fails + ``test_lifetimes_and_labels_are_not_literals``. + """ + + def test_forms_and_their_ends(self) -> None: + for source, expected in [ + ("'a'", 3), + ("'_'", 3), + (r"'\n'", 4), + (r"'\''", 4), + (r"'\\'", 4), + ("'\"'", 3), + (r"'\u{1F600}'", 11), + # Rust's other multi-char escape, always two hex digits. The + # comment here used to claim the braced form was the only one. + (r"'\x41'", 6), + (r"'\x22'", 6), + ]: + with self.subTest(source=source): + self.assertEqual(gate.char_literal_end(source, 0), expected) + + def test_lifetimes_and_labels_are_not_literals(self) -> None: + for source in ["'a>", "'a,", "'static ", "'_>", "'outer: loop", "'"]: + with self.subTest(source=source): + self.assertIsNone(gate.char_literal_end(source, 0)) + + +class RawStringEndTest(unittest.TestCase): + def test_raw_and_byte_raw_forms(self) -> None: + for source, expected in [ + ('r"a"', 4), + ('r#"a"#', 6), + ('r##"a"##', 8), + ('br"a"', 5), + ('br#"a"#', 7), + ]: + with self.subTest(source=source): + self.assertEqual(gate.raw_string_end(source, 0), expected) + + def test_non_raw_openers_are_rejected(self) -> None: + # `b"…"` is deliberately not handled here: the `b` falls through + # as an ordinary character and the `"` opens a regular literal, + # which escapes identically. + for source in ['b"a"', "rust", "bar", "r", "b"]: + with self.subTest(source=source): + self.assertIsNone(gate.raw_string_end(source, 0)) + + +class CountBareTest(unittest.TestCase): + def test_char_literal_holding_a_quote_does_not_hide_a_later_call(self) -> None: + """The #1192 reproducer. + + Before the fix the `b'"'` opened a string span that ran to the + next `"` anywhere later in the file, so the bare call between + them was invisible and the gate reported zero. + """ + source = """\ +#[test] +fn t() { + let quote = b'"'; + insta::assert_json_snapshot!(metric.cognitive); + let _ = quote; +} +""" + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_lifetime_does_not_swallow_the_call_that_follows_it(self) -> None: + """Three lifetimes, not two, and a real char literal after the call. + + The count of quotes before the call is what makes this + discriminate. A *greedy* variant that scans from one `'` to the + next pairs them off, so with an even number of lifetimes ahead + of the call every span closes before reaching it and the test + passes against the bug. With three, the last lifetime pairs with + the `'z'` below and the span swallows the call: 1 -> 0. + """ + source = """\ +fn f<'a>(x: &'a str, y: &'a str) { + insta::assert_json_snapshot!(metric.x); + let c = 'z'; +} +""" + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_byte_raw_string_does_not_hide_a_later_call(self) -> None: + """A `br#"…"#` holding an unpaired quote leaves later calls visible. + + Honest caveat: this does **not** discriminate the `b`-prefix + branch of `raw_string_end`. Measured — with that branch disabled + the count is still 1, because the walk consumes the `b` as an + ordinary character and then recognises `r#"a"b"#` one index + later, producing the same span from a different start. Across + every `.rs` file in the workspace the `b` branch changes no + count at all; only `test_raw_and_byte_raw_forms` pins it. + + The case is kept because it pins the *scanner's* behaviour on a + real Rust spelling, which is worth having even though the branch + it looks like it guards is guarded elsewhere. + """ + source = 'let s = br#"a"b"#;\ninsta::assert_json_snapshot!(metric.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_block_comment_in_the_arguments_does_not_steal_a_later_anchor(self) -> None: + """`find_macro_call_end` skipped every literal kind but block comments. + + A `"` inside one opened a runaway string span, so the body scan + ran past the real closing paren into the next call and the bare + call claimed that call's `@"1"` as its own: 0 instead of 1. + """ + source = ( + 'insta::assert_json_snapshot!(m.x /* " */);\n' + 'insta::assert_json_snapshot!(m.y, @"1");\n' + ) + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_block_comment_in_the_arguments_does_not_truncate_the_body(self) -> None: + """The same gap in the other direction — a spurious gate failure. + + A `)` inside a block comment dropped the paren depth to zero + early, truncating the body before its real inline anchor, so an + anchored call counted as bare. + """ + source = 'insta::assert_json_snapshot!(m.x /* ) */, @"1");\n' + self.assertEqual(gate.count_bare_in_source(source), 0) + # Rust block comments nest; the body scan tracks depth. + nested = 'insta::assert_json_snapshot!(m.x /* a /* b */ c */, @"1");\n' + self.assertEqual(gate.count_bare_in_source(nested), 0) + + def test_escaped_quote_in_a_string_does_not_hide_a_later_call(self) -> None: + """Pins `regular_string_end`'s escape branch. + + Extracted as a named helper by #1192 and untested until now: + replacing it with a naive "find the next quote" makes this count + 0 instead of 1, and the rest of the suite stays green. + """ + source = 'let s = "a\\"b"; insta::assert_json_snapshot!(m.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_nested_block_comments_stay_commented_out(self) -> None: + """Pins the depth counter in `scan_ignore_spans`. + + Removing the depth-increment survives the rest of the suite; this + input is what discriminates it — the inner `*/` would otherwise + close the comment early and expose the call as live code. + """ + source = "/* outer /* inner */ insta::assert_json_snapshot!(m.x); */" + self.assertEqual(gate.count_bare_in_source(source), 0) + + def test_char_literal_in_the_arguments_does_not_borrow_a_later_anchor(self) -> None: + """`find_macro_call_end` carries the same lexer and the same gap. + + A quote-bearing char literal in the argument list ran the body + scan past the real closing paren and into the *next* call, whose + `@"…"` the bare call then claimed as its own anchor — so the + pair counted 0 instead of 1. The second call must be anchored + for this to discriminate; that is the anchor being stolen. + """ + source = ( + "insta::assert_json_snapshot!(m.x(b'\"'));\n" + 'insta::assert_json_snapshot!(m.y, @"1");\n' + ) + self.assertEqual(gate.count_bare_in_source(source), 1) + + def test_anchored_forms_stay_uncounted(self) -> None: + for name, source in [ + ("non-raw inline", 'insta::assert_json_snapshot!(m.x, @"123");'), + ("raw inline", 'insta::assert_json_snapshot!(m.x, @r###"1"###);'), + ("assert_eq above", "assert_eq!(m.branches(), 2);\ninsta::assert_json_snapshot!(m.x);"), + ("expected comment", "// expected: 2 branches\ninsta::assert_json_snapshot!(m.x);"), + ("line-commented out", "// insta::assert_json_snapshot!(m.x);\n"), + ("block-commented out", "/* insta::assert_json_snapshot!(m.x); */"), + ]: + with self.subTest(name=name): + self.assertEqual(gate.count_bare_in_source(source), 0) + + def test_bare_call_is_counted(self) -> None: + self.assertEqual(gate.count_bare_in_source("insta::assert_json_snapshot!(m.x);"), 1) + + def test_slashes_in_a_string_do_not_mask_a_bare_call(self) -> None: + source = 'let url = "http://x"; insta::assert_json_snapshot!(m.x);\n' + self.assertEqual(gate.count_bare_in_source(source), 1) + + +class BaselineTest(unittest.TestCase): + """`main()` over a synthetic tree: pass, regress and ratchet.""" + + def _run(self, argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + old = sys.argv + sys.argv = ["check-snapshot-anchors.py", *argv] + try: + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = gate.main() + finally: + sys.argv = old + return code, out.getvalue(), err.getvalue() + + def test_pass_regress_and_ratchet(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + target = root / "m.rs" + baseline = root / "baseline.txt" + target.write_text("insta::assert_json_snapshot!(m.x);\n", encoding="utf-8") + + code, _, _ = self._run([str(target), "--baseline", str(baseline), "--update"]) + self.assertEqual(code, 0) + # The parsed dict, not a substring: the written header carries + # "#1192" and "126", so `assertIn("1", …)` holds for an empty + # baseline too and pins nothing. + self.assertEqual(list(gate.load_baseline(baseline).values()), [1]) + + code, _, _ = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 0, "unchanged tree must pass") + + target.write_text( + "insta::assert_json_snapshot!(m.x);\ninsta::assert_json_snapshot!(m.y);\n", + encoding="utf-8", + ) + code, _, err = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 1, "an increase must fail") + self.assertIn("baseline 1, current 2", err) + + target.write_text('insta::assert_json_snapshot!(m.x, @"1");\n', encoding="utf-8") + code, _, _ = self._run([str(target), "--baseline", str(baseline)]) + self.assertEqual(code, 0, "a decrease must pass silently") + + def test_zero_counts_are_omitted_from_the_written_baseline(self) -> None: + """An unlisted file is allowed zero via `.get(rel, 0)`. + + Pinned because the omission is what keeps the baseline a list of + debt after #1192 made the scan recursive over 126 more files. + """ + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + clean = root / "clean.rs" + baseline = root / "baseline.txt" + clean.write_text("fn f() {}\n", encoding="utf-8") + + self._run([str(clean), "--baseline", str(baseline), "--update"]) + self.assertNotIn("clean.rs", baseline.read_text(encoding="utf-8")) + + clean.write_text("insta::assert_json_snapshot!(m.x);\n", encoding="utf-8") + code, _, err = self._run([str(clean), "--baseline", str(baseline)]) + self.assertEqual(code, 1, "an unlisted file must still be held at zero") + self.assertIn("baseline 0, current 1", err) + + +class RepositoryTest(unittest.TestCase): + def test_default_targets_reach_the_metric_subdirectories(self) -> None: + """#1192: the scan was non-recursive and missed 126 files. + + Asserts a positive property rather than a count, so splitting or + merging a per-language module cannot make it stale. + """ + targets = gate.default_targets() + self.assertTrue(targets, "no metric files found") + nested = [p for p in targets if p.parent != gate.METRICS_DIR] + self.assertTrue(nested, "subdirectories of src/metrics/ are unscanned") + + def test_repository_is_clean_against_its_baseline(self) -> None: + code, _, err = BaselineTest()._run([]) + self.assertEqual(code, 0, f"repository gate failed:\n{err}") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/check-snapshot-anchors.py b/utils/check-snapshot-anchors.py index 38c4ebc4..379ef5a7 100755 --- a/utils/check-snapshot-anchors.py +++ b/utils/check-snapshot-anchors.py @@ -46,14 +46,112 @@ LOOKBACK_LINES = 5 +def char_literal_end(source: str, i: int) -> int | None: + """End index (exclusive) of the char literal at ``i``, else ``None``. + + Rust spells lifetimes (``'a``), anonymous lifetimes (``'_``) and loop + labels (``'outer:``) with the same leading quote and no terminator, + so the two are told apart by looking for the closing ``'`` rather + than by the opener alone. Returning ``None`` for a lifetime is what + keeps it from opening a span that swallows the rest of the file. + + Shared in shape (and in tests) with ``check-rustfmt-bail.py``, which + hit the identical defect first (#1136). The scripts are hyphen-named + and so not importable, hence the copy rather than a shared module. + """ + n = len(source) + j = i + 1 + if j >= n: + return None + if source[j] == "\\": + j += 1 + if j >= n: + return None + if source[j] == "u": + # '\u{1F600}' — braced, variable length. + close = source.find("}", j) + if close == -1: + return None + j = close + 1 + elif source[j] == "x": + # '\x41' — always exactly two hex digits. Rust's other + # multi-char escape; missing it made `char_literal_end` + # return `None` for a valid literal. Benign for the count + # (an unrecognised literal opens no span, and both its quotes + # are then consumed as ordinary characters), but the comment + # that used to sit here claimed the braced form was the only + # one — which is what would let the next reader skip it when + # this lexer is copied again, as it already was from + # `check-rustfmt-bail.py`. + j += 3 + else: + j += 1 + else: + j += 1 + return j + 1 if j < n and source[j] == "'" else None + + +def raw_string_end(source: str, i: int) -> int | None: + """End index (exclusive) of the raw string at ``i``, else ``None``. + + Covers ``r"…"``, ``r#"…"#``, and the byte-string spellings ``br"…"`` + / ``br##"…"##``. A plain ``b"…"`` needs no special case: the ``b`` is + consumed as an ordinary character and the ``"`` that follows opens a + regular literal, which escapes identically. + """ + n = len(source) + j = i + if source[j] == "b" and j + 1 < n and source[j + 1] == "r": + j += 1 + if j >= n or source[j] != "r": + return None + j += 1 + hashes = 0 + while j < n and source[j] == "#": + hashes += 1 + j += 1 + if j >= n or source[j] != '"': + return None + close = '"' + ("#" * hashes) + end = source.find(close, j + 1) + return n if end == -1 else end + len(close) + + +def regular_string_end(source: str, i: int) -> int: + """End index (exclusive) of the ``"``-delimited literal at ``i``.""" + n = len(source) + j = i + 1 + while j < n: + if source[j] == "\\" and j + 1 < n: + j += 2 + continue + if source[j] == '"': + break + j += 1 + return j + 1 + + def find_macro_call_end(source: str, open_paren_idx: int) -> int: """Return the index *after* the closing ``)`` of a macro call. ``open_paren_idx`` must point at the ``(`` that opens the call. - Tracks parens, skipping string literals (including Rust raw strings - of the form ``r"…"``, ``r#"…"#``, …) and ``//`` line comments so - nested ``)`` inside the inline ``@r###"…"###`` anchor does not - confuse the depth counter. + Tracks parens, skipping string literals (regular, raw and byte), + char literals, ``//`` line comments and ``/* … */`` block comments so + a ``)`` inside the inline ``@r###"…"###`` anchor does not confuse the + depth counter. + + Char literals are skipped for the same reason ``scan_ignore_spans`` + skips them: a ``b'"'`` in the macro body would otherwise open a + bogus string span, run past the real closing paren, and hand back a + body the anchor test then reads the wrong answer out of. + + Block comments are skipped for the same reason again, in both + directions. A ``"`` inside one opened a runaway string span and the + body ran into the *next* call, whose ``@"…"`` the bare call then + claimed as its own anchor (under-count); a ``)`` inside one dropped + the depth to zero early and truncated the body before its real + anchor (over-count, i.e. a spurious CI failure). ``scan_ignore_spans`` + has always handled them; this sibling scanner did not. """ depth = 0 i = open_paren_idx @@ -65,29 +163,38 @@ def find_macro_call_end(source: str, open_paren_idx: int) -> int: nl = source.find("\n", i) i = n if nl == -1 else nl + 1 continue - # Raw string: r"…", r#"…"#, r##"…"##, … - if ch == "r" and i + 1 < n and source[i + 1] in ('"', "#"): - j = i + 1 - hashes = 0 - while j < n and source[j] == "#": - hashes += 1 - j += 1 - if j < n and source[j] == '"': - close = '"' + ("#" * hashes) - end = source.find(close, j + 1) - i = n if end == -1 else end + len(close) + # Block comment, which Rust allows to nest. + if ch == "/" and i + 1 < n and source[i + 1] == "*": + depth_comment = 1 + i += 2 + while i < n and depth_comment > 0: + if source[i] == "/" and i + 1 < n and source[i + 1] == "*": + depth_comment += 1 + i += 2 + continue + if source[i] == "*" and i + 1 < n and source[i + 1] == "/": + depth_comment -= 1 + i += 2 + continue + i += 1 + continue + # Raw / byte-raw string: r"…", r#"…"#, br##"…"##, … + if ch in "rb": + stop = raw_string_end(source, i) + if stop is not None: + i = stop + continue + # Char literal — or a lifetime, which `char_literal_end` rejects. + if ch == "'": + stop = char_literal_end(source, i) + if stop is not None: + i = stop continue + i += 1 + continue # Regular string literal. if ch == '"': - j = i + 1 - while j < n: - if source[j] == "\\" and j + 1 < n: - j += 2 - continue - if source[j] == '"': - break - j += 1 - i = j + 1 + i = regular_string_end(source, i) continue if ch == "(": depth += 1 @@ -104,11 +211,15 @@ class IgnoreSpans: A single source walk classifies every ``//`` line comment, every ``/* … */`` block comment (Rust block comments nest), and every - string literal (regular and Rust raw strings) into disjoint span - lists. Keeping them separate lets ``count_bare`` answer two distinct - questions with the same scan: *is this match inside a comment or - string?* (block + string spans) and *is the ``//`` before it a real - line comment, or just text inside a string?* (line-comment spans). + string or char literal into disjoint span lists. Keeping them + separate lets ``count_bare`` answer two distinct questions with the + same scan: *is this match inside a comment or literal?* (block + + literal spans) and *is the ``//`` before it a real line comment, or + just text inside a string?* (line-comment spans). + + Char literals share the ``strings`` list rather than getting a + fourth: no ``insta::assert_json_snapshot!`` can begin inside one, so + the only thing that matters is that the walk *consumes* them. """ def __init__(self) -> None: @@ -122,11 +233,19 @@ def scan_ignore_spans(source: str) -> IgnoreSpans: The walk skips string literals before testing for comment openers, so a ``//`` or ``/*`` that appears inside a string (or a ``"`` - inside a comment) is never misread. This is the single string-aware + inside a comment) is never misread. This is the single literal-aware scanner the bare-call counter relies on: over-counting a commented or quoted ``insta::assert_json_snapshot!`` occurrence produces a spurious gate failure, and under-counting (treating a ``//`` inside a string as a real comment) lets an unanchored snapshot through. + + Char literals are classified for one reason (#1192): a ``'"'`` or + ``b'"'`` holds an unpaired double quote, which would otherwise open + a bogus string span running to the next ``"`` anywhere later in the + file and hide every snapshot call in between. The gate would then + report zero for a file that really does carry a bare snapshot — the + exact "reads as clean" failure it exists to prevent, which must not + be its own failure mode. """ spans = IgnoreSpans() i = 0 @@ -157,31 +276,26 @@ def scan_ignore_spans(source: str) -> IgnoreSpans: i += 1 spans.block_comments.append((start, i)) continue - # Raw string: r"…", r#"…"#, … - if ch == "r" and i + 1 < n and source[i + 1] in ('"', "#"): - j = i + 1 - hashes = 0 - while j < n and source[j] == "#": - hashes += 1 - j += 1 - if j < n and source[j] == '"': - close = '"' + ("#" * hashes) - end = source.find(close, j + 1) - stop = n if end == -1 else end + len(close) + # Raw / byte-raw string: r"…", r#"…"#, br##"…"##, … + if ch in "rb": + stop = raw_string_end(source, i) + if stop is not None: + spans.strings.append((i, stop)) + i = stop + continue + # Char literal — or a lifetime, which `char_literal_end` rejects + # so it cannot open a span that swallows the rest of the file. + if ch == "'": + stop = char_literal_end(source, i) + if stop is not None: spans.strings.append((i, stop)) i = stop continue + i += 1 + continue # Regular string literal. if ch == '"': - j = i + 1 - while j < n: - if source[j] == "\\" and j + 1 < n: - j += 2 - continue - if source[j] == '"': - break - j += 1 - stop = j + 1 + stop = regular_string_end(source, i) spans.strings.append((i, stop)) i = stop continue @@ -244,11 +358,25 @@ def count_bare(path: pathlib.Path) -> int: return count_bare_in_source(path.read_text(encoding="utf-8")) +def baseline_key(path: pathlib.Path, root: pathlib.Path) -> str: + """The baseline's spelling of ``path``: repo-relative when it is under + ``root``, absolute otherwise. + + A named file outside the repository has no repo-relative spelling, and + ``Path.relative_to`` raises rather than saying so — an argument typo + then surfaces as a traceback instead of a count. Ported from + ``check-rustfmt-bail.py``, which fixed the same crash first. + """ + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + def collect_counts(files: list[pathlib.Path]) -> "OrderedDict[str, int]": counts: OrderedDict[str, int] = OrderedDict() for path in sorted(files): - rel = path.relative_to(REPO_ROOT).as_posix() - counts[rel] = count_bare(path) + counts[baseline_key(path, REPO_ROOT)] = count_bare(path) return counts @@ -274,15 +402,31 @@ def write_baseline(path: pathlib.Path, counts: dict[str, int]) -> None: "# Maintained by check-snapshot-anchors.py; see AGENTS.md.", "# Lower-or-equal current counts pass; any increase fails CI.", "# Regenerate with: ./utils/check-snapshot-anchors.py --update", + "#", + "# Only files with outstanding bare calls are listed. An unlisted", + "# file is allowed zero, which `load_baseline`'s `.get(rel, 0)`", + "# already encodes — so omitting them keeps this a list of debt", + "# rather than a census of every scanned file (#1192 made the", + "# scan recursive, which would otherwise have added 126 zeroes).", "", ] for rel, count in counts.items(): - body.append(f"{rel} {count}") + if count: + body.append(f"{rel} {count}") path.write_text("\n".join(body) + "\n", encoding="utf-8") def default_targets() -> list[pathlib.Path]: - return sorted(p for p in METRICS_DIR.glob("*.rs") if p.is_file()) + """Every Rust file under ``src/metrics/``, subdirectories included. + + ``rglob``, not ``glob``: the non-recursive form scanned only the 14 + top-level modules, so the 126 files under ``abc/``, ``cognitive/``, + ``cyclomatic/``, ``loc/``, ``npa/`` and ``npm/`` — created when the + per-language impls were split out (#969) — were invisible to the + gate. No bare call lives there today, but a new one would have + landed silently, which is the failure this gate exists to prevent. + """ + return sorted(p for p in METRICS_DIR.rglob("*.rs") if p.is_file()) def _self_test() -> int: @@ -375,18 +519,23 @@ def main() -> int: if args.update: write_baseline(args.baseline, counts) - print(f"Baseline updated: {args.baseline.relative_to(REPO_ROOT)}") + print(f"Baseline updated: {baseline_key(args.baseline, REPO_ROOT)}") for rel, count in counts.items(): print(f" {rel} {count}") return 0 - baseline = load_baseline(args.baseline) - if not baseline: + # Existence, not emptiness: since zero counts are omitted from the + # written baseline, a tree with no outstanding bare calls anywhere + # legitimately produces a file of nothing but comments. Testing the + # parsed dict for truthiness would report that clean state as a + # missing baseline and fail the gate with exit 2. + if not args.baseline.exists(): sys.stderr.write( - f"error: baseline file not found or empty: {args.baseline}\n" + f"error: baseline file not found: {args.baseline}\n" " run with --update to create it.\n" ) return 2 + baseline = load_baseline(args.baseline) regressions: list[tuple[str, int, int]] = [] for rel, count in counts.items():