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,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'time'
|
|
7
|
+
require_relative 'command_execution'
|
|
8
|
+
|
|
9
|
+
module CovLoupe
|
|
10
|
+
module Scripts
|
|
11
|
+
class PreReleaseCheck
|
|
12
|
+
include CommandExecution
|
|
13
|
+
|
|
14
|
+
ROOT = Pathname.new(__dir__).join('../../..').expand_path
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
Dir.chdir(ROOT) do
|
|
18
|
+
verify_git_clean!
|
|
19
|
+
puts '✓ Git working tree is clean'
|
|
20
|
+
|
|
21
|
+
verify_branch!
|
|
22
|
+
puts '✓ On main branch'
|
|
23
|
+
|
|
24
|
+
verify_sync!
|
|
25
|
+
puts '✓ Local branch is in sync with origin/main'
|
|
26
|
+
|
|
27
|
+
verify_ci_passed!
|
|
28
|
+
puts '✓ GitHub Actions CI passed'
|
|
29
|
+
|
|
30
|
+
@version = fetch_version
|
|
31
|
+
@tag_name = "v#{@version}"
|
|
32
|
+
puts "✓ Preparing release for version #{@version}"
|
|
33
|
+
|
|
34
|
+
verify_release_notes!
|
|
35
|
+
puts "✓ Release notes found for #{@tag_name}"
|
|
36
|
+
|
|
37
|
+
verify_tag_new!
|
|
38
|
+
puts "✓ Tag #{@tag_name} does not yet exist"
|
|
39
|
+
|
|
40
|
+
build_gem!
|
|
41
|
+
puts '✓ Gem built successfully'
|
|
42
|
+
|
|
43
|
+
puts "\nBuild complete! To finish the release, run:"
|
|
44
|
+
puts
|
|
45
|
+
puts " git tag -a #{@tag_name} -m 'Version #{@version}'"
|
|
46
|
+
puts ' git push origin main --follow-tags'
|
|
47
|
+
puts " gem push #{@gem_file.basename}"
|
|
48
|
+
puts
|
|
49
|
+
puts 'Then draft the GitHub release via the web UI.'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private def verify_git_clean!
|
|
54
|
+
status = run_command(%w[git status --porcelain], print_output: false)
|
|
55
|
+
unless status.strip.empty?
|
|
56
|
+
abort_with('Uncommitted changes present. Commit or stash before releasing.')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private def verify_branch!
|
|
61
|
+
current_branch = run_command(%w[git rev-parse --abbrev-ref HEAD], print_output: false).strip
|
|
62
|
+
abort_with('Releases must be cut from the main branch.') unless current_branch == 'main'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private def verify_sync!
|
|
66
|
+
run_command(%w[git fetch origin --tags], print_output: true)
|
|
67
|
+
local = run_command(%w[git rev-parse HEAD], print_output: false).strip
|
|
68
|
+
remote = run_command(%w[git rev-parse origin/main], print_output: false).strip
|
|
69
|
+
return if local == remote
|
|
70
|
+
|
|
71
|
+
base = run_command(%w[git merge-base HEAD origin/main], print_output: false).strip
|
|
72
|
+
|
|
73
|
+
if base == local
|
|
74
|
+
abort_with('Local main is behind origin. Pull before releasing.')
|
|
75
|
+
elsif base == remote
|
|
76
|
+
abort_with('Local main is ahead of origin. Push before releasing.')
|
|
77
|
+
else
|
|
78
|
+
abort_with('Local main has diverged from origin. Reconcile before releasing.')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private def verify_ci_passed!
|
|
83
|
+
# Capture current HEAD SHA and timestamp before triggering
|
|
84
|
+
head_sha = run_command(%w[git rev-parse HEAD], print_output: false).strip
|
|
85
|
+
trigger_time = Time.now
|
|
86
|
+
|
|
87
|
+
# Trigger the workflow
|
|
88
|
+
run_command(%w[gh workflow run test.yml --ref main], print_output: true)
|
|
89
|
+
puts 'Waiting for workflow to initialize...'
|
|
90
|
+
|
|
91
|
+
# Poll for the specific workflow run matching HEAD SHA and created after trigger time
|
|
92
|
+
run_id = find_triggered_run_id(head_sha, trigger_time)
|
|
93
|
+
abort_with('Failed to retrieve the CI run ID.') if run_id.empty?
|
|
94
|
+
|
|
95
|
+
puts "Monitoring CI build (Run ID: #{run_id})..."
|
|
96
|
+
run_command(['gh', 'run', 'watch', run_id, '--exit-status'], print_output: true)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private def find_triggered_run_id(head_sha, trigger_time)
|
|
100
|
+
max_attempts = 30
|
|
101
|
+
poll_interval = 2
|
|
102
|
+
attempts = 0
|
|
103
|
+
|
|
104
|
+
while attempts < max_attempts
|
|
105
|
+
sleep poll_interval
|
|
106
|
+
attempts += 1
|
|
107
|
+
|
|
108
|
+
# Get runs with databaseId, headSha, and createdAt fields
|
|
109
|
+
runs_json = run_command(
|
|
110
|
+
%w[gh run list --workflow test.yml --branch main --limit 10] \
|
|
111
|
+
+ %w[--json databaseId,headSha,createdAt],
|
|
112
|
+
print_output: false
|
|
113
|
+
).strip
|
|
114
|
+
|
|
115
|
+
next if runs_json.empty?
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
runs = JSON.parse(runs_json)
|
|
119
|
+
# Find the newest run matching our HEAD SHA and created after trigger time
|
|
120
|
+
matching_run = runs.find do |run|
|
|
121
|
+
run['headSha'] == head_sha &&
|
|
122
|
+
Time.parse(run['createdAt']) >= trigger_time
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
return matching_run['databaseId'].to_s if matching_run
|
|
126
|
+
rescue JSON::ParserError => e
|
|
127
|
+
abort_with("Failed to parse GitHub API response: #{e.message}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
abort_with("Timed out waiting for workflow run to appear for HEAD SHA #{head_sha}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private def fetch_version
|
|
135
|
+
version_file = ROOT.join('lib/cov_loupe/version.rb')
|
|
136
|
+
version_source = version_file.read
|
|
137
|
+
version = version_source[/VERSION\s*=\s*["'](.+?)["']/, 1]
|
|
138
|
+
abort_with("Could not find VERSION constant in #{version_file}") unless version
|
|
139
|
+
version
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private def verify_release_notes!
|
|
143
|
+
release_notes = ROOT.join('RELEASE_NOTES.md').read
|
|
144
|
+
version_pattern = /^## .*\b#{Regexp.escape(@tag_name)}\b/
|
|
145
|
+
unless release_notes.match?(version_pattern)
|
|
146
|
+
abort_with("Add a '## #{@tag_name}' section to RELEASE_NOTES.md before releasing.")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private def verify_tag_new!
|
|
151
|
+
existing_tag = run_command(['git', 'tag', '-l', @tag_name], print_output: false)
|
|
152
|
+
.split("\n").include?(@tag_name)
|
|
153
|
+
abort_with("Tag #{@tag_name} already exists. Bump the version before releasing.") if existing_tag
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private def build_gem!
|
|
157
|
+
@gem_file = ROOT.join("cov-loupe-#{@version}.gem")
|
|
158
|
+
FileUtils.rm_f(@gem_file)
|
|
159
|
+
run_command(%w[gem build cov-loupe.gemspec], print_output: true)
|
|
160
|
+
abort_with("Gem file #{@gem_file} not found after build.") unless @gem_file.exist?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'command_execution'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Scripts
|
|
7
|
+
class SetupDocServer
|
|
8
|
+
include CommandExecution
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
puts 'Setting up Python virtual environment...'
|
|
12
|
+
run_command(%w[python3 -m venv .venv], print_output: true)
|
|
13
|
+
|
|
14
|
+
puts 'Installing dependencies...'
|
|
15
|
+
# Install using the venv's pip directly
|
|
16
|
+
pip_path = File.exist?('.venv/bin/pip') ? '.venv/bin/pip' : 'pip'
|
|
17
|
+
run_command([pip_path, 'install', '-q', '-r', 'requirements.txt'], print_output: true)
|
|
18
|
+
|
|
19
|
+
puts '✓ Documentation server setup complete.'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'command_execution'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
module Scripts
|
|
7
|
+
class StartDocServer
|
|
8
|
+
include CommandExecution
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
mkdocs_path = File.exist?('.venv/bin/mkdocs') ? '.venv/bin/mkdocs' : 'mkdocs'
|
|
12
|
+
|
|
13
|
+
unless command_exists?(mkdocs_path)
|
|
14
|
+
warn "Error: mkdocs not found. Please run 'bin/set-up-python-for-doc-server' or " \
|
|
15
|
+
"'rake docs:setup' first."
|
|
16
|
+
exit 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
puts 'Starting documentation server...'
|
|
20
|
+
exec(mkdocs_path, 'serve')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CovLoupe
|
|
4
|
+
# Helpers for working with staleness status values.
|
|
5
|
+
module StaleStatus
|
|
6
|
+
VALID_STATUSES = %w[ok missing newer length_mismatch error].freeze
|
|
7
|
+
|
|
8
|
+
module_function def stale?(value)
|
|
9
|
+
normalize(value) != 'ok'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module_function def normalize(value)
|
|
13
|
+
raise ArgumentError, 'Stale status is missing' if value.nil?
|
|
14
|
+
unless value.is_a?(String)
|
|
15
|
+
raise ArgumentError, "Stale status must be a String, got #{value.class} (value: #{value.inspect})"
|
|
16
|
+
end
|
|
17
|
+
return value if VALID_STATUSES.include?(value)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, "Unknown stale status: #{value.inspect}. " \
|
|
20
|
+
"Permitted values: #{VALID_STATUSES.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby >= 3.4 requires explicit require for set; RuboCop targets 3.2
|
|
6
|
+
require_relative '../errors/errors'
|
|
7
|
+
require_relative '../resolvers/resolver_helpers'
|
|
8
|
+
|
|
9
|
+
module CovLoupe
|
|
10
|
+
# Lightweight service object to check staleness of coverage vs. sources
|
|
11
|
+
class StalenessChecker
|
|
12
|
+
MODES = [:off, :error].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(root:, resultset:, mode: :off, tracked_globs: nil, timestamp: nil)
|
|
15
|
+
@root = File.expand_path(root || '.')
|
|
16
|
+
@resultset = resultset
|
|
17
|
+
@mode = (mode || :off).to_sym
|
|
18
|
+
@tracked_globs = tracked_globs
|
|
19
|
+
@cov_timestamp = timestamp
|
|
20
|
+
@resultset_path = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def off?
|
|
24
|
+
@mode == :off
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raise CoverageDataStaleError if stale (only in error mode)
|
|
28
|
+
def check_file!(file_abs, coverage_lines)
|
|
29
|
+
return if off?
|
|
30
|
+
|
|
31
|
+
d = compute_file_staleness_details(file_abs, coverage_lines)
|
|
32
|
+
|
|
33
|
+
# Raise FileError if there was a read error
|
|
34
|
+
if d[:read_error]
|
|
35
|
+
raise FileError, "Error reading file: #{rel(file_abs)}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# For single-file checks, missing files with recorded coverage count as stale
|
|
39
|
+
# via length mismatch; project-level checks also handle deleted files explicitly.
|
|
40
|
+
if d[:newer] || d[:len_mismatch]
|
|
41
|
+
raise CoverageDataStaleError.new(
|
|
42
|
+
nil,
|
|
43
|
+
nil,
|
|
44
|
+
file_path: rel(file_abs),
|
|
45
|
+
file_mtime: d[:file_mtime],
|
|
46
|
+
cov_timestamp: d[:coverage_timestamp],
|
|
47
|
+
src_len: d[:src_len],
|
|
48
|
+
cov_len: d[:cov_len],
|
|
49
|
+
resultset_path: resultset_path
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compute the staleness status for a specific file relative to coverage.
|
|
55
|
+
# Ignores mode and never raises. Returns a String:
|
|
56
|
+
# - 'ok' - file is not stale (fresh)
|
|
57
|
+
# - 'missing' - the file is missing/deleted
|
|
58
|
+
# - 'newer' - the file mtime is newer than the coverage timestamp
|
|
59
|
+
# - 'length_mismatch' - the source line count differs from coverage lines array length
|
|
60
|
+
# - 'error' - the file cannot be read due to permission or I/O errors
|
|
61
|
+
def file_staleness_status(file_abs, coverage_lines)
|
|
62
|
+
d = compute_file_staleness_details(file_abs, coverage_lines)
|
|
63
|
+
return 'error' if d[:read_error]
|
|
64
|
+
return 'missing' unless d[:exists]
|
|
65
|
+
return 'newer' if d[:newer]
|
|
66
|
+
return 'length_mismatch' if d[:len_mismatch]
|
|
67
|
+
|
|
68
|
+
'ok'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Compute and return project staleness details (newer, missing, deleted files).
|
|
72
|
+
# If in error mode, raises CoverageDataProjectStaleError when issues are found.
|
|
73
|
+
# Returns a hash { newer_files: [], missing_files: [], deleted_files: [], unreadable_files: [] }
|
|
74
|
+
def check_project!(coverage_map)
|
|
75
|
+
ts = coverage_timestamp
|
|
76
|
+
coverage_files = coverage_map.keys
|
|
77
|
+
|
|
78
|
+
newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
|
|
79
|
+
missing = compute_missing_files(coverage_files)
|
|
80
|
+
|
|
81
|
+
staleness_details = {
|
|
82
|
+
newer_files: newer,
|
|
83
|
+
missing_files: missing,
|
|
84
|
+
deleted_files: deleted,
|
|
85
|
+
unreadable_files: unreadable,
|
|
86
|
+
timestamp_status: ts.to_i > 0 ? 'ok' : 'missing'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if @mode == :error && (newer.any? || missing.any? || deleted.any? || unreadable.any?)
|
|
90
|
+
raise CoverageDataProjectStaleError.new(
|
|
91
|
+
nil,
|
|
92
|
+
nil,
|
|
93
|
+
cov_timestamp: ts,
|
|
94
|
+
newer_files: newer,
|
|
95
|
+
missing_files: missing,
|
|
96
|
+
deleted_files: deleted,
|
|
97
|
+
unreadable_files: unreadable,
|
|
98
|
+
resultset_path: resultset_path
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
staleness_details
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Compute and return project staleness details including line-count mismatches.
|
|
106
|
+
# If in error mode, raises CoverageDataProjectStaleError when issues are found.
|
|
107
|
+
# Returns a hash with newer/missing/deleted/mismatched/unreadable files and per-file statuses.
|
|
108
|
+
def check_project_with_lines!(coverage_lines_by_path, coverage_files:)
|
|
109
|
+
coverage_lines_by_path ||= {}
|
|
110
|
+
ts = coverage_timestamp
|
|
111
|
+
|
|
112
|
+
newer, deleted, unreadable = compute_newer_and_deleted_files(coverage_files, ts)
|
|
113
|
+
missing = compute_missing_files(coverage_files)
|
|
114
|
+
|
|
115
|
+
file_statuses = {}
|
|
116
|
+
length_mismatch = []
|
|
117
|
+
|
|
118
|
+
coverage_lines_by_path.each do |abs_path, coverage_lines|
|
|
119
|
+
details = compute_file_staleness_details(abs_path, coverage_lines)
|
|
120
|
+
status = if details[:read_error]
|
|
121
|
+
'error'
|
|
122
|
+
elsif !details[:exists]
|
|
123
|
+
'missing'
|
|
124
|
+
elsif details[:newer]
|
|
125
|
+
'newer'
|
|
126
|
+
elsif details[:len_mismatch]
|
|
127
|
+
'length_mismatch'
|
|
128
|
+
else
|
|
129
|
+
'ok'
|
|
130
|
+
end
|
|
131
|
+
file_statuses[abs_path] = status
|
|
132
|
+
unreadable << rel(abs_path) if details[:read_error]
|
|
133
|
+
length_mismatch << rel(abs_path) if details[:len_mismatch] && details[:exists]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Ensure files are not reported as both "newer" and "length mismatch" or "unreadable"
|
|
137
|
+
# Length mismatch and unreadable are the stronger signals for staleness
|
|
138
|
+
newer -= length_mismatch
|
|
139
|
+
newer -= unreadable
|
|
140
|
+
|
|
141
|
+
staleness_details = {
|
|
142
|
+
newer_files: newer,
|
|
143
|
+
missing_files: missing,
|
|
144
|
+
deleted_files: deleted,
|
|
145
|
+
length_mismatch_files: length_mismatch,
|
|
146
|
+
unreadable_files: unreadable,
|
|
147
|
+
file_statuses: file_statuses,
|
|
148
|
+
timestamp_status: ts.to_i > 0 ? 'ok' : 'missing'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if @mode == :error && [newer, missing, deleted, length_mismatch, unreadable].any?(&:any?)
|
|
152
|
+
raise CoverageDataProjectStaleError.new(
|
|
153
|
+
nil,
|
|
154
|
+
nil,
|
|
155
|
+
cov_timestamp: ts,
|
|
156
|
+
newer_files: newer,
|
|
157
|
+
missing_files: missing,
|
|
158
|
+
deleted_files: deleted,
|
|
159
|
+
length_mismatch_files: length_mismatch,
|
|
160
|
+
unreadable_files: unreadable,
|
|
161
|
+
resultset_path: resultset_path
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
staleness_details
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private def compute_newer_and_deleted_files(coverage_files, timestamp)
|
|
169
|
+
existing = []
|
|
170
|
+
deleted_abs = []
|
|
171
|
+
unreadable_abs = []
|
|
172
|
+
|
|
173
|
+
coverage_files.each do |abs|
|
|
174
|
+
if File.file?(abs)
|
|
175
|
+
existing << abs
|
|
176
|
+
else
|
|
177
|
+
deleted_abs << abs
|
|
178
|
+
end
|
|
179
|
+
rescue SystemCallError, IOError
|
|
180
|
+
# Permission denied or other filesystem errors
|
|
181
|
+
unreadable_abs << abs
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
newer = []
|
|
185
|
+
# If timestamp is missing/0, skip newer checks
|
|
186
|
+
check_newer = timestamp.to_i > 0
|
|
187
|
+
|
|
188
|
+
existing.each do |abs|
|
|
189
|
+
newer << rel(abs) if check_newer && File.mtime(abs).to_i > timestamp.to_i
|
|
190
|
+
rescue SystemCallError, IOError
|
|
191
|
+
# Permission denied or other filesystem errors reading mtime
|
|
192
|
+
unreadable_abs << abs
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
deleted = deleted_abs.map { |abs| rel(abs) }
|
|
196
|
+
unreadable = unreadable_abs.map { |abs| rel(abs) }
|
|
197
|
+
|
|
198
|
+
[newer, deleted, unreadable]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Identifies tracked files that are missing from coverage.
|
|
202
|
+
# Returns array of relative paths for files matched by tracked_globs but not in coverage.
|
|
203
|
+
private def compute_missing_files(coverage_files)
|
|
204
|
+
return [] unless @tracked_globs && Array(@tracked_globs).any?
|
|
205
|
+
|
|
206
|
+
patterns = Array(@tracked_globs).map { |g| File.expand_path(g, @root) }
|
|
207
|
+
tracked = patterns
|
|
208
|
+
.flat_map { |p| Dir.glob(p, File::FNM_EXTGLOB | File::FNM_PATHNAME) }
|
|
209
|
+
.select { |p| File.file?(p) }
|
|
210
|
+
|
|
211
|
+
covered_set = coverage_files.to_set
|
|
212
|
+
tracked.reject { |abs| covered_set.include?(abs) }.map { |abs| rel(abs) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private def coverage_timestamp
|
|
216
|
+
@cov_timestamp || 0
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private def resultset_path
|
|
220
|
+
@resultset_path ||= Resolvers::ResolverHelpers.find_resultset(@root, resultset: @resultset)
|
|
221
|
+
rescue
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private def safe_count_lines(path)
|
|
226
|
+
return 0 unless File.file?(path)
|
|
227
|
+
|
|
228
|
+
File.foreach(path).count
|
|
229
|
+
rescue SystemCallError, IOError
|
|
230
|
+
:read_error
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private def safe_file_state(path)
|
|
234
|
+
exists = false
|
|
235
|
+
file_mtime = nil
|
|
236
|
+
read_error = false
|
|
237
|
+
|
|
238
|
+
begin
|
|
239
|
+
exists = File.file?(path)
|
|
240
|
+
rescue SystemCallError, IOError
|
|
241
|
+
return [false, nil, true]
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
return [false, nil, false] unless exists
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
file_mtime = File.mtime(path)
|
|
248
|
+
rescue SystemCallError, IOError
|
|
249
|
+
read_error = true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
[exists, file_mtime, read_error]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private def rel(path)
|
|
256
|
+
# Handle relative vs absolute path mismatches that cause ArgumentError
|
|
257
|
+
Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
|
|
258
|
+
rescue ArgumentError
|
|
259
|
+
# Path is outside the project root or has a different prefix type, fall back to absolute path
|
|
260
|
+
path.to_s
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Centralized computation of staleness-related details for a single file.
|
|
264
|
+
# Returns a Hash with keys:
|
|
265
|
+
# :exists, :file_mtime, :coverage_timestamp, :cov_len, :src_len, :newer, :len_mismatch, :read_error
|
|
266
|
+
private def compute_file_staleness_details(file_abs, coverage_lines)
|
|
267
|
+
coverage_ts = coverage_timestamp
|
|
268
|
+
|
|
269
|
+
exists, file_mtime, read_error = safe_file_state(file_abs)
|
|
270
|
+
|
|
271
|
+
cov_len = coverage_lines.respond_to?(:length) ? coverage_lines.length : 0
|
|
272
|
+
src_len = (exists && !read_error) ? safe_count_lines(file_abs) : 0
|
|
273
|
+
|
|
274
|
+
# Check if safe_count_lines returned an error sentinel
|
|
275
|
+
read_error ||= src_len == :read_error
|
|
276
|
+
src_len = 0 if read_error
|
|
277
|
+
|
|
278
|
+
# Check if the source file has been modified since coverage was generated
|
|
279
|
+
# Don't check for mismatch if we couldn't read the file
|
|
280
|
+
len_mismatch = read_error ? false : length_mismatch?(cov_len, src_len)
|
|
281
|
+
newer = check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch, read_error)
|
|
282
|
+
|
|
283
|
+
{
|
|
284
|
+
exists: exists,
|
|
285
|
+
file_mtime: file_mtime,
|
|
286
|
+
coverage_timestamp: coverage_ts,
|
|
287
|
+
cov_len: cov_len,
|
|
288
|
+
src_len: src_len,
|
|
289
|
+
newer: newer,
|
|
290
|
+
len_mismatch: len_mismatch,
|
|
291
|
+
read_error: read_error
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Checks if the source line count differs from the coverage line count.
|
|
296
|
+
#
|
|
297
|
+
# Why this check exists:
|
|
298
|
+
# - When a file is modified after coverage is generated, the line count often changes
|
|
299
|
+
# - A mismatch indicates the coverage data is stale and no longer represents the current file
|
|
300
|
+
# - We only flag as mismatch when coverage data exists (cov_len > 0)
|
|
301
|
+
#
|
|
302
|
+
# Note: Empty coverage (cov_len == 0) is not considered a mismatch, as it may represent
|
|
303
|
+
# files that were never executed or files that are legitimately empty.
|
|
304
|
+
private def length_mismatch?(cov_len, src_len)
|
|
305
|
+
cov_len.positive? && src_len != cov_len
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Determines if a file has been modified more recently than the coverage timestamp.
|
|
309
|
+
#
|
|
310
|
+
# Why this check exists:
|
|
311
|
+
# - Files modified after coverage generation may have behavioral changes not captured
|
|
312
|
+
# - However, if there's already a length mismatch or read error, we prioritize that as the staleness indicator
|
|
313
|
+
# - This prevents double-flagging: if lines changed, the file is already stale (length mismatch)
|
|
314
|
+
#
|
|
315
|
+
# The logic: newer &&= !len_mismatch && !read_error means:
|
|
316
|
+
# - If len_mismatch or read_error is true, set newer to false (those take precedence)
|
|
317
|
+
# - This way, staleness is categorized as either 'newer' (time-based), 'length_mismatch' (length-based),
|
|
318
|
+
# or 'error' (read error), not multiple
|
|
319
|
+
private def check_file_newer_than_coverage(file_mtime, coverage_ts, len_mismatch, read_error)
|
|
320
|
+
return false if coverage_ts.to_i <= 0
|
|
321
|
+
|
|
322
|
+
newer = !!(file_mtime && file_mtime.to_i > coverage_ts.to_i)
|
|
323
|
+
# If there's a length mismatch or read error, don't also flag as "newer" - those are more specific
|
|
324
|
+
newer &&= !len_mismatch && !read_error
|
|
325
|
+
newer
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../output_chars'
|
|
4
|
+
|
|
5
|
+
module CovLoupe
|
|
6
|
+
# Formatter for stale coverage error messages
|
|
7
|
+
class StalenessMessageFormatter
|
|
8
|
+
def initialize(cov_timestamp:, resultset_path: nil, output_chars: :default)
|
|
9
|
+
@cov_timestamp = cov_timestamp
|
|
10
|
+
@resultset_path = resultset_path
|
|
11
|
+
@output_chars = output_chars
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def format_project_details(newer_files:, missing_files:, deleted_files:,
|
|
15
|
+
length_mismatch_files:, unreadable_files: [])
|
|
16
|
+
[
|
|
17
|
+
format_coverage_time,
|
|
18
|
+
*format_file_list(newer_files, 'Newer files'),
|
|
19
|
+
*format_file_list(missing_files, 'Missing files', 'new in project, not in coverage'),
|
|
20
|
+
*format_file_list(deleted_files, 'Coverage-only files', 'deleted or moved in project'),
|
|
21
|
+
*format_file_list(length_mismatch_files, 'Line count mismatches'),
|
|
22
|
+
*format_file_list(unreadable_files, 'Unreadable files', 'permission denied or read errors'),
|
|
23
|
+
(@resultset_path ? "\nResultset - #{convert_path(@resultset_path)}" : nil)
|
|
24
|
+
].compact.join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def format_single_file_details(file_path:, file_mtime:, src_len:, cov_len:)
|
|
28
|
+
file_utc, file_local = format_time_both(file_mtime)
|
|
29
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
|
30
|
+
delta_str = format_delta_seconds(file_mtime, @cov_timestamp)
|
|
31
|
+
|
|
32
|
+
details = <<~DETAILS
|
|
33
|
+
|
|
34
|
+
File - time: #{file_utc || 'not found'} (local #{file_local || 'n/a'}), lines: #{src_len}
|
|
35
|
+
Coverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'}), lines: #{cov_len}
|
|
36
|
+
DETAILS
|
|
37
|
+
|
|
38
|
+
details += "\nDelta - file is #{delta_str} newer than coverage" if delta_str
|
|
39
|
+
details += "\nResultset - #{convert_path(@resultset_path)}" if @resultset_path
|
|
40
|
+
details.chomp
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private def convert_path(path)
|
|
44
|
+
OutputChars.convert(path, @output_chars)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private def format_coverage_time
|
|
48
|
+
cov_utc, cov_local = format_epoch_both(@cov_timestamp)
|
|
49
|
+
"\nCoverage - time: #{cov_utc || 'not found'} (local #{cov_local || 'n/a'})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private def format_file_list(files, label, description = nil)
|
|
53
|
+
return [] if files.empty?
|
|
54
|
+
|
|
55
|
+
desc = description ? " (#{description}, #{files.size}):" : " (#{files.size}):"
|
|
56
|
+
[
|
|
57
|
+
"\n#{label}#{desc}",
|
|
58
|
+
*files.first(10).map { |f| " - #{convert_path(f)}" },
|
|
59
|
+
*(files.size > 10 ? [' ...'] : [])
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private def format_epoch_both(epoch_seconds)
|
|
64
|
+
return [nil, nil] unless epoch_seconds
|
|
65
|
+
|
|
66
|
+
t = Time.at(epoch_seconds.to_i)
|
|
67
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
|
68
|
+
rescue
|
|
69
|
+
[epoch_seconds.to_s, epoch_seconds.to_s]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private def format_time_both(time)
|
|
73
|
+
return [nil, nil] unless time
|
|
74
|
+
|
|
75
|
+
t = time.is_a?(Time) ? time : Time.parse(time.to_s)
|
|
76
|
+
[t.utc.iso8601, t.getlocal.iso8601]
|
|
77
|
+
rescue
|
|
78
|
+
[time.to_s, time.to_s]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private def format_delta_seconds(file_mtime, cov_timestamp)
|
|
82
|
+
return nil unless file_mtime && cov_timestamp
|
|
83
|
+
|
|
84
|
+
seconds = file_mtime.to_i - cov_timestamp.to_i
|
|
85
|
+
sign = seconds >= 0 ? '+' : '-'
|
|
86
|
+
"#{sign}#{seconds.abs}s"
|
|
87
|
+
rescue
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|