cov-loupe 3.0.0 → 4.0.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +230 -0
- data/CLAUDE.md +5 -0
- data/CODE_OF_CONDUCT.md +62 -0
- data/CONTRIBUTING.md +102 -0
- data/GEMINI.md +5 -0
- data/README.md +154 -51
- data/RELEASE_NOTES.md +452 -0
- data/dev/images/cov-loupe-icon-lores.png +0 -0
- data/dev/images/cov-loupe-icon-square.png +0 -0
- data/dev/images/cov-loupe-icon.png +0 -0
- data/dev/images/cov-loupe-logo.png +0 -0
- data/dev/prompts/README.md +74 -0
- data/dev/prompts/archive/architectural-review-and-actions-prompt.md +53 -0
- data/dev/prompts/archive/investigate-and-report-issues-prompt.md +33 -0
- data/dev/prompts/archive/produce-action-items-prompt.md +25 -0
- data/dev/prompts/guidelines/ai-code-evaluator-guidelines.md +337 -0
- data/dev/prompts/improve/refactor-test-suite.md +18 -0
- data/dev/prompts/improve/simplify-code-logic.md +133 -0
- data/dev/prompts/improve/update-documentation.md +21 -0
- data/dev/prompts/review/comprehensive-codebase-review.md +176 -0
- data/dev/prompts/review/identify-action-items.md +143 -0
- data/dev/prompts/review/verify-code-changes.md +54 -0
- data/dev/prompts/validate/create-screencast-outline.md +234 -0
- data/dev/prompts/validate/test-documentation-examples.md +180 -0
- data/docs/QUICKSTART.md +63 -0
- data/docs/assets/images/cov-loupe-logo-lores.png +0 -0
- data/docs/assets/images/cov-loupe-logo.png +0 -0
- data/docs/assets/images/favicon.png +0 -0
- data/docs/assets/stylesheets/branding.css +16 -0
- data/docs/assets/stylesheets/extra.css +15 -0
- data/docs/code_of_conduct.md +1 -0
- data/docs/contributing.md +1 -0
- data/docs/dev/ARCHITECTURE.md +56 -11
- data/docs/dev/DEVELOPMENT.md +116 -12
- data/docs/dev/FUTURE_ENHANCEMENTS.md +14 -0
- data/docs/dev/README.md +3 -2
- data/docs/dev/RELEASING.md +2 -0
- data/docs/dev/arch-decisions/README.md +10 -7
- data/docs/dev/arch-decisions/application-architecture.md +259 -0
- data/docs/dev/arch-decisions/coverage-data-quality.md +193 -0
- data/docs/dev/arch-decisions/output-character-mode.md +217 -0
- data/docs/dev/arch-decisions/path-resolution.md +90 -0
- data/docs/dev/arch-decisions/{004-x-arch-decision.md → policy-validation.md} +32 -28
- data/docs/dev/arch-decisions/{005-x-arch-decision.md → simplecov-integration.md} +47 -44
- data/docs/dev/presentations/cov-loupe-presentation.md +15 -13
- data/docs/examples/mcp-inputs.md +3 -0
- data/docs/examples/prompts.md +3 -0
- data/docs/examples/success_predicates.md +3 -0
- data/docs/fixtures/demo_project/.resultset.json +170 -0
- data/docs/fixtures/demo_project/README.md +6 -0
- data/docs/fixtures/demo_project/app/controllers/admin/audit_logs_controller.rb +19 -0
- data/docs/fixtures/demo_project/app/controllers/orders_controller.rb +26 -0
- data/docs/fixtures/demo_project/app/models/order.rb +20 -0
- data/docs/fixtures/demo_project/app/models/user.rb +19 -0
- data/docs/fixtures/demo_project/lib/api/client.rb +22 -0
- data/docs/fixtures/demo_project/lib/ops/jobs/cleanup_job.rb +16 -0
- data/docs/fixtures/demo_project/lib/ops/jobs/report_job.rb +17 -0
- data/docs/fixtures/demo_project/lib/payments/processor.rb +15 -0
- data/docs/fixtures/demo_project/lib/payments/refund_service.rb +15 -0
- data/docs/fixtures/demo_project/lib/payments/reporting/exporter.rb +16 -0
- data/docs/index.md +1 -0
- data/docs/license.md +3 -0
- data/docs/release_notes.md +3 -0
- data/docs/user/ADVANCED_USAGE.md +208 -115
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +2 -0
- data/docs/user/CLI_USAGE.md +276 -101
- data/docs/user/ERROR_HANDLING.md +4 -4
- data/docs/user/EXAMPLES.md +121 -128
- data/docs/user/INSTALLATION.md +9 -28
- data/docs/user/LIBRARY_API.md +227 -122
- data/docs/user/MCP_INTEGRATION.md +114 -203
- data/docs/user/README.md +5 -1
- data/docs/user/TROUBLESHOOTING.md +49 -27
- data/docs/user/installing-a-prelease-version-of-covloupe.md +43 -0
- data/docs/user/{V2-BREAKING-CHANGES.md → migrations/MIGRATING_TO_V2.md} +62 -72
- data/docs/user/migrations/MIGRATING_TO_V3.md +72 -0
- data/docs/user/migrations/MIGRATING_TO_V4.md +591 -0
- data/docs/user/migrations/README.md +22 -0
- data/docs/user/prompts/README.md +9 -0
- data/docs/user/prompts/non-web-coverage-analysis-prompt.md +103 -0
- data/docs/user/prompts/rails-coverage-analysis-prompt.md +94 -0
- data/docs/user/prompts/use-cli-not-mcp-prompt.md +53 -0
- data/examples/cli_demo.sh +77 -0
- data/examples/filter_and_table_demo-output.md +114 -0
- data/examples/filter_and_table_demo.rb +174 -0
- data/examples/fixtures/demo_project/coverage/.resultset.json +10 -0
- data/examples/mcp-inputs/README.md +66 -0
- data/examples/mcp-inputs/coverage_detailed.json +1 -0
- data/examples/mcp-inputs/coverage_raw.json +1 -0
- data/examples/mcp-inputs/coverage_summary.json +1 -0
- data/examples/mcp-inputs/list.json +1 -0
- data/examples/mcp-inputs/uncovered_lines.json +1 -0
- data/examples/prompts/README.md +27 -0
- data/examples/prompts/custom_resultset.txt +2 -0
- data/examples/prompts/detailed_with_source.txt +2 -0
- data/examples/prompts/list_lowest.txt +2 -0
- data/examples/prompts/summary.txt +2 -0
- data/examples/prompts/uncovered.txt +2 -0
- data/examples/success_predicates/README.md +198 -0
- data/examples/success_predicates/all_files_above_threshold_predicate.rb +21 -0
- data/examples/success_predicates/directory_specific_thresholds_predicate.rb +30 -0
- data/examples/success_predicates/project_coverage_minimum_predicate.rb +6 -0
- data/lib/cov_loupe/base_tool.rb +229 -20
- data/lib/cov_loupe/cli.rb +132 -23
- data/lib/cov_loupe/commands/base_command.rb +25 -6
- data/lib/cov_loupe/commands/command_factory.rb +0 -1
- data/lib/cov_loupe/commands/detailed_command.rb +10 -5
- data/lib/cov_loupe/commands/list_command.rb +2 -1
- data/lib/cov_loupe/commands/raw_command.rb +7 -5
- data/lib/cov_loupe/commands/summary_command.rb +12 -7
- data/lib/cov_loupe/commands/totals_command.rb +74 -10
- data/lib/cov_loupe/commands/uncovered_command.rb +7 -5
- data/lib/cov_loupe/commands/validate_command.rb +11 -3
- data/lib/cov_loupe/commands/version_command.rb +6 -4
- data/lib/cov_loupe/{app_config.rb → config/app_config.rb} +13 -5
- data/lib/cov_loupe/config/app_context.rb +43 -0
- data/lib/cov_loupe/config/boolean_type.rb +91 -0
- data/lib/cov_loupe/config/logger.rb +92 -0
- data/lib/cov_loupe/{option_normalizers.rb → config/option_normalizers.rb} +55 -24
- data/lib/cov_loupe/{option_parser_builder.rb → config/option_parser_builder.rb} +46 -24
- data/lib/cov_loupe/coverage/coverage_calculator.rb +53 -0
- data/lib/cov_loupe/coverage/coverage_reporter.rb +63 -0
- data/lib/cov_loupe/coverage/coverage_table_formatter.rb +133 -0
- data/lib/cov_loupe/{error_handler.rb → errors/error_handler.rb} +21 -33
- data/lib/cov_loupe/{errors.rb → errors/errors.rb} +48 -71
- data/lib/cov_loupe/formatters/formatters.rb +75 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +18 -7
- data/lib/cov_loupe/formatters/table_formatter.rb +80 -0
- data/lib/cov_loupe/loaders/all.rb +15 -0
- data/lib/cov_loupe/loaders/all_cli.rb +10 -0
- data/lib/cov_loupe/loaders/all_mcp.rb +23 -0
- data/lib/cov_loupe/loaders/resultset_loader.rb +147 -0
- data/lib/cov_loupe/mcp_server.rb +3 -2
- data/lib/cov_loupe/model/model.rb +520 -0
- data/lib/cov_loupe/model/model_data.rb +13 -0
- data/lib/cov_loupe/model/model_data_cache.rb +116 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +17 -6
- data/lib/cov_loupe/option_parsers/error_helper.rb +16 -10
- data/lib/cov_loupe/output_chars.rb +192 -0
- data/lib/cov_loupe/paths/glob_utils.rb +100 -0
- data/lib/cov_loupe/{path_relativizer.rb → paths/path_relativizer.rb} +5 -13
- data/lib/cov_loupe/paths/path_utils.rb +265 -0
- data/lib/cov_loupe/paths/volume_case_sensitivity.rb +173 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +9 -13
- data/lib/cov_loupe/presenters/coverage_payload_presenter.rb +21 -0
- data/lib/cov_loupe/presenters/payload_caching.rb +23 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +73 -21
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +16 -10
- data/lib/cov_loupe/repositories/coverage_repository.rb +149 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +90 -76
- data/lib/cov_loupe/resolvers/{resolver_factory.rb → resolver_helpers.rb} +6 -5
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +40 -12
- data/lib/cov_loupe/scripts/command_execution.rb +113 -0
- data/lib/cov_loupe/scripts/latest_ci_status.rb +97 -0
- data/lib/cov_loupe/scripts/pre_release_check.rb +164 -0
- data/lib/cov_loupe/scripts/setup_doc_server.rb +23 -0
- data/lib/cov_loupe/scripts/start_doc_server.rb +24 -0
- data/lib/cov_loupe/staleness/stale_status.rb +23 -0
- data/lib/cov_loupe/staleness/staleness_checker.rb +328 -0
- data/lib/cov_loupe/staleness/staleness_message_formatter.rb +91 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +14 -15
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +14 -14
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +16 -16
- data/lib/cov_loupe/tools/coverage_table_tool.rb +139 -21
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +31 -13
- data/lib/cov_loupe/tools/help_tool.rb +16 -20
- data/lib/cov_loupe/tools/list_tool.rb +65 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +14 -14
- data/lib/cov_loupe/tools/validate_tool.rb +18 -24
- data/lib/cov_loupe/tools/version_tool.rb +8 -3
- data/lib/cov_loupe/version.rb +1 -1
- data/lib/cov_loupe.rb +83 -55
- metadata +184 -154
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +0 -158
- data/docs/dev/arch-decisions/001-x-arch-decision.md +0 -95
- data/docs/dev/arch-decisions/002-x-arch-decision.md +0 -159
- data/docs/dev/arch-decisions/003-x-arch-decision.md +0 -165
- data/lib/cov_loupe/app_context.rb +0 -26
- data/lib/cov_loupe/constants.rb +0 -22
- data/lib/cov_loupe/coverage_reporter.rb +0 -31
- data/lib/cov_loupe/formatters.rb +0 -51
- data/lib/cov_loupe/mode_detector.rb +0 -56
- data/lib/cov_loupe/model.rb +0 -339
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +0 -14
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +0 -14
- data/lib/cov_loupe/resultset_loader.rb +0 -131
- data/lib/cov_loupe/staleness_checker.rb +0 -247
- data/lib/cov_loupe/table_formatter.rb +0 -64
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +0 -51
- data/lib/cov_loupe/util.rb +0 -88
- data/spec/MCP_INTEGRATION_TESTS_README.md +0 -111
- data/spec/TIMESTAMPS.md +0 -48
- data/spec/all_files_coverage_tool_spec.rb +0 -53
- data/spec/app_config_spec.rb +0 -142
- data/spec/base_tool_spec.rb +0 -62
- data/spec/cli/show_default_report_spec.rb +0 -33
- data/spec/cli_enumerated_options_spec.rb +0 -90
- data/spec/cli_error_spec.rb +0 -184
- data/spec/cli_format_spec.rb +0 -123
- data/spec/cli_json_options_spec.rb +0 -50
- data/spec/cli_source_spec.rb +0 -44
- data/spec/cli_spec.rb +0 -192
- data/spec/cli_table_spec.rb +0 -28
- data/spec/cli_usage_spec.rb +0 -42
- data/spec/commands/base_command_spec.rb +0 -107
- data/spec/commands/command_factory_spec.rb +0 -76
- data/spec/commands/detailed_command_spec.rb +0 -34
- data/spec/commands/list_command_spec.rb +0 -28
- data/spec/commands/raw_command_spec.rb +0 -69
- data/spec/commands/summary_command_spec.rb +0 -34
- data/spec/commands/totals_command_spec.rb +0 -34
- data/spec/commands/uncovered_command_spec.rb +0 -55
- data/spec/commands/validate_command_spec.rb +0 -213
- data/spec/commands/version_command_spec.rb +0 -38
- data/spec/constants_spec.rb +0 -61
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +0 -267
- data/spec/cov_loupe/formatters_spec.rb +0 -76
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +0 -79
- data/spec/cov_loupe_model_spec.rb +0 -454
- data/spec/cov_loupe_module_spec.rb +0 -37
- data/spec/cov_loupe_opts_spec.rb +0 -185
- data/spec/coverage_reporter_spec.rb +0 -102
- data/spec/coverage_table_tool_spec.rb +0 -59
- data/spec/coverage_totals_tool_spec.rb +0 -37
- data/spec/error_handler_spec.rb +0 -197
- data/spec/error_mode_spec.rb +0 -139
- data/spec/errors_edge_cases_spec.rb +0 -312
- data/spec/errors_stale_spec.rb +0 -83
- data/spec/file_based_mcp_tools_spec.rb +0 -99
- data/spec/help_tool_spec.rb +0 -26
- data/spec/integration_spec.rb +0 -789
- data/spec/logging_fallback_spec.rb +0 -128
- data/spec/mcp_logging_spec.rb +0 -44
- data/spec/mcp_server_integration_spec.rb +0 -23
- data/spec/mcp_server_spec.rb +0 -106
- data/spec/mode_detector_spec.rb +0 -153
- data/spec/model_error_handling_spec.rb +0 -269
- data/spec/model_staleness_spec.rb +0 -79
- data/spec/option_normalizers_spec.rb +0 -203
- data/spec/option_parsers/env_options_parser_spec.rb +0 -221
- data/spec/option_parsers/error_helper_spec.rb +0 -222
- data/spec/path_relativizer_spec.rb +0 -98
- data/spec/presenters/coverage_detailed_presenter_spec.rb +0 -19
- data/spec/presenters/coverage_raw_presenter_spec.rb +0 -15
- data/spec/presenters/coverage_summary_presenter_spec.rb +0 -15
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +0 -16
- data/spec/presenters/project_coverage_presenter_spec.rb +0 -87
- data/spec/presenters/project_totals_presenter_spec.rb +0 -144
- data/spec/resolvers/coverage_line_resolver_spec.rb +0 -282
- data/spec/resolvers/resolver_factory_spec.rb +0 -61
- data/spec/resolvers/resultset_path_resolver_spec.rb +0 -60
- data/spec/resultset_loader_spec.rb +0 -167
- data/spec/shared_examples/README.md +0 -115
- data/spec/shared_examples/coverage_presenter_examples.rb +0 -66
- data/spec/shared_examples/file_based_mcp_tools.rb +0 -179
- data/spec/shared_examples/formatted_command_examples.rb +0 -64
- data/spec/shared_examples/mcp_tool_text_json_response.rb +0 -16
- data/spec/spec_helper.rb +0 -127
- data/spec/staleness_checker_spec.rb +0 -374
- data/spec/staleness_more_spec.rb +0 -42
- data/spec/support/cli_helpers.rb +0 -22
- data/spec/support/control_flow_helpers.rb +0 -20
- data/spec/support/fake_mcp.rb +0 -40
- data/spec/support/io_helpers.rb +0 -29
- data/spec/support/mcp_helpers.rb +0 -35
- data/spec/support/mcp_runner.rb +0 -66
- data/spec/support/mocking_helpers.rb +0 -30
- data/spec/table_format_spec.rb +0 -70
- data/spec/tools/validate_tool_spec.rb +0 -132
- data/spec/tools_error_handling_spec.rb +0 -130
- data/spec/util_spec.rb +0 -154
- data/spec/version_spec.rb +0 -123
- data/spec/version_tool_spec.rb +0 -141
- /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/bar.rb +0 -0
- /data/{spec/fixtures/project1 → examples/fixtures/demo_project}/lib/foo.rb +0 -0
- /data/lib/cov_loupe/{config_parser.rb → config/config_parser.rb} +0 -0
- /data/lib/cov_loupe/{predicate_evaluator.rb → config/predicate_evaluator.rb} +0 -0
- /data/lib/cov_loupe/{error_handler_factory.rb → errors/error_handler_factory.rb} +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby >= 3.4 requires explicit require for set; RuboCop targets 3.2
|
|
6
|
+
|
|
7
|
+
require_relative '../errors/errors'
|
|
8
|
+
require_relative '../errors/error_handler'
|
|
9
|
+
require_relative '../staleness/staleness_checker'
|
|
10
|
+
require_relative '../staleness/stale_status'
|
|
11
|
+
require_relative '../paths/path_relativizer'
|
|
12
|
+
require_relative '../loaders/resultset_loader'
|
|
13
|
+
require_relative '../coverage/coverage_table_formatter'
|
|
14
|
+
require_relative '../coverage/coverage_calculator'
|
|
15
|
+
require_relative '../resolvers/resolver_helpers'
|
|
16
|
+
require_relative '../paths/glob_utils'
|
|
17
|
+
require_relative '../model/model_data_cache'
|
|
18
|
+
require_relative '../paths/path_utils'
|
|
19
|
+
|
|
20
|
+
module CovLoupe
|
|
21
|
+
class CoverageModel
|
|
22
|
+
RELATIVIZER_SCALAR_KEYS = %w[file file_path].freeze
|
|
23
|
+
RELATIVIZER_ARRAY_KEYS = %w[
|
|
24
|
+
newer_files
|
|
25
|
+
deleted_files
|
|
26
|
+
missing_tracked_files
|
|
27
|
+
skipped_files
|
|
28
|
+
length_mismatch_files
|
|
29
|
+
unreadable_files
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
DEFAULT_SORT_ORDER = :descending
|
|
33
|
+
|
|
34
|
+
attr_reader :relativizer, :skipped_rows, :volume_case_sensitive
|
|
35
|
+
|
|
36
|
+
# Create a CoverageModel
|
|
37
|
+
#
|
|
38
|
+
# Params:
|
|
39
|
+
# - root: project root directory (default '.')
|
|
40
|
+
# - resultset: path or directory to .resultset.json
|
|
41
|
+
# - raise_on_stale: boolean (default false). When true, raises
|
|
42
|
+
# stale errors if sources are newer than coverage or line counts mismatch.
|
|
43
|
+
# - tracked_globs: array of glob patterns (default []). Used for filtering and tracking.
|
|
44
|
+
# - logger: logger instance (defaults to CovLoupe.logger)
|
|
45
|
+
def initialize(root: '.', resultset: nil, raise_on_stale: false, tracked_globs: [],
|
|
46
|
+
logger: nil)
|
|
47
|
+
@root = File.expand_path(root || '.')
|
|
48
|
+
@resultset_arg = resultset
|
|
49
|
+
@default_tracked_globs = tracked_globs
|
|
50
|
+
@skipped_rows = []
|
|
51
|
+
@logger = logger || CovLoupe.logger
|
|
52
|
+
@relativizer = PathRelativizer.new(
|
|
53
|
+
root: @root,
|
|
54
|
+
scalar_keys: RELATIVIZER_SCALAR_KEYS,
|
|
55
|
+
array_keys: RELATIVIZER_ARRAY_KEYS
|
|
56
|
+
)
|
|
57
|
+
@default_raise_on_stale = raise_on_stale
|
|
58
|
+
@resolved_resultset_path = nil # Resolved on first fetch
|
|
59
|
+
|
|
60
|
+
# Eagerly validate resultset exists and load initial data
|
|
61
|
+
# This matches original behavior and surfaces errors immediately
|
|
62
|
+
begin
|
|
63
|
+
data = fetch_data
|
|
64
|
+
@resultset_path = data.resultset_path
|
|
65
|
+
rescue CovLoupe::Error
|
|
66
|
+
raise # Re-raise our own errors as-is
|
|
67
|
+
rescue => e
|
|
68
|
+
raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Compute volume case sensitivity based on this model's root directory
|
|
72
|
+
# This is not cached because different models may use the same resultset
|
|
73
|
+
# with different root directories on different volumes
|
|
74
|
+
@volume_case_sensitive = PathUtils.volume_case_sensitive?(@root)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [hits|nil,...] }
|
|
78
|
+
def raw_for(path, raise_on_stale: @default_raise_on_stale)
|
|
79
|
+
file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
|
|
80
|
+
{ 'file' => file_abs, 'lines' => coverage_lines }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def relativize(payload)
|
|
84
|
+
relativizer.relativize(payload)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns { 'file' => <absolute_path>, 'summary' => {'covered'=>, 'total'=>, 'percentage'=>} }
|
|
88
|
+
def summary_for(path, raise_on_stale: @default_raise_on_stale)
|
|
89
|
+
file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
|
|
90
|
+
{ 'file' => file_abs, 'summary' => CoverageCalculator.summary(coverage_lines) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Returns { 'file' => <absolute_path>, 'uncovered' => [line,...], 'summary' => {...} }
|
|
94
|
+
def uncovered_for(path, raise_on_stale: @default_raise_on_stale)
|
|
95
|
+
file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
|
|
96
|
+
{
|
|
97
|
+
'file' => file_abs,
|
|
98
|
+
'uncovered' => CoverageCalculator.uncovered(coverage_lines),
|
|
99
|
+
'summary' => CoverageCalculator.summary(coverage_lines)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns { 'file' => <absolute_path>, 'lines' => [{'line'=>,'hits'=>,'covered'=>},...], 'summary' => {...} }
|
|
104
|
+
def detailed_for(path, raise_on_stale: @default_raise_on_stale)
|
|
105
|
+
file_abs, coverage_lines = coverage_data_for(path, raise_on_stale: raise_on_stale)
|
|
106
|
+
{
|
|
107
|
+
'file' => file_abs,
|
|
108
|
+
'lines' => CoverageCalculator.detailed(coverage_lines),
|
|
109
|
+
'summary' => CoverageCalculator.summary(coverage_lines)
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns [ { 'file' =>, 'covered' =>, 'total' =>, 'percentage' =>, 'stale' => }, ... ]
|
|
114
|
+
def list(sort_order: DEFAULT_SORT_ORDER,
|
|
115
|
+
raise_on_stale: @default_raise_on_stale,
|
|
116
|
+
tracked_globs: @default_tracked_globs)
|
|
117
|
+
@skipped_rows = []
|
|
118
|
+
# Build rows in lenient mode to collect all data even if some files have errors
|
|
119
|
+
# This ensures staleness checking can examine all files, not just the ones before
|
|
120
|
+
# the first error. We'll re-raise any errors after staleness checking if needed.
|
|
121
|
+
rows, coverage_lines_by_path = build_list_rows(
|
|
122
|
+
tracked_globs: tracked_globs,
|
|
123
|
+
raise_on_stale: false # Always use lenient mode for row building
|
|
124
|
+
)
|
|
125
|
+
project_staleness_details = project_staleness_report(
|
|
126
|
+
tracked_globs: tracked_globs,
|
|
127
|
+
raise_on_stale: raise_on_stale, # Honor raise_on_stale for staleness checks
|
|
128
|
+
coverage_lines_by_path: coverage_lines_by_path
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# If raise_on_stale is true and there were any skipped files with errors,
|
|
132
|
+
# raise the first error encountered after staleness checking is complete
|
|
133
|
+
if raise_on_stale && @skipped_rows.any?
|
|
134
|
+
first_error = @skipped_rows.first
|
|
135
|
+
error_class = Object.const_get(first_error['error_class'])
|
|
136
|
+
raise error_class, first_error['error']
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
file_statuses = project_staleness_details[:file_statuses] || {}
|
|
140
|
+
length_mismatch_files = Array(project_staleness_details[:length_mismatch_files]).uniq
|
|
141
|
+
unreadable_files = Array(project_staleness_details[:unreadable_files]).uniq
|
|
142
|
+
rows.each do |row|
|
|
143
|
+
row['stale'] = file_statuses.fetch(row['file'], 'ok')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
'files' => sort_rows(rows, sort_order: sort_order),
|
|
148
|
+
'skipped_files' => filter_rows_by_globs(@skipped_rows, tracked_globs),
|
|
149
|
+
'missing_tracked_files' => project_staleness_details[:missing_files],
|
|
150
|
+
'newer_files' => project_staleness_details[:newer_files],
|
|
151
|
+
'deleted_files' => project_staleness_details[:deleted_files],
|
|
152
|
+
'length_mismatch_files' => length_mismatch_files,
|
|
153
|
+
'unreadable_files' => unreadable_files,
|
|
154
|
+
'timestamp_status' => project_staleness_details[:timestamp_status]
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def project_totals(
|
|
159
|
+
tracked_globs: @default_tracked_globs, raise_on_stale: @default_raise_on_stale
|
|
160
|
+
)
|
|
161
|
+
list_result = list(sort_order: :ascending, raise_on_stale: raise_on_stale,
|
|
162
|
+
tracked_globs: tracked_globs)
|
|
163
|
+
|
|
164
|
+
rows = list_result['files']
|
|
165
|
+
|
|
166
|
+
included_rows = rows.reject { |row| StaleStatus.stale?(row['stale']) }
|
|
167
|
+
line_totals = line_totals_from_rows(included_rows)
|
|
168
|
+
|
|
169
|
+
tracking = tracking_payload(tracked_globs)
|
|
170
|
+
with_coverage = with_coverage_payload(rows)
|
|
171
|
+
without_coverage = without_coverage_payload(list_result, tracking['enabled'])
|
|
172
|
+
files = files_payload(with_coverage, without_coverage)
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
'lines' => line_totals,
|
|
176
|
+
'tracking' => tracking,
|
|
177
|
+
'files' => files,
|
|
178
|
+
'timestamp_status' => list_result['timestamp_status']
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def staleness_for(path)
|
|
183
|
+
file_abs = File.expand_path(path, @root)
|
|
184
|
+
coverage_lines = Resolvers::ResolverHelpers.lookup_lines(coverage_map, file_abs, root: @root,
|
|
185
|
+
volume_case_sensitive: volume_case_sensitive)
|
|
186
|
+
build_staleness_checker(raise_on_stale: false, tracked_globs: nil)
|
|
187
|
+
.file_staleness_status(file_abs, coverage_lines)
|
|
188
|
+
rescue => e
|
|
189
|
+
@logger.safe_log("Failed to check staleness for #{path}: #{e.message}")
|
|
190
|
+
'error'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Returns formatted table string for all files coverage data
|
|
194
|
+
# Delegates to CoverageTableFormatter for presentation logic
|
|
195
|
+
#
|
|
196
|
+
# @param rows [Array<Hash>, nil] Pre-computed rows, or nil to compute from coverage data
|
|
197
|
+
# @param sort_order [Symbol] Sort order (:ascending or :descending)
|
|
198
|
+
# @param raise_on_stale [Boolean] Whether to raise on stale coverage data
|
|
199
|
+
# @param tracked_globs [Array<String>, nil] Glob patterns for tracked files
|
|
200
|
+
# @param output_chars [Symbol] Output character mode (:default, :fancy, :ascii)
|
|
201
|
+
# @return [String] Formatted table
|
|
202
|
+
def format_table(rows = nil, sort_order: DEFAULT_SORT_ORDER,
|
|
203
|
+
raise_on_stale: @default_raise_on_stale,
|
|
204
|
+
tracked_globs: @default_tracked_globs,
|
|
205
|
+
output_chars: :default)
|
|
206
|
+
rows = prepare_rows(rows, sort_order: sort_order, raise_on_stale: raise_on_stale,
|
|
207
|
+
tracked_globs: tracked_globs)
|
|
208
|
+
CoverageTableFormatter.format(rows, output_chars: output_chars)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Lazily resolves the resultset path on first access
|
|
212
|
+
private def resolved_resultset_path
|
|
213
|
+
@resolved_resultset_path ||= Resolvers::ResolverHelpers.find_resultset(
|
|
214
|
+
@root, resultset: @resultset_arg
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Fetches current ModelData from the shared cache
|
|
219
|
+
# The cache automatically reloads if the resultset file has changed
|
|
220
|
+
private def fetch_data
|
|
221
|
+
ModelDataCache.instance.get(resolved_resultset_path, root: @root, logger: @logger)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Returns the coverage map by delegating to ModelDataCache.
|
|
225
|
+
# The cache automatically reloads if the resultset file has changed.
|
|
226
|
+
private def coverage_map
|
|
227
|
+
fetch_data.coverage_map
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Returns the timestamp by delegating to ModelDataCache.
|
|
231
|
+
# The cache automatically reloads if the resultset file has changed.
|
|
232
|
+
private def coverage_timestamp
|
|
233
|
+
fetch_data.timestamp
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Clears the resolved resultset path to allow re-resolution.
|
|
237
|
+
# ModelDataCache automatically handles resultset file changes on each access,
|
|
238
|
+
# so explicit refresh is rarely needed. This method is primarily for testing.
|
|
239
|
+
def refresh_data
|
|
240
|
+
@resolved_resultset_path = nil
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private def build_staleness_checker(raise_on_stale:, tracked_globs:)
|
|
245
|
+
StalenessChecker.new(
|
|
246
|
+
root: @root,
|
|
247
|
+
resultset: resolved_resultset_path,
|
|
248
|
+
mode: raise_on_stale ? :error : :off,
|
|
249
|
+
tracked_globs: tracked_globs,
|
|
250
|
+
timestamp: coverage_timestamp
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private def build_list_rows(tracked_globs:, raise_on_stale:)
|
|
255
|
+
coverage_lines_by_path = {}
|
|
256
|
+
rows = coverage_map.filter_map do |abs_path, entry|
|
|
257
|
+
# Extract lines directly from the entry to avoid O(n^2) resolver scans
|
|
258
|
+
coverage_lines = coverage_lines_for_listing(abs_path, entry, raise_on_stale)
|
|
259
|
+
next unless coverage_lines
|
|
260
|
+
|
|
261
|
+
coverage_lines_by_path[abs_path] = coverage_lines
|
|
262
|
+
summary = CoverageCalculator.summary(coverage_lines)
|
|
263
|
+
{
|
|
264
|
+
'file' => abs_path,
|
|
265
|
+
'covered' => summary['covered'],
|
|
266
|
+
'total' => summary['total'],
|
|
267
|
+
'percentage' => summary['percentage'],
|
|
268
|
+
|
|
269
|
+
# We set 'stale' => 'ok' as a placeholder, then in list we overwrite it
|
|
270
|
+
# with the true status from the project report.
|
|
271
|
+
'stale' => 'ok'
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
[filter_rows_by_globs(rows, tracked_globs), coverage_lines_by_path]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private def coverage_lines_for_listing(abs_path, entry, raise_on_stale)
|
|
279
|
+
# Try to extract lines directly from the entry (O(1) operation)
|
|
280
|
+
# Only fall back to resolver if the entry is malformed
|
|
281
|
+
lines = extract_lines_from_entry(entry)
|
|
282
|
+
return lines if lines
|
|
283
|
+
|
|
284
|
+
# Fallback to resolver for malformed entries
|
|
285
|
+
Resolvers::ResolverHelpers.lookup_lines(coverage_map, abs_path, root: @root,
|
|
286
|
+
volume_case_sensitive: volume_case_sensitive)
|
|
287
|
+
rescue FileError, CoverageDataError => e
|
|
288
|
+
# When raise_on_stale is true, raise all errors immediately for strict validation
|
|
289
|
+
# When false, skip files with errors and report them in skipped_files for lenient mode
|
|
290
|
+
raise e if raise_on_stale
|
|
291
|
+
|
|
292
|
+
@logger.safe_log("Skipping coverage row for #{abs_path}: #{e.message}")
|
|
293
|
+
@skipped_rows << {
|
|
294
|
+
'file' => abs_path,
|
|
295
|
+
'error' => e.message,
|
|
296
|
+
'error_class' => e.class.name
|
|
297
|
+
}
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private def project_staleness_report(tracked_globs:, raise_on_stale:, coverage_lines_by_path:)
|
|
302
|
+
# Filter coverage files to match the same scope as tracked_globs
|
|
303
|
+
coverage_files = GlobUtils.filter_paths(coverage_map.keys, tracked_globs, root: @root)
|
|
304
|
+
|
|
305
|
+
# Filter coverage_lines_by_path to the same scope to ensure length-mismatch
|
|
306
|
+
# checks only apply to files within the tracked_globs scope
|
|
307
|
+
coverage_files_set = coverage_files.to_set
|
|
308
|
+
scoped_coverage_lines = coverage_lines_by_path.slice(*coverage_files_set)
|
|
309
|
+
|
|
310
|
+
build_staleness_checker(
|
|
311
|
+
raise_on_stale: raise_on_stale, tracked_globs: tracked_globs
|
|
312
|
+
).check_project_with_lines!(scoped_coverage_lines, coverage_files: coverage_files)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private def prepare_rows(rows, sort_order:, raise_on_stale:, tracked_globs:)
|
|
316
|
+
files = rows || list(sort_order: sort_order, raise_on_stale: raise_on_stale,
|
|
317
|
+
tracked_globs: tracked_globs)['files']
|
|
318
|
+
|
|
319
|
+
files = sort_rows(files.dup, sort_order: sort_order)
|
|
320
|
+
filter_rows_by_globs(files, tracked_globs)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private def sort_rows(rows, sort_order: :descending)
|
|
324
|
+
percent_comparator = sort_order == :descending \
|
|
325
|
+
? ->(left, right) { right <=> left }
|
|
326
|
+
: ->(left, right) { left <=> right }
|
|
327
|
+
|
|
328
|
+
nil_comparator = ->(left, right) do
|
|
329
|
+
left_nil = left['percentage'].nil?
|
|
330
|
+
right_nil = right['percentage'].nil?
|
|
331
|
+
return 0 if left_nil == right_nil
|
|
332
|
+
|
|
333
|
+
if sort_order == :descending
|
|
334
|
+
left_nil ? -1 : 1
|
|
335
|
+
else
|
|
336
|
+
left_nil ? 1 : -1
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
comparator = ->(a, b) do
|
|
341
|
+
nil_comparison = nil_comparator.call(a, b)
|
|
342
|
+
return nil_comparison unless nil_comparison.zero?
|
|
343
|
+
|
|
344
|
+
if !a['percentage'].nil? && !b['percentage'].nil?
|
|
345
|
+
percent_comp_result = percent_comparator.(a['percentage'], b['percentage'])
|
|
346
|
+
return percent_comp_result if percent_comp_result != 0
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
a['file'] <=> b['file']
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
rows.sort { |a, b| comparator.(a, b) }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Filters coverage rows to only include files matching the given glob patterns.
|
|
356
|
+
#
|
|
357
|
+
# @param rows [Array<Hash>] coverage rows with 'file' keys containing absolute paths
|
|
358
|
+
# @param tracked_globs [Array<String>, String, nil] glob patterns to match against
|
|
359
|
+
# @return [Array<Hash>] rows whose files match at least one pattern (or all rows if no patterns)
|
|
360
|
+
private def filter_rows_by_globs(rows, tracked_globs)
|
|
361
|
+
patterns = GlobUtils.normalize_patterns(tracked_globs)
|
|
362
|
+
return rows if patterns.empty?
|
|
363
|
+
|
|
364
|
+
absolute_patterns = patterns.map { |p| GlobUtils.absolutize_pattern(p, @root) }
|
|
365
|
+
GlobUtils.filter_by_pattern(rows, absolute_patterns)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Retrieves coverage data for a file path.
|
|
369
|
+
# Converts the path to absolute form and performs staleness checking if enabled.
|
|
370
|
+
#
|
|
371
|
+
# @param path [String] relative or absolute file path
|
|
372
|
+
# @return [Array(String, Array)] tuple of [absolute_path, coverage_lines]
|
|
373
|
+
# @raise [FileError] if no coverage data exists for the file
|
|
374
|
+
# @raise [FileNotFoundError] if the file does not exist and raise_on_stale is true
|
|
375
|
+
# @raise [CoverageDataStaleError] if staleness checking is enabled and data is stale
|
|
376
|
+
private def coverage_data_for(path, raise_on_stale: @default_raise_on_stale)
|
|
377
|
+
file_abs = File.expand_path(path, @root)
|
|
378
|
+
coverage_lines = Resolvers::ResolverHelpers.lookup_lines(coverage_map, file_abs, root: @root,
|
|
379
|
+
volume_case_sensitive: volume_case_sensitive)
|
|
380
|
+
|
|
381
|
+
if coverage_lines.nil?
|
|
382
|
+
raise FileError, "No coverage data found for file: #{path}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# When raise_on_stale is false, allow missing files to return coverage data
|
|
386
|
+
# The staleness status will be added by the presenter via staleness_for
|
|
387
|
+
if raise_on_stale && !File.file?(file_abs)
|
|
388
|
+
raise FileNotFoundError, "File not found: #{path}"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
checker = build_staleness_checker(raise_on_stale: raise_on_stale, tracked_globs: nil)
|
|
392
|
+
checker.check_file!(file_abs, coverage_lines) unless checker.off?
|
|
393
|
+
|
|
394
|
+
[file_abs, coverage_lines]
|
|
395
|
+
rescue Errno::ENOENT
|
|
396
|
+
raise FileNotFoundError, "File not found: #{path}" if raise_on_stale
|
|
397
|
+
|
|
398
|
+
[file_abs, coverage_lines]
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
private def line_totals_from_rows(rows)
|
|
402
|
+
covered = rows.sum { |row| row['covered'].to_i }
|
|
403
|
+
total = rows.sum { |row| row['total'].to_i }
|
|
404
|
+
uncovered = total - covered
|
|
405
|
+
percent_covered = total.zero? ? nil : ((covered.to_f * 100.0 / total) * 100).round / 100.0
|
|
406
|
+
|
|
407
|
+
{
|
|
408
|
+
'covered' => covered,
|
|
409
|
+
'uncovered' => uncovered,
|
|
410
|
+
'total' => total,
|
|
411
|
+
'percent_covered' => percent_covered
|
|
412
|
+
}
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
private def tracking_payload(tracked_globs)
|
|
416
|
+
patterns = GlobUtils.normalize_patterns(tracked_globs)
|
|
417
|
+
{
|
|
418
|
+
'enabled' => patterns.any?,
|
|
419
|
+
'globs' => patterns
|
|
420
|
+
}
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
private def with_coverage_payload(rows)
|
|
424
|
+
breakdown = stale_breakdown(rows)
|
|
425
|
+
stale_by_type = breakdown[:stale_by_type]
|
|
426
|
+
stale_total = stale_by_type.values.sum
|
|
427
|
+
|
|
428
|
+
{
|
|
429
|
+
'total' => rows.length,
|
|
430
|
+
'ok' => breakdown[:ok],
|
|
431
|
+
'stale' => {
|
|
432
|
+
'total' => stale_total,
|
|
433
|
+
'by_type' => stale_by_type
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
private def without_coverage_payload(list_result, tracking_enabled)
|
|
439
|
+
return nil unless tracking_enabled
|
|
440
|
+
|
|
441
|
+
missing_from_coverage = Array(list_result['missing_tracked_files']).length
|
|
442
|
+
skipped = Array(list_result['skipped_files']).length
|
|
443
|
+
unreadable = Array(list_result['unreadable_files']).length
|
|
444
|
+
by_type = {
|
|
445
|
+
'missing_from_coverage' => missing_from_coverage,
|
|
446
|
+
'unreadable' => unreadable,
|
|
447
|
+
'skipped' => skipped
|
|
448
|
+
}
|
|
449
|
+
{
|
|
450
|
+
'total' => by_type.values.sum,
|
|
451
|
+
'by_type' => by_type
|
|
452
|
+
}
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
private def files_payload(with_coverage, without_coverage)
|
|
456
|
+
total = with_coverage['total']
|
|
457
|
+
total += without_coverage['total'] if without_coverage
|
|
458
|
+
|
|
459
|
+
files = {
|
|
460
|
+
'total' => total,
|
|
461
|
+
'with_coverage' => with_coverage
|
|
462
|
+
}
|
|
463
|
+
files['without_coverage'] = without_coverage if without_coverage
|
|
464
|
+
files
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
private def stale_breakdown(rows)
|
|
468
|
+
stale_by_type = {
|
|
469
|
+
'missing_from_disk' => 0,
|
|
470
|
+
'newer' => 0,
|
|
471
|
+
'length_mismatch' => 0,
|
|
472
|
+
'unreadable' => 0
|
|
473
|
+
}
|
|
474
|
+
ok_files = 0
|
|
475
|
+
|
|
476
|
+
rows.each do |row|
|
|
477
|
+
case row['stale']
|
|
478
|
+
when 'ok'
|
|
479
|
+
ok_files += 1
|
|
480
|
+
when 'missing'
|
|
481
|
+
stale_by_type['missing_from_disk'] += 1
|
|
482
|
+
when 'newer'
|
|
483
|
+
stale_by_type['newer'] += 1
|
|
484
|
+
when 'length_mismatch'
|
|
485
|
+
stale_by_type['length_mismatch'] += 1
|
|
486
|
+
when 'error'
|
|
487
|
+
stale_by_type['unreadable'] += 1
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
{
|
|
492
|
+
ok: ok_files,
|
|
493
|
+
stale_by_type: stale_by_type
|
|
494
|
+
}
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Extract coverage lines from a SimpleCov entry.
|
|
498
|
+
# Returns nil if the entry is not a valid Hash, does not contain a lines array,
|
|
499
|
+
# or contains invalid elements. Invalid entries trigger fallback to the resolver,
|
|
500
|
+
# which performs centralized validation and error handling.
|
|
501
|
+
#
|
|
502
|
+
# @param entry [Hash, Object] coverage entry from the resultset
|
|
503
|
+
# @return [Array<Integer, nil>, nil] SimpleCov-style line coverage array or nil
|
|
504
|
+
private def extract_lines_from_entry(entry)
|
|
505
|
+
return unless entry.is_a?(Hash)
|
|
506
|
+
|
|
507
|
+
lines = entry['lines']
|
|
508
|
+
unless lines.is_a?(Array)
|
|
509
|
+
@logger.safe_log("Invalid coverage lines encountered (not an array): #{lines.class}") if lines
|
|
510
|
+
return nil
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Validate all elements - return nil to trigger resolver fallback on validation failure
|
|
514
|
+
# The resolver will raise CoverageDataError with a detailed message
|
|
515
|
+
return nil unless lines.all? { |v| v.nil? || v.is_a?(Integer) }
|
|
516
|
+
|
|
517
|
+
lines
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Immutable data container for coverage data loaded from a specific resultset file.
|
|
5
|
+
# Holds the normalized coverage map, timestamp, and resultset path.
|
|
6
|
+
#
|
|
7
|
+
# This class has no awareness of caching - it's managed by ModelDataCache.
|
|
8
|
+
#
|
|
9
|
+
# @attr_reader coverage_map [Hash] Map of absolute file paths to coverage data
|
|
10
|
+
# @attr_reader timestamp [Integer] Latest timestamp from coverage suites
|
|
11
|
+
# @attr_reader resultset_path [String] Absolute path to the .resultset.json file
|
|
12
|
+
ModelData = Data.define(:coverage_map, :timestamp, :resultset_path)
|
|
13
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'model_data'
|
|
5
|
+
require_relative '../repositories/coverage_repository'
|
|
6
|
+
|
|
7
|
+
module CovLoupe
|
|
8
|
+
# Thread-safe singleton cache for ModelData instances.
|
|
9
|
+
# Entries are keyed by [resultset_path, root] and automatically invalidated when the file changes.
|
|
10
|
+
#
|
|
11
|
+
# On every get() call, the cache checks the resultset file's signature (mtime/size/inode)
|
|
12
|
+
# and digest (MD5) to ensure the data is current. If the file has changed, fresh data
|
|
13
|
+
# is loaded automatically.
|
|
14
|
+
#
|
|
15
|
+
# The cache key includes both resultset_path and root because path normalization and
|
|
16
|
+
# case-sensitivity detection depend on the root directory. Two models with the same
|
|
17
|
+
# resultset but different roots may have different normalized coverage maps.
|
|
18
|
+
class ModelDataCache
|
|
19
|
+
# Mutex for thread-safe singleton initialization.
|
|
20
|
+
# Using a constant ensures it cannot be reset, avoiding race conditions in JRuby.
|
|
21
|
+
INSTANCE_MUTEX = Mutex.new
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@entries = {}
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns the singleton instance with thread-safe initialization
|
|
29
|
+
def self.instance
|
|
30
|
+
INSTANCE_MUTEX.synchronize do
|
|
31
|
+
@instance ||= new
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Fetches ModelData for the given resultset path.
|
|
36
|
+
# Checks signature/digest on every call and reloads if the file has changed.
|
|
37
|
+
#
|
|
38
|
+
# @param resultset_path [String] Absolute path to .resultset.json
|
|
39
|
+
# @param root [String] Project root directory for path normalization
|
|
40
|
+
# @param logger [Logger, nil] Logger instance for data loading operations
|
|
41
|
+
# @return [ModelData] The cached or freshly loaded data
|
|
42
|
+
def get(resultset_path, root:, logger: nil)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
# Cache key must include both resultset_path and root because
|
|
45
|
+
# path normalization and case-sensitivity depend on the root
|
|
46
|
+
cache_key = [resultset_path, root]
|
|
47
|
+
entry = @entries[cache_key]
|
|
48
|
+
|
|
49
|
+
# Compute current signature and digest
|
|
50
|
+
signature = compute_signature(resultset_path)
|
|
51
|
+
digest = compute_digest(resultset_path)
|
|
52
|
+
|
|
53
|
+
# Return cached data if it matches
|
|
54
|
+
if entry && signature && digest &&
|
|
55
|
+
entry[:signature] == signature &&
|
|
56
|
+
entry[:digest] == digest
|
|
57
|
+
return entry[:data]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Load fresh data using the provided logger
|
|
61
|
+
data = load_data(resultset_path, root, logger)
|
|
62
|
+
|
|
63
|
+
# Store with signature/digest if we computed them
|
|
64
|
+
if signature && digest
|
|
65
|
+
@entries[cache_key] = {
|
|
66
|
+
data: data,
|
|
67
|
+
signature: signature,
|
|
68
|
+
digest: digest
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
data
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Clears all cached entries (primarily for testing)
|
|
77
|
+
def clear
|
|
78
|
+
@mutex.synchronize { @entries.clear }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private def load_data(resultset_path, root, logger)
|
|
82
|
+
repo = Repositories::CoverageRepository.new(
|
|
83
|
+
root: root,
|
|
84
|
+
resultset_path: resultset_path,
|
|
85
|
+
logger: logger || CovLoupe.logger
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
ModelData.new(
|
|
89
|
+
coverage_map: repo.coverage_map,
|
|
90
|
+
timestamp: repo.timestamp,
|
|
91
|
+
resultset_path: resultset_path
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private def compute_signature(resultset_path)
|
|
96
|
+
stat = File.stat(resultset_path)
|
|
97
|
+
{
|
|
98
|
+
mtime: stat.mtime,
|
|
99
|
+
mtime_nsec: stat.respond_to?(:mtime_nsec) ? stat.mtime_nsec : stat.mtime.nsec,
|
|
100
|
+
size: stat.size,
|
|
101
|
+
inode: stat.respond_to?(:ino) ? stat.ino : nil
|
|
102
|
+
}.compact
|
|
103
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Compute a fast digest of the resultset file.
|
|
108
|
+
# Uses MD5 which is fast and sufficient for cache validation
|
|
109
|
+
# (we don't need cryptographic security, just change detection).
|
|
110
|
+
private def compute_digest(resultset_path)
|
|
111
|
+
Digest::MD5.file(resultset_path).hexdigest
|
|
112
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'shellwords'
|
|
4
|
-
require_relative '../option_normalizers'
|
|
4
|
+
require_relative '../config/option_normalizers'
|
|
5
5
|
|
|
6
6
|
module CovLoupe
|
|
7
7
|
module OptionParsers
|
|
@@ -24,15 +24,26 @@ module CovLoupe
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def pre_scan_error_mode(argv, error_mode_normalizer: method(:normalize_error_mode))
|
|
27
|
-
# Quick scan for --error-mode to ensure early errors are logged correctly
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
# Quick scan for --error-mode or -e to ensure early errors are logged correctly.
|
|
28
|
+
# Scan in reverse so that the last occurrence (CLI) overrides earlier ones (ENV).
|
|
29
|
+
i = argv.length - 1
|
|
30
|
+
while i >= 0
|
|
31
|
+
arg = argv[i]
|
|
32
|
+
if %w[--error-mode -e].include?(arg) && argv[i + 1]
|
|
30
33
|
return error_mode_normalizer.call(argv[i + 1])
|
|
34
|
+
|
|
31
35
|
elsif arg.start_with?('--error-mode=')
|
|
32
36
|
value = arg.split('=', 2)[1]
|
|
33
|
-
return
|
|
34
|
-
|
|
37
|
+
return error_mode_normalizer.call(value)
|
|
38
|
+
|
|
39
|
+
elsif arg.start_with?('-e') && arg.length > 2
|
|
40
|
+
# Handle attached short option: -edebug
|
|
41
|
+
value = arg[2..]
|
|
42
|
+
return error_mode_normalizer.call(value)
|
|
43
|
+
|
|
35
44
|
end
|
|
45
|
+
|
|
46
|
+
i -= 1
|
|
36
47
|
end
|
|
37
48
|
nil
|
|
38
49
|
rescue
|