reviewer 0.1.4 → 1.0.0

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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.alexignore +1 -0
  3. data/.github/FUNDING.yml +3 -0
  4. data/.github/workflows/main.yml +81 -11
  5. data/.github/workflows/release.yml +98 -0
  6. data/.gitignore +1 -1
  7. data/.inch.yml +3 -1
  8. data/.reek.yml +175 -0
  9. data/.reviewer.example.yml +27 -12
  10. data/.reviewer.future.yml +221 -0
  11. data/.reviewer.yml +191 -28
  12. data/.reviewer_stdout +0 -0
  13. data/.rubocop.yml +34 -1
  14. data/CHANGELOG.md +42 -2
  15. data/Gemfile +39 -1
  16. data/Gemfile.lock +294 -72
  17. data/README.md +315 -7
  18. data/RELEASING.md +190 -0
  19. data/Rakefile +117 -0
  20. data/dependency_decisions.yml +61 -0
  21. data/exe/fmt +1 -1
  22. data/exe/rvw +1 -1
  23. data/lib/reviewer/arguments/files.rb +60 -27
  24. data/lib/reviewer/arguments/keywords.rb +39 -43
  25. data/lib/reviewer/arguments/tags.rb +21 -14
  26. data/lib/reviewer/arguments.rb +107 -29
  27. data/lib/reviewer/batch/formatter.rb +87 -0
  28. data/lib/reviewer/batch.rb +46 -35
  29. data/lib/reviewer/capabilities.rb +81 -0
  30. data/lib/reviewer/command/string/env.rb +16 -6
  31. data/lib/reviewer/command/string/flags.rb +14 -5
  32. data/lib/reviewer/command/string.rb +53 -24
  33. data/lib/reviewer/command.rb +69 -39
  34. data/lib/reviewer/configuration/loader.rb +70 -0
  35. data/lib/reviewer/configuration.rb +14 -4
  36. data/lib/reviewer/context.rb +15 -0
  37. data/lib/reviewer/doctor/config_check.rb +46 -0
  38. data/lib/reviewer/doctor/environment_check.rb +58 -0
  39. data/lib/reviewer/doctor/formatter.rb +75 -0
  40. data/lib/reviewer/doctor/keyword_check.rb +85 -0
  41. data/lib/reviewer/doctor/opportunity_check.rb +88 -0
  42. data/lib/reviewer/doctor/report.rb +63 -0
  43. data/lib/reviewer/doctor/tool_inventory.rb +41 -0
  44. data/lib/reviewer/doctor.rb +28 -0
  45. data/lib/reviewer/history.rb +36 -12
  46. data/lib/reviewer/output/formatting.rb +40 -0
  47. data/lib/reviewer/output/printer.rb +105 -0
  48. data/lib/reviewer/output.rb +54 -65
  49. data/lib/reviewer/prompt.rb +38 -0
  50. data/lib/reviewer/report/formatter.rb +124 -0
  51. data/lib/reviewer/report.rb +100 -0
  52. data/lib/reviewer/runner/failed_files.rb +66 -0
  53. data/lib/reviewer/runner/formatter.rb +103 -0
  54. data/lib/reviewer/runner/guidance.rb +79 -0
  55. data/lib/reviewer/runner/result.rb +150 -0
  56. data/lib/reviewer/runner/strategies/captured.rb +232 -0
  57. data/lib/reviewer/runner/strategies/{verbose.rb → passthrough.rb} +15 -24
  58. data/lib/reviewer/runner.rb +179 -35
  59. data/lib/reviewer/session/formatter.rb +87 -0
  60. data/lib/reviewer/session.rb +208 -0
  61. data/lib/reviewer/setup/catalog.rb +233 -0
  62. data/lib/reviewer/setup/detector.rb +61 -0
  63. data/lib/reviewer/setup/formatter.rb +94 -0
  64. data/lib/reviewer/setup/gemfile_lock.rb +55 -0
  65. data/lib/reviewer/setup/generator.rb +54 -0
  66. data/lib/reviewer/setup/tool_block.rb +112 -0
  67. data/lib/reviewer/setup.rb +41 -0
  68. data/lib/reviewer/shell/result.rb +25 -11
  69. data/lib/reviewer/shell/timer.rb +47 -27
  70. data/lib/reviewer/shell.rb +46 -21
  71. data/lib/reviewer/tool/conversions.rb +20 -0
  72. data/lib/reviewer/tool/file_resolver.rb +54 -0
  73. data/lib/reviewer/tool/settings.rb +107 -56
  74. data/lib/reviewer/tool/test_file_mapper.rb +73 -0
  75. data/lib/reviewer/tool/timing.rb +78 -0
  76. data/lib/reviewer/tool.rb +88 -47
  77. data/lib/reviewer/tools.rb +47 -33
  78. data/lib/reviewer/version.rb +1 -1
  79. data/lib/reviewer.rb +114 -54
  80. data/reviewer.gemspec +21 -20
  81. data/structure.svg +1 -0
  82. metadata +113 -148
  83. data/.ruby-version +0 -1
  84. data/lib/reviewer/command/string/verbosity.rb +0 -51
  85. data/lib/reviewer/command/verbosity.rb +0 -65
  86. data/lib/reviewer/conversions.rb +0 -27
  87. data/lib/reviewer/guidance.rb +0 -73
  88. data/lib/reviewer/keywords/git/staged.rb +0 -48
  89. data/lib/reviewer/keywords/git.rb +0 -14
  90. data/lib/reviewer/keywords.rb +0 -9
  91. data/lib/reviewer/loader.rb +0 -59
  92. data/lib/reviewer/printer.rb +0 -25
  93. data/lib/reviewer/runner/strategies/quiet.rb +0 -90
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ # Bundles the shared runtime dependencies that flow through the review/format lifecycle.
5
+ # Passed from Session → Batch → Runner → Command so that no class needs to reach
6
+ # into module-level globals for arguments, output, or history.
7
+ #
8
+ # @!attribute [rw] arguments
9
+ # @return [Arguments] the parsed command-line arguments
10
+ # @!attribute [rw] output
11
+ # @return [Output] the output channel for displaying content
12
+ # @!attribute [rw] history
13
+ # @return [History] the YAML store for timing data and prepare timestamps
14
+ Context = Struct.new(:arguments, :output, :history, keyword_init: true)
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Validates the configuration file by delegating to Configuration::Loader
6
+ class ConfigCheck
7
+ attr_reader :report
8
+
9
+ # Creates a config check that validates the .reviewer.yml file
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param configuration [Configuration] the configuration to validate
12
+ #
13
+ # @return [ConfigCheck]
14
+ def initialize(report, configuration:)
15
+ @report = report
16
+ @configuration = configuration
17
+ end
18
+
19
+ # Checks for .reviewer.yml existence and validity
20
+ def check
21
+ config_file = @configuration.file
22
+
23
+ unless config_file.exist?
24
+ report.add(:configuration, status: :error,
25
+ message: 'No .reviewer.yml found', detail: 'Run `rvw init` to generate one')
26
+ return
27
+ end
28
+
29
+ report.add(:configuration, status: :ok, message: '.reviewer.yml found')
30
+ validate_via_loader
31
+ end
32
+
33
+ private
34
+
35
+ # Exercises the full Configuration::Loader pipeline (parse + validate) to surface config errors
36
+ def validate_via_loader
37
+ Configuration::Loader.configuration(file: @configuration.file)
38
+ report.add(:configuration, status: :ok, message: 'Configuration is valid')
39
+ rescue Configuration::Loader::InvalidConfigurationError => e
40
+ report.add(:configuration, status: :error, message: 'YAML syntax error', detail: e.message)
41
+ rescue Configuration::Loader::MissingReviewCommandError => e
42
+ report.add(:configuration, status: :error, message: 'Missing review command', detail: e.message)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Reviewer
6
+ module Doctor
7
+ # Checks environment prerequisites (git, Ruby version)
8
+ class EnvironmentCheck
9
+ attr_reader :report
10
+
11
+ # Creates an environment checker for Ruby and git availability
12
+ # @param report [Doctor::Report] the report to add findings to
13
+ #
14
+ # @return [EnvironmentCheck]
15
+ def initialize(report)
16
+ @report = report
17
+ end
18
+
19
+ # Checks Ruby version and git availability
20
+ def check
21
+ check_ruby_version
22
+ check_git
23
+ end
24
+
25
+ private
26
+
27
+ def check_ruby_version
28
+ report.add(:environment, status: :ok, message: "Ruby #{RUBY_VERSION}")
29
+ end
30
+
31
+ def check_git
32
+ stdout, _stderr, status = Open3.capture3('git --version')
33
+
34
+ unless status.success?
35
+ report.add(:environment, status: :warning,
36
+ message: 'Git not available',
37
+ detail: 'Git keywords (staged, modified, etc.) require git')
38
+ return
39
+ end
40
+
41
+ report.add(:environment, status: :ok, message: stdout.strip)
42
+ check_git_repo
43
+ end
44
+
45
+ def check_git_repo
46
+ _stdout, _stderr, status = Open3.capture3('git rev-parse --git-dir')
47
+
48
+ if status.success?
49
+ report.add(:environment, status: :ok, message: 'Inside a git repository')
50
+ else
51
+ report.add(:environment, status: :warning,
52
+ message: 'Not inside a git repository',
53
+ detail: 'Git keywords (staged, modified, etc.) will not work')
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../output/formatting'
4
+
5
+ module Reviewer
6
+ module Doctor
7
+ # Display logic for diagnostic reports
8
+ class Formatter
9
+ include Output::Formatting
10
+
11
+ attr_reader :output, :printer
12
+ private :output, :printer
13
+
14
+ SYMBOLS = { ok: "\u2713", warning: '!', error: "\u2717", info: "\u00b7", muted: "\u00b7" }.freeze
15
+ STYLES = { ok: :success, warning: :warning, error: :failure, info: :muted, muted: :muted }.freeze
16
+
17
+ SECTION_LABELS = {
18
+ configuration: 'Configuration',
19
+ tools: 'Tools',
20
+ opportunities: 'Opportunities',
21
+ environment: 'Environment'
22
+ }.freeze
23
+
24
+ # Creates a formatter for diagnostic report display
25
+ # @param output [Output] the console output handler
26
+ #
27
+ # @return [Formatter]
28
+ def initialize(output)
29
+ @output = output
30
+ @printer = output.printer
31
+ end
32
+
33
+ # Renders a full diagnostic report
34
+ # @param report [Doctor::Report] the report to display
35
+ def print(report)
36
+ output.newline
37
+ Doctor::Report::SECTIONS.each do |section|
38
+ findings = report.section(section)
39
+ print_section(section, findings) if findings.any?
40
+ end
41
+ print_summary(report)
42
+ end
43
+
44
+ private
45
+
46
+ def print_section(section, findings)
47
+ printer.puts(:bold, SECTION_LABELS.fetch(section) { section.to_s.capitalize })
48
+ findings.each { |finding| print_finding(finding) }
49
+ output.newline
50
+ end
51
+
52
+ def print_finding(finding)
53
+ status = finding.status
54
+ message = finding.message
55
+ detail = finding.detail
56
+
57
+ symbol = SYMBOLS.fetch(status) { ' ' }
58
+ style = STYLES.fetch(status) { :default }
59
+
60
+ printer.print(style, " #{symbol} ")
61
+ printer.puts(:default, message)
62
+ printer.puts(:muted, " #{detail}") if detail
63
+ end
64
+
65
+ def print_summary(report)
66
+ if report.ok?
67
+ printer.puts(:success, 'No issues found')
68
+ else
69
+ printer.puts(:failure, pluralize(report.errors.size, 'issue found', 'issues found'))
70
+ end
71
+ output.newline
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Detects conflicts between configured tool names, tags, and reserved keywords
6
+ class KeywordCheck
7
+ attr_reader :report
8
+
9
+ RESERVED = Arguments::Keywords::RESERVED
10
+
11
+ # Creates a keyword check that scans for keyword conflicts in configuration
12
+ # @param report [Doctor::Report] the report to add findings to
13
+ # @param configuration [Configuration] the configuration to check
14
+ # @param tools [Tools] the tools collection to analyze
15
+ #
16
+ # @return [KeywordCheck]
17
+ def initialize(report, configuration:, tools:)
18
+ @report = report
19
+ @configuration = configuration
20
+ @tools = tools
21
+ end
22
+
23
+ # Checks for keyword conflicts between tool names, tags, and reserved keywords
24
+ def check
25
+ return unless @configuration.file.exist?
26
+
27
+ check_tool_names_vs_reserved
28
+ check_tags_vs_reserved
29
+ check_tool_names_vs_tags
30
+ rescue Configuration::Loader::MissingConfigurationError
31
+ # Tools may reference a stale config path — nothing to check
32
+ end
33
+
34
+ private
35
+
36
+ def all_tools = @all_tools ||= @tools.all
37
+
38
+ def tool_names = all_tools.map { |tool| tool.key.to_s }
39
+
40
+ def all_tags = all_tools.flat_map(&:tags).uniq
41
+
42
+ # Names of tools that use a given tag
43
+ def tools_with_tag(tag)
44
+ all_tools.select { |tool| tool.tags.include?(tag) }.map(&:name)
45
+ end
46
+
47
+ # Tags that belong to tools OTHER than the named tool
48
+ def tags_from_other_tools(tool_name)
49
+ all_tools.reject { |tool| tool.key.to_s == tool_name }.flat_map(&:tags).uniq
50
+ end
51
+
52
+ def check_tool_names_vs_reserved
53
+ (tool_names & RESERVED).each do |name|
54
+ report.add(:configuration,
55
+ status: :warning,
56
+ message: "Tool name '#{name}' shadows reserved keyword '#{name}'",
57
+ detail: "Reserved keywords (#{RESERVED.join(', ')}) trigger special behavior. " \
58
+ 'Rename this tool to avoid unexpected results.')
59
+ end
60
+ end
61
+
62
+ def check_tags_vs_reserved
63
+ (all_tags & RESERVED).each do |tag|
64
+ report.add(:configuration,
65
+ status: :warning,
66
+ message: "Tag '#{tag}' shadows reserved keyword '#{tag}' (used by #{tools_with_tag(tag).join(', ')})",
67
+ detail: "Reserved keywords (#{RESERVED.join(', ')}) trigger special behavior. " \
68
+ 'Rename this tag or use -t to pass tags explicitly.')
69
+ end
70
+ end
71
+
72
+ def check_tool_names_vs_tags
73
+ tool_names.each do |name|
74
+ next unless tags_from_other_tools(name).include?(name)
75
+
76
+ report.add(:configuration,
77
+ status: :warning,
78
+ message: "Tool name '#{name}' is also a tag on: #{tools_with_tag(name).join(', ')}",
79
+ detail: "'rvw #{name}' will run both the '#{name}' tool and tagged tools. " \
80
+ 'Use -t to target tags explicitly if this is unintended.')
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Suggests improvements based on current configuration and project state
6
+ class OpportunityCheck
7
+ attr_reader :report, :project_dir
8
+
9
+ # Creates an opportunity checker that scans for unconfigured tools and missing features
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param project_dir [Pathname] the project root for tool detection
12
+ # @param configuration [Configuration] the configuration to check
13
+ # @param tools [Tools] the tools collection to analyze
14
+ #
15
+ # @return [OpportunityCheck]
16
+ def initialize(report, project_dir, configuration:, tools:)
17
+ @report = report
18
+ @project_dir = project_dir
19
+ @configuration = configuration
20
+ @tools = tools
21
+ end
22
+
23
+ # Checks for unconfigured tools, missing file targeting, and missing format commands
24
+ def check
25
+ return unless @configuration.file.exist?
26
+
27
+ check_unconfigured_tools
28
+ check_missing_files_config
29
+ check_missing_format_command
30
+ end
31
+
32
+ private
33
+
34
+ def check_unconfigured_tools
35
+ detected = Setup::Detector.new(project_dir).detect
36
+ configured_keys = @tools.all.map(&:key)
37
+
38
+ detected.each do |result|
39
+ next if configured_keys.include?(result.key)
40
+
41
+ report.add(:opportunities, status: :info,
42
+ message: "#{result.name} detected but not configured",
43
+ detail: result.reasons.join(', '))
44
+ end
45
+ end
46
+
47
+ def check_missing_files_config
48
+ @tools.all.each do |tool|
49
+ next if tool.skip_in_batch?
50
+ next if tool.supports_files?
51
+ next unless catalog_supports?(tool.key, :files)
52
+
53
+ report.add(:opportunities, status: :info,
54
+ message: "#{tool.name} has no file targeting configured",
55
+ detail: 'Add a `files` section to enable staged/modified file scoping')
56
+ end
57
+ end
58
+
59
+ def check_missing_format_command
60
+ @tools.all.each do |tool|
61
+ next if tool.skip_in_batch?
62
+ next if tool.formattable?
63
+ next unless catalog_supports?(tool.key, :format)
64
+
65
+ report.add(:opportunities, status: :info,
66
+ message: "#{tool.name} has no format command",
67
+ detail: 'Add a `format` command to enable `fmt` support')
68
+ end
69
+ end
70
+
71
+ # Returns true only if the catalog knows this tool AND the catalog entry
72
+ # includes the given capability (:files or :format command)
73
+ def catalog_supports?(key, capability)
74
+ entry = Setup::Catalog::TOOLS[key]
75
+ return false unless entry
76
+
77
+ case capability
78
+ when :files
79
+ entry.key?(:files)
80
+ when :format
81
+ entry.dig(:commands, :format) ? true : false
82
+ else
83
+ false
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Structured container for diagnostic findings organized by section
6
+ class Report
7
+ # A single diagnostic finding with status, message, and optional detail.
8
+ # @!attribute status [rw]
9
+ # @return [Symbol] the severity (:ok, :warning, :error, or :info)
10
+ # @!attribute message [rw]
11
+ # @return [String] the finding summary
12
+ # @!attribute detail [rw]
13
+ # @return [String, nil] optional detail or guidance text
14
+ Finding = Struct.new(:status, :message, :detail, keyword_init: true)
15
+
16
+ # Ordered list of report sections
17
+ SECTIONS = %i[configuration tools opportunities environment].freeze
18
+
19
+ attr_reader :findings
20
+
21
+ def initialize
22
+ @findings = Hash.new { |hash, key| hash[key] = [] }
23
+ end
24
+
25
+ # Adds a finding to a section
26
+ # @param section [Symbol] one of SECTIONS
27
+ # @param status [Symbol] :ok, :warning, :error, or :info
28
+ # @param message [String] the finding summary
29
+ # @param detail [String, nil] optional detail text
30
+ def add(section, status:, message:, detail: nil)
31
+ findings[section] << Finding.new(status: status, message: message, detail: detail)
32
+ end
33
+
34
+ # Whether all findings are free of errors
35
+ def ok?
36
+ all_findings.none? { |finding| finding.status == :error }
37
+ end
38
+
39
+ # All error findings across sections
40
+ def errors
41
+ all_findings.select { |finding| finding.status == :error }
42
+ end
43
+
44
+ # All warning findings across sections
45
+ def warnings
46
+ all_findings.select { |finding| finding.status == :warning }
47
+ end
48
+
49
+ # Findings for a specific section
50
+ # @param name [Symbol] the section name
51
+ # @return [Array<Finding>] findings for that section
52
+ def section(name)
53
+ findings[name]
54
+ end
55
+
56
+ private
57
+
58
+ def all_findings
59
+ findings.values.flatten
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ module Doctor
5
+ # Reports the status of each configured tool
6
+ class ToolInventory
7
+ attr_reader :report
8
+
9
+ # Creates a tool inventory check that reports batch/skip status for each tool
10
+ # @param report [Doctor::Report] the report to add findings to
11
+ # @param configuration [Configuration] the configuration to check
12
+ # @param tools [Tools] the tools collection to report on
13
+ #
14
+ # @return [ToolInventory]
15
+ def initialize(report, configuration:, tools:)
16
+ @report = report
17
+ @configuration = configuration
18
+ @tools = tools
19
+ end
20
+
21
+ # Reports batch/skip status and available commands for each configured tool
22
+ def check
23
+ return unless @configuration.file.exist?
24
+
25
+ @tools.all.each do |tool|
26
+ skipped = tool.skip_in_batch?
27
+
28
+ report.add(:tools,
29
+ status: skipped ? :muted : :ok,
30
+ message: "#{tool.name} (#{tool.key}) — #{command_summary(tool)}")
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def command_summary(tool)
37
+ %i[review format install prepare].select { |cmd| tool.command?(cmd) }.join(', ')
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'doctor/formatter'
4
+ require_relative 'doctor/report'
5
+ require_relative 'doctor/config_check'
6
+ require_relative 'doctor/keyword_check'
7
+ require_relative 'doctor/tool_inventory'
8
+ require_relative 'doctor/opportunity_check'
9
+ require_relative 'doctor/environment_check'
10
+
11
+ module Reviewer
12
+ # Diagnostic module for checking configuration, tools, and environment health
13
+ module Doctor
14
+ # Runs all diagnostic checks and returns a structured report
15
+ # @param project_dir [Pathname] the project root to scan
16
+ #
17
+ # @return [Doctor::Report] the complete diagnostic report
18
+ def self.run(configuration:, tools:, project_dir: Pathname.pwd)
19
+ report = Report.new
20
+ ConfigCheck.new(report, configuration: configuration).check
21
+ KeywordCheck.new(report, configuration: configuration, tools: tools).check
22
+ ToolInventory.new(report, configuration: configuration, tools: tools).check
23
+ OpportunityCheck.new(report, project_dir, configuration: configuration, tools: tools).check
24
+ EnvironmentCheck.new(report).check
25
+ report
26
+ end
27
+ end
28
+ end
@@ -3,36 +3,60 @@
3
3
  require 'yaml/store'
