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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
# Handles detection and caching of filesystem volume case sensitivity.
|
|
7
|
+
# Provides thread-safe case sensitivity detection with caching for performance.
|
|
8
|
+
module VolumeCaseSensitivity
|
|
9
|
+
# Mutex for thread-safe cache access
|
|
10
|
+
CACHE_MUTEX = Mutex.new
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Detects whether the volume at the given path is case-sensitive.
|
|
14
|
+
# Prefer using an existing file (via File.identical?) to avoid writing;
|
|
15
|
+
# fall back to a temporary file if no suitable file exists.
|
|
16
|
+
#
|
|
17
|
+
# This method caches results by path to avoid repeated filesystem checks,
|
|
18
|
+
# which can be expensive, especially on network-mounted volumes.
|
|
19
|
+
#
|
|
20
|
+
# @param path [String, nil] directory path to test (defaults to current directory)
|
|
21
|
+
# @return [Boolean] true if case-sensitive, false if case-insensitive or on error
|
|
22
|
+
def volume_case_sensitive?(path = nil)
|
|
23
|
+
require 'securerandom'
|
|
24
|
+
|
|
25
|
+
test_path = path ? File.absolute_path(path) : Dir.pwd
|
|
26
|
+
abs_path = File.absolute_path(test_path)
|
|
27
|
+
|
|
28
|
+
# Check cache first (thread-safe read)
|
|
29
|
+
cached_value = get_from_cache(abs_path)
|
|
30
|
+
return cached_value unless cached_value.nil?
|
|
31
|
+
|
|
32
|
+
# Return false if directory doesn't exist
|
|
33
|
+
return false unless File.directory?(abs_path)
|
|
34
|
+
|
|
35
|
+
result = detect_case_sensitivity?(abs_path)
|
|
36
|
+
|
|
37
|
+
# Store result in cache (thread-safe write)
|
|
38
|
+
set_in_cache(abs_path, result)
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
rescue SystemCallError, IOError
|
|
42
|
+
# Can't detect from filesystem, assume case-insensitive to be conservative
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clears the case sensitivity cache (useful for testing)
|
|
47
|
+
#
|
|
48
|
+
# @return [void]
|
|
49
|
+
def clear_cache
|
|
50
|
+
CACHE_MUTEX.synchronize do
|
|
51
|
+
@cache = {}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the current cache contents (useful for testing)
|
|
56
|
+
#
|
|
57
|
+
# @return [Hash] cache contents
|
|
58
|
+
def cache
|
|
59
|
+
CACHE_MUTEX.synchronize do
|
|
60
|
+
@cache ||= {}
|
|
61
|
+
@cache.dup
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Retrieves a value from the cache (thread-safe)
|
|
66
|
+
#
|
|
67
|
+
# @param abs_path [String] absolute path to look up
|
|
68
|
+
# @return [Boolean, nil] cached value or nil if not found
|
|
69
|
+
def get_from_cache(abs_path)
|
|
70
|
+
CACHE_MUTEX.synchronize do
|
|
71
|
+
@cache ||= {}
|
|
72
|
+
@cache[abs_path]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Stores a value in the cache (thread-safe)
|
|
77
|
+
#
|
|
78
|
+
# @param abs_path [String] absolute path to cache
|
|
79
|
+
# @param value [Boolean] value to cache
|
|
80
|
+
# @return [void]
|
|
81
|
+
def set_in_cache(abs_path, value)
|
|
82
|
+
CACHE_MUTEX.synchronize do
|
|
83
|
+
@cache ||= {}
|
|
84
|
+
@cache[abs_path] = value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Detects case sensitivity for a given directory
|
|
89
|
+
#
|
|
90
|
+
# @param abs_path [String] absolute path to directory
|
|
91
|
+
# @return [Boolean] true if case-sensitive, false if case-insensitive
|
|
92
|
+
def detect_case_sensitivity?(abs_path)
|
|
93
|
+
# Try to use an existing file to avoid filesystem writes
|
|
94
|
+
existing_file = find_existing_file(abs_path)
|
|
95
|
+
|
|
96
|
+
if existing_file
|
|
97
|
+
detect_case_sensitive_using_existing_file?(abs_path, existing_file)
|
|
98
|
+
else
|
|
99
|
+
detect_case_sensitive_using_temp_file?(abs_path)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Finds an existing file in the directory suitable for case sensitivity testing
|
|
104
|
+
#
|
|
105
|
+
# @param abs_path [String] absolute path to directory
|
|
106
|
+
# @return [String, nil] filename or nil if no suitable file found
|
|
107
|
+
def find_existing_file(abs_path)
|
|
108
|
+
Dir.children(abs_path).find do |name|
|
|
109
|
+
name.match?(/[A-Za-z]/) && File.file?(File.join(abs_path, name))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Detects case sensitivity using an existing file in the directory
|
|
114
|
+
#
|
|
115
|
+
# @param abs_path [String] absolute path to directory
|
|
116
|
+
# @param existing_file [String] name of existing file
|
|
117
|
+
# @return [Boolean] true if case-sensitive, false if case-insensitive
|
|
118
|
+
def detect_case_sensitive_using_existing_file?(abs_path, existing_file)
|
|
119
|
+
require 'securerandom'
|
|
120
|
+
|
|
121
|
+
original = File.join(abs_path, existing_file)
|
|
122
|
+
alternate_name = existing_file.tr('A-Za-z', 'a-zA-Z')
|
|
123
|
+
alternate = File.join(abs_path, alternate_name)
|
|
124
|
+
|
|
125
|
+
if File.exist?(alternate)
|
|
126
|
+
# Same file -> case-insensitive, different files -> case-sensitive
|
|
127
|
+
!File.identical?(original, alternate)
|
|
128
|
+
else
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Detects case sensitivity using a temporary test file
|
|
134
|
+
#
|
|
135
|
+
# @param abs_path [String] absolute path to directory
|
|
136
|
+
# @return [Boolean] true if case-sensitive, false if case-insensitive
|
|
137
|
+
def detect_case_sensitive_using_temp_file?(abs_path)
|
|
138
|
+
require 'securerandom'
|
|
139
|
+
|
|
140
|
+
# Create a temporary test file with a unique name
|
|
141
|
+
test_file = generate_unique_test_filename(abs_path)
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
FileUtils.touch(test_file)
|
|
145
|
+
variants = [test_file, test_file.upcase, test_file.downcase]
|
|
146
|
+
# Test if exactly one variant exists (case-sensitive) vs all exist (case-insensitive)
|
|
147
|
+
variants.one? { |variant| File.exist?(variant) }
|
|
148
|
+
ensure
|
|
149
|
+
# Clean up all potential variants
|
|
150
|
+
[test_file, test_file.upcase, test_file.downcase].each do |variant|
|
|
151
|
+
FileUtils.rm_f(variant)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Generates a unique test filename that doesn't conflict with existing files
|
|
157
|
+
#
|
|
158
|
+
# @param abs_path [String] absolute path to directory
|
|
159
|
+
# @return [String] unique filename path
|
|
160
|
+
def generate_unique_test_filename(abs_path)
|
|
161
|
+
require 'securerandom'
|
|
162
|
+
|
|
163
|
+
test_file = nil
|
|
164
|
+
while test_file.nil?
|
|
165
|
+
candidate = File.join(abs_path, "CovLoupe_CaseSensitivity_Test_#{SecureRandom.hex(16)}.tmp")
|
|
166
|
+
variants = [candidate, candidate.upcase, candidate.downcase]
|
|
167
|
+
test_file = candidate if variants.none? { |v| File.exist?(v) }
|
|
168
|
+
end
|
|
169
|
+
test_file
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'payload_caching'
|
|
4
|
+
|
|
3
5
|
module CovLoupe
|
|
4
6
|
module Presenters
|
|
5
7
|
# Shared presenter behavior for single-file coverage payloads.
|
|
6
8
|
class BaseCoveragePresenter
|
|
9
|
+
include PayloadCaching
|
|
10
|
+
|
|
7
11
|
attr_reader :model, :path
|
|
8
12
|
|
|
9
13
|
def initialize(model:, path:)
|
|
@@ -11,19 +15,6 @@ module CovLoupe
|
|
|
11
15
|
@path = path
|
|
12
16
|
end
|
|
13
17
|
|
|
14
|
-
# Returns the absolute-path payload augmented with stale metadata.
|
|
15
|
-
def absolute_payload
|
|
16
|
-
@absolute_payload ||= begin
|
|
17
|
-
payload = build_payload
|
|
18
|
-
payload.merge('stale' => model.staleness_for(path))
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Returns the payload with file paths relativized for presentation.
|
|
23
|
-
def relativized_payload
|
|
24
|
-
@relativized_payload ||= model.relativize(absolute_payload)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
18
|
# Returns the cached stale status for the file.
|
|
28
19
|
def stale
|
|
29
20
|
absolute_payload['stale']
|
|
@@ -34,6 +25,11 @@ module CovLoupe
|
|
|
34
25
|
relativized_payload['file']
|
|
35
26
|
end
|
|
36
27
|
|
|
28
|
+
private def compute_absolute_payload
|
|
29
|
+
payload = build_payload
|
|
30
|
+
payload.merge('stale' => model.staleness_for(path))
|
|
31
|
+
end
|
|
32
|
+
|
|
37
33
|
private def build_payload
|
|
38
34
|
raise NotImplementedError, "#{self.class} must implement #build_payload"
|
|
39
35
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base_coverage_presenter'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Presenters
|
|
7
|
+
# Provides shared single-file coverage payloads for CLI and MCP callers.
|
|
8
|
+
class CoveragePayloadPresenter < BaseCoveragePresenter
|
|
9
|
+
def initialize(model:, path:, payload_method:, raise_on_stale: nil)
|
|
10
|
+
super(model: model, path: path)
|
|
11
|
+
@payload_method = payload_method
|
|
12
|
+
@raise_on_stale = raise_on_stale
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private def build_payload
|
|
16
|
+
args = { raise_on_stale: @raise_on_stale }.compact
|
|
17
|
+
model.public_send(@payload_method, path, **args)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
module Presenters
|
|
5
|
+
# Shared memoization logic for coverage payloads.
|
|
6
|
+
module PayloadCaching
|
|
7
|
+
# Returns the absolute-path payload.
|
|
8
|
+
# Consumers must implement #compute_absolute_payload.
|
|
9
|
+
def absolute_payload
|
|
10
|
+
@absolute_payload ||= compute_absolute_payload
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the payload with file paths relativized for presentation.
|
|
14
|
+
def relativized_payload
|
|
15
|
+
@relativized_payload ||= model.relativize(absolute_payload)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private def compute_absolute_payload
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #compute_absolute_payload"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -1,35 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'payload_caching'
|
|
4
|
+
|
|
5
|
+
require_relative '../staleness/stale_status'
|
|
6
|
+
|
|
3
7
|
module CovLoupe
|
|
4
8
|
module Presenters
|
|
5
9
|
# Provides repository-wide coverage summaries shared by CLI and MCP surfaces.
|
|
6
10
|
class ProjectCoveragePresenter
|
|
7
|
-
|
|
11
|
+
include PayloadCaching
|
|
12
|
+
|
|
13
|
+
attr_reader :model, :sort_order, :raise_on_stale, :tracked_globs
|
|
8
14
|
|
|
9
|
-
def initialize(model:, sort_order:,
|
|
15
|
+
def initialize(model:, sort_order:, raise_on_stale:, tracked_globs:)
|
|
10
16
|
@model = model
|
|
11
17
|
@sort_order = sort_order
|
|
12
|
-
@
|
|
18
|
+
@raise_on_stale = raise_on_stale
|
|
13
19
|
@tracked_globs = tracked_globs
|
|
14
20
|
end
|
|
15
21
|
|
|
16
|
-
# Returns the absolute-path payload including counts.
|
|
17
|
-
def absolute_payload
|
|
18
|
-
@absolute_payload ||= begin
|
|
19
|
-
files = model.all_files(
|
|
20
|
-
sort_order: sort_order,
|
|
21
|
-
check_stale: check_stale,
|
|
22
|
-
tracked_globs: tracked_globs
|
|
23
|
-
)
|
|
24
|
-
{ 'files' => files, 'counts' => build_counts(files) }
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Returns the payload with file paths relativized for presentation.
|
|
29
|
-
def relativized_payload
|
|
30
|
-
@relativized_payload ||= model.relativize(absolute_payload)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
22
|
# Returns the relativized file rows.
|
|
34
23
|
def relative_files
|
|
35
24
|
relativized_payload['files']
|
|
@@ -40,9 +29,72 @@ module CovLoupe
|
|
|
40
29
|
relativized_payload['counts']
|
|
41
30
|
end
|
|
42
31
|
|
|
32
|
+
# Returns the relativized skipped files.
|
|
33
|
+
def relative_skipped_files
|
|
34
|
+
relativized_payload['skipped_files']
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns the relativized missing tracked files.
|
|
38
|
+
def relative_missing_tracked_files
|
|
39
|
+
relativized_payload['missing_tracked_files']
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the relativized newer files.
|
|
43
|
+
def relative_newer_files
|
|
44
|
+
relativized_payload['newer_files']
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the relativized deleted files.
|
|
48
|
+
def relative_deleted_files
|
|
49
|
+
relativized_payload['deleted_files']
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns the relativized length-mismatch files.
|
|
53
|
+
def relative_length_mismatch_files
|
|
54
|
+
relativized_payload['length_mismatch_files']
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns the relativized unreadable files.
|
|
58
|
+
def relative_unreadable_files
|
|
59
|
+
relativized_payload['unreadable_files']
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the timestamp status indicating whether coverage timestamps are available.
|
|
63
|
+
# Can be 'ok' (timestamps available) or 'missing' (no timestamps, staleness checks skipped).
|
|
64
|
+
def timestamp_status
|
|
65
|
+
relativized_payload['timestamp_status']
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private def compute_absolute_payload
|
|
69
|
+
list_result = model.list(
|
|
70
|
+
sort_order: sort_order,
|
|
71
|
+
raise_on_stale: raise_on_stale,
|
|
72
|
+
tracked_globs: tracked_globs
|
|
73
|
+
)
|
|
74
|
+
files = list_result['files']
|
|
75
|
+
skipped_files = list_result['skipped_files']
|
|
76
|
+
missing_tracked_files = list_result['missing_tracked_files']
|
|
77
|
+
newer_files = list_result['newer_files']
|
|
78
|
+
deleted_files = list_result['deleted_files']
|
|
79
|
+
length_mismatch_files = list_result['length_mismatch_files']
|
|
80
|
+
unreadable_files = list_result['unreadable_files']
|
|
81
|
+
timestamp_status = list_result['timestamp_status']
|
|
82
|
+
{
|
|
83
|
+
'files' => files,
|
|
84
|
+
'skipped_files' => skipped_files,
|
|
85
|
+
'missing_tracked_files' => missing_tracked_files,
|
|
86
|
+
'newer_files' => newer_files,
|
|
87
|
+
'deleted_files' => deleted_files,
|
|
88
|
+
'length_mismatch_files' => length_mismatch_files,
|
|
89
|
+
'unreadable_files' => unreadable_files,
|
|
90
|
+
'timestamp_status' => timestamp_status,
|
|
91
|
+
'counts' => build_counts(files)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
43
95
|
private def build_counts(files)
|
|
44
96
|
total = files.length
|
|
45
|
-
stale = files.count { |f| f['stale'] }
|
|
97
|
+
stale = files.count { |f| StaleStatus.stale?(f['stale']) }
|
|
46
98
|
{ 'total' => total, 'ok' => total - stale, 'stale' => stale }
|
|
47
99
|
end
|
|
48
100
|
end
|
|
@@ -1,26 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'payload_caching'
|
|
4
|
+
|
|
3
5
|
module CovLoupe
|
|
4
6
|
module Presenters
|
|
5
7
|
# Provides aggregated line totals and average coverage across the project.
|
|
6
8
|
class ProjectTotalsPresenter
|
|
7
|
-
|
|
9
|
+
include PayloadCaching
|
|
10
|
+
|
|
11
|
+
attr_reader :model, :raise_on_stale, :tracked_globs
|
|
8
12
|
|
|
9
|
-
def initialize(model:,
|
|
13
|
+
def initialize(model:, raise_on_stale:, tracked_globs:)
|
|
10
14
|
@model = model
|
|
11
|
-
@
|
|
15
|
+
@raise_on_stale = raise_on_stale
|
|
12
16
|
@tracked_globs = tracked_globs
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
19
|
+
# Returns the timestamp status indicating whether coverage timestamps are available.
|
|
20
|
+
# Can be 'ok' (timestamps available) or 'missing' (no timestamps, staleness checks skipped).
|
|
21
|
+
def timestamp_status
|
|
22
|
+
relativized_payload['timestamp_status']
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
def
|
|
23
|
-
|
|
25
|
+
private def compute_absolute_payload
|
|
26
|
+
model.project_totals(
|
|
27
|
+
tracked_globs: tracked_globs,
|
|
28
|
+
raise_on_stale: raise_on_stale
|
|
29
|
+
)
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../resolvers/resolver_helpers'
|
|
5
|
+
require_relative '../loaders/resultset_loader'
|
|
6
|
+
require_relative '../errors/errors'
|
|
7
|
+
require_relative '../paths/path_utils'
|
|
8
|
+
|
|
9
|
+
module CovLoupe
|
|
10
|
+
module Repositories
|
|
11
|
+
# CoverageRepository handles the discovery, loading, and normalization of SimpleCov
|
|
12
|
+
# coverage data. It decouples data access concerns from the domain logic in CoverageModel.
|
|
13
|
+
#
|
|
14
|
+
# Its primary responsibilities are:
|
|
15
|
+
# 1. Locating the .resultset.json file using ResolverHelpers.
|
|
16
|
+
# 2. Loading and parsing the JSON data using ResultsetLoader (handling suite merging if needed).
|
|
17
|
+
# 3. Normalizing all coverage map keys to absolute paths relative to the project root.
|
|
18
|
+
#
|
|
19
|
+
# @attr_reader coverage_map [Hash] A map of absolute file paths to coverage data.
|
|
20
|
+
# @attr_reader timestamp [Integer] The latest timestamp from the loaded coverage suites.
|
|
21
|
+
# @attr_reader resultset_path [String] The resolved absolute path to the .resultset.json file.
|
|
22
|
+
class CoverageRepository
|
|
23
|
+
attr_reader :coverage_map, :timestamp, :resultset_path
|
|
24
|
+
|
|
25
|
+
def initialize(root:, resultset_path: nil, logger: nil)
|
|
26
|
+
@root = root
|
|
27
|
+
@logger = logger || CovLoupe.logger
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
# 1. Locate the file
|
|
31
|
+
@resultset_path = resolve_resultset_path(resultset_path)
|
|
32
|
+
|
|
33
|
+
# 2. Load the data
|
|
34
|
+
loaded_data = load_data
|
|
35
|
+
|
|
36
|
+
# 3. Detect volume case sensitivity from project root
|
|
37
|
+
@volume_case_sensitive = detect_volume_case_sensitivity
|
|
38
|
+
|
|
39
|
+
# 4. Normalize keys to absolute paths
|
|
40
|
+
@coverage_map = normalize_paths(loaded_data.coverage_map)
|
|
41
|
+
@timestamp = loaded_data.timestamp
|
|
42
|
+
rescue CovLoupe::Error
|
|
43
|
+
raise # Re-raise our own errors as-is
|
|
44
|
+
rescue => e
|
|
45
|
+
raise ErrorHandler.new.convert_standard_error(e, context: :coverage_loading)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private def resolve_resultset_path(path_arg)
|
|
50
|
+
Resolvers::ResolverHelpers.find_resultset(@root, resultset: path_arg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private def load_data
|
|
54
|
+
ResultsetLoader.load(resultset_path: @resultset_path, logger: @logger)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Detects volume case sensitivity from the project root directory.
|
|
58
|
+
# Uses @root because coverage map keys are paths to source files in the project.
|
|
59
|
+
#
|
|
60
|
+
# Falls back to assuming case-insensitive if @root doesn't exist (test scenarios)
|
|
61
|
+
# or isn't accessible. This conservative fallback catches more potential collisions.
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean] true if volume is case-sensitive
|
|
64
|
+
private def detect_volume_case_sensitivity
|
|
65
|
+
return false unless File.directory?(@root)
|
|
66
|
+
|
|
67
|
+
PathUtils.volume_case_sensitive?(@root)
|
|
68
|
+
rescue SystemCallError, IOError
|
|
69
|
+
# Can't detect from filesystem, assume case-insensitive to be conservative
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Normalizes all coverage map keys to absolute paths and detects collisions.
|
|
74
|
+
#
|
|
75
|
+
# This method transforms relative and mixed-case paths to their canonical absolute
|
|
76
|
+
# form. If multiple original keys normalize to the same path (e.g., "lib/foo.rb" and
|
|
77
|
+
# "/full/path/lib/foo.rb"), this indicates corrupt or problematic coverage data that
|
|
78
|
+
# would otherwise silently overwrite earlier entries.
|
|
79
|
+
#
|
|
80
|
+
# On case-insensitive volumes, paths that differ only in case (e.g., "Foo.rb" and
|
|
81
|
+
# "foo.rb") are detected as collisions. The original case is preserved in stored keys
|
|
82
|
+
# for correct display in error messages and reports.
|
|
83
|
+
#
|
|
84
|
+
# @param map [Hash] Original coverage map with potentially relative/mixed keys
|
|
85
|
+
# @return [Hash] Normalized coverage map with absolute path keys (preserving original case)
|
|
86
|
+
# @raise [CoverageDataError] If duplicate keys normalize to the same path
|
|
87
|
+
private def normalize_paths(map)
|
|
88
|
+
return {} unless map
|
|
89
|
+
|
|
90
|
+
result = {}
|
|
91
|
+
# Track which original keys map to each normalized key to detect collisions
|
|
92
|
+
# Example: { "/abs/path/lib/foo.rb" => ["lib/foo.rb", "/abs/path/lib/foo.rb"] }
|
|
93
|
+
provided_paths_by_normalized_path = Hash.new { |h, k| h[k] = [] }
|
|
94
|
+
# Track the expanded (but not case-normalized) key for storage
|
|
95
|
+
# Example: { "/abs/path/lib/foo.rb" => "/full/path/lib/foo.rb" }
|
|
96
|
+
expanded_by_normalized = {}
|
|
97
|
+
|
|
98
|
+
# First pass: normalize all keys and track the mapping
|
|
99
|
+
map.each do |original_key, value|
|
|
100
|
+
# Expand to absolute path first
|
|
101
|
+
expanded_key = PathUtils.expand(original_key, @root)
|
|
102
|
+
|
|
103
|
+
# Then apply case normalization for collision detection only
|
|
104
|
+
# Pass root to ensure case-sensitivity is derived from root's volume
|
|
105
|
+
normalized_key = PathUtils.normalize(
|
|
106
|
+
expanded_key,
|
|
107
|
+
normalize_case: !@volume_case_sensitive,
|
|
108
|
+
root: @root
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
provided_paths_by_normalized_path[normalized_key] << original_key
|
|
112
|
+
# Store using expanded key (preserves original case) for display purposes
|
|
113
|
+
expanded_by_normalized[normalized_key] ||= expanded_key
|
|
114
|
+
result[expanded_by_normalized[normalized_key]] = value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Second pass: detect collisions (any normalized key with multiple original keys)
|
|
118
|
+
collisions = provided_paths_by_normalized_path.select do |_norm_key, orig_keys|
|
|
119
|
+
orig_keys.size > 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
collisions.empty? ? result : raise_collision_error(collisions, expanded_by_normalized)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Raises a CoverageDataError with details about path normalization collisions.
|
|
126
|
+
#
|
|
127
|
+
# Formats collision data as parseable JSON with each collision on one line:
|
|
128
|
+
# {
|
|
129
|
+
# "/full/path/lib/foo.rb": ["lib/foo.rb", "/full/path/lib/foo.rb"],
|
|
130
|
+
# "/full/path/lib/bar.rb": ["lib/bar.rb", "/full/path/lib/bar.rb"]
|
|
131
|
+
# }
|
|
132
|
+
#
|
|
133
|
+
# @param collisions [Hash] Map of normalized paths to arrays of original keys
|
|
134
|
+
# @param expanded_by_normalized [Hash] Map of normalized paths to case-preserved expanded paths
|
|
135
|
+
# @raise [CoverageDataError] Always raises with formatted collision details
|
|
136
|
+
private def raise_collision_error(collisions, expanded_by_normalized)
|
|
137
|
+
json_lines = collisions.map do |norm_key, orig_keys|
|
|
138
|
+
# Use the case-preserved expanded key instead of the normalized key
|
|
139
|
+
expanded_key = expanded_by_normalized[norm_key]
|
|
140
|
+
" #{JSON.generate(expanded_key)}: #{JSON.generate(orig_keys)}"
|
|
141
|
+
end
|
|
142
|
+
details = "{\n#{json_lines.join(",\n")}\n}"
|
|
143
|
+
|
|
144
|
+
raise CoverageDataError,
|
|
145
|
+
"Duplicate paths detected after normalization. The following keys normalize to the same path:\n#{details}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|