4
4
 
5
5
  module Reviewer
6
- # Provides an instance of a storage resource for persisting data across runs
6
+ # Provides an interface to a local storage resource for persisting data across runs. For example,
7
+ # it enables remembering when `prepare` commands were run for reviews so they can be run less
8
+ # frequently and thus improve performance.
9
+ #
10
+ # It also enables remembering seeds across runs. Eventually `rvw rerun` could reuse the seeds from
11
+ # the immediately preceding run to more easily facilitate fixing tests that are accidentally
12
+ # order-dependent. Or it could automatically record a list of seeds that led to failures.
13
+ #
14
+ # Long term, it could serve to record timing details across runs to provide insight to min, max,
15
+ # and means. Those times could then be used for reviewer to make more informed decisions about
16
+ # default behavior to ensure each run remains fast.
7
17
  class History
8
18
  attr_reader :file, :store
9
19
 
10
- def initialize(file = Reviewer.configuration.history_file)
20
+ # Creates an instance of a YAML::Store-backed history file
21
+ # @param file [Pathname] the history file to store data
22
+ #
23
+ # @return [History]
24
+ def initialize(file:)
11
25
  @file = file
12
26
  @store = YAML::Store.new(file)
13
27
  end
14
28
 
29
+ # Saves a value to a given location in the history
30
+ # @param group [Symbol] the first-level key to use for saving the value--frequently a tool name
31
+ # @param attribute [Symbol] the second-level key to use for retrieving the value
32
+ # @param value [Primitive] any value that can be cleanly stored in YAML
33
+ #
34
+ # @return [Primitive] the value being stored
15
35
  def set(group, attribute, value)
16
- store.transaction do |s|
17
- s[group] = {} if s[group].nil?
18
- s[group][attribute] = value
36
+ store.transaction do
37
+ store[group] ||= {}
38
+ store[group][attribute] = value
19
39
  end
20
40
  end
21
41
 
42
+ # Retrieves a stored value from the history file
43
+ # @param group [Symbol] the first-level key to use for retrieving the value
44
+ # @param attribute [Symbol] the second-level key to use for retrieving the value
45
+ #
46
+ # @return [Primitive] the value being stored
22
47
  def get(group, attribute)
23
- store.transaction do |s|
24
- s[group].nil? ? nil : s[group][attribute]
48
+ store.transaction(true) do
49
+ store[group]&.[](attribute)
25
50
  end
26
51
  end
27
52
 
28
- def reset!
53
+ # Removes the existing history file.
54
+ #
55
+ # @return [void]
56
+ def clear
29
57
  return unless File.exist?(file)
30
58
 
31
59
  FileUtils.rm(file)
32
60
  end
33
-
34
- def self.reset!
35
- new.reset!
36
- end
37
61
  end
38
62
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewer
4
+ class Output
5
+ # Shared display vocabulary included by all domain formatters.
6
+ # Provides common constants and helper methods for formatting output.
7
+ module Formatting
8
+ CHECKMARK = "\u2713"
9
+ XMARK = "\u2717"
10
+
11
+ private
12
+
13
+ # Formats a duration in seconds for display
14
+ # @param seconds [Float, nil] the duration to format
15
+ # @return [String] formatted duration (e.g., "1.23s")
16
+ def format_duration(seconds)
17
+ "#{seconds.to_f.round(2)}s"
18
+ end
19
+
20
+ # Returns the appropriate status mark
21
+ # @param success [Boolean] whether the operation succeeded
22
+ # @return [String] checkmark or x mark
23
+ def status_mark(success) = success ? CHECKMARK : XMARK
24
+
25
+ # Returns the appropriate style key for a status
26
+ # @param success [Boolean] whether the operation succeeded
27
+ # @return [Symbol] :success or :failure
28
+ def status_style(success) = success ? :success : :failure
29
+
30
+ # Pluralizes a word based on count
31
+ # @param count [Integer] the count
32
+ # @param singular [String] the singular form
33
+ # @param plural [String] the plural form (defaults to singular + "s")
34
+ # @return [String] formatted count with word (e.g., "1 issue" or "3 issues")
35
+ def pluralize(count, singular, plural = "#{singular}s")
36
+ count == 1 ? "1 #{singular}" : "#{count} #{plural}"
37
+ end
38
+ end
39
+ end
40
+ end