ace-review 0.49.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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
  3. data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
  4. data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
  5. data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
  6. data/.ace-defaults/review/config.yml +79 -0
  7. data/.ace-defaults/review/presets/code-fit.yml +64 -0
  8. data/.ace-defaults/review/presets/code-shine.yml +44 -0
  9. data/.ace-defaults/review/presets/code-valid.yml +39 -0
  10. data/.ace-defaults/review/presets/docs.yml +42 -0
  11. data/.ace-defaults/review/presets/spec.yml +37 -0
  12. data/CHANGELOG.md +1780 -0
  13. data/LICENSE +21 -0
  14. data/README.md +42 -0
  15. data/Rakefile +14 -0
  16. data/exe/ace-review +27 -0
  17. data/exe/ace-review-feedback +17 -0
  18. data/handbook/guides/code-review-process.g.md +234 -0
  19. data/handbook/prompts/base/sections.md +23 -0
  20. data/handbook/prompts/base/system.md +60 -0
  21. data/handbook/prompts/focus/architecture/atom.md +30 -0
  22. data/handbook/prompts/focus/architecture/reflection.md +60 -0
  23. data/handbook/prompts/focus/frameworks/rails.md +40 -0
  24. data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
  25. data/handbook/prompts/focus/languages/ruby.md +50 -0
  26. data/handbook/prompts/focus/phase/correctness.md +51 -0
  27. data/handbook/prompts/focus/phase/polish.md +43 -0
  28. data/handbook/prompts/focus/phase/quality.md +42 -0
  29. data/handbook/prompts/focus/quality/performance.md +48 -0
  30. data/handbook/prompts/focus/quality/security.md +47 -0
  31. data/handbook/prompts/focus/scope/docs.md +38 -0
  32. data/handbook/prompts/focus/scope/spec.md +58 -0
  33. data/handbook/prompts/focus/scope/tests.md +36 -0
  34. data/handbook/prompts/format/compact.md +12 -0
  35. data/handbook/prompts/format/detailed.md +39 -0
  36. data/handbook/prompts/format/standard.md +16 -0
  37. data/handbook/prompts/guidelines/icons.md +19 -0
  38. data/handbook/prompts/guidelines/tone.md +21 -0
  39. data/handbook/prompts/synthesis-review-reports.system.md +318 -0
  40. data/handbook/prompts/synthesize-feedback.system.md +147 -0
  41. data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
  42. data/handbook/skills/as-review-package/SKILL.md +36 -0
  43. data/handbook/skills/as-review-pr/SKILL.md +38 -0
  44. data/handbook/skills/as-review-run/SKILL.md +30 -0
  45. data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
  46. data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
  47. data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
  48. data/handbook/workflow-instructions/review/package.wf.md +16 -0
  49. data/handbook/workflow-instructions/review/pr.wf.md +284 -0
  50. data/handbook/workflow-instructions/review/run.wf.md +262 -0
  51. data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
  52. data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
  53. data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
  54. data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
  55. data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
  56. data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
  57. data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
  58. data/lib/ace/review/atoms/preset_validator.rb +103 -0
  59. data/lib/ace/review/atoms/priority_filter.rb +115 -0
  60. data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
  61. data/lib/ace/review/atoms/slug_generator.rb +50 -0
  62. data/lib/ace/review/atoms/token_estimator.rb +86 -0
  63. data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
  64. data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
  65. data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
  66. data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
  67. data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
  68. data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
  69. data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
  70. data/lib/ace/review/cli/commands/feedback.rb +79 -0
  71. data/lib/ace/review/cli/commands/review.rb +378 -0
  72. data/lib/ace/review/cli/feedback_cli.rb +71 -0
  73. data/lib/ace/review/cli.rb +103 -0
  74. data/lib/ace/review/errors.rb +146 -0
  75. data/lib/ace/review/models/feedback_item.rb +216 -0
  76. data/lib/ace/review/models/review_options.rb +208 -0
  77. data/lib/ace/review/models/reviewer.rb +181 -0
  78. data/lib/ace/review/molecules/context_composer.rb +123 -0
  79. data/lib/ace/review/molecules/context_extractor.rb +159 -0
  80. data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
  81. data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
  82. data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
  83. data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
  84. data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
  85. data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
  86. data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
  87. data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
  88. data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
  89. data/lib/ace/review/molecules/llm_executor.rb +142 -0
  90. data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
  91. data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
  92. data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
  93. data/lib/ace/review/molecules/preset_manager.rb +494 -0
  94. data/lib/ace/review/molecules/prompt_composer.rb +76 -0
  95. data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
  96. data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
  97. data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
  98. data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
  99. data/lib/ace/review/molecules/subject_extractor.rb +315 -0
  100. data/lib/ace/review/molecules/subject_filter.rb +199 -0
  101. data/lib/ace/review/molecules/subject_strategy.rb +96 -0
  102. data/lib/ace/review/molecules/task_report_saver.rb +161 -0
  103. data/lib/ace/review/molecules/task_resolver.rb +48 -0
  104. data/lib/ace/review/organisms/feedback_manager.rb +386 -0
  105. data/lib/ace/review/organisms/review_manager.rb +1059 -0
  106. data/lib/ace/review/version.rb +7 -0
  107. data/lib/ace/review.rb +135 -0
  108. metadata +351 -0
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Review
7
+ module Molecules
8
+ # Composes context.md files with YAML frontmatter for ace-bundle integration
9
+ # Follows the pattern from ace-docs DocumentAnalysisPrompt
10
+ class ContextComposer
11
+ # Create context.md with YAML frontmatter for review context
12
+ # @param base_instructions [String] Base instructions for the context
13
+ # @param context_config [Hash] Context configuration (presets, files, diffs, commands)
14
+ # @param subject_config [Hash, nil] Optional subject configuration for scope section
15
+ # @return [String] Complete context.md content with YAML frontmatter
16
+ def self.create_context_md(base_instructions, context_config, subject_config = nil)
17
+ # Normalize context configuration following ace-docs pattern
18
+ normalized_config = normalize_context_config(context_config)
19
+
20
+ frontmatter = {"bundle" => normalized_config}
21
+
22
+ # Build review scope section if subject config provided
23
+ scope_section = build_review_scope_section(subject_config) if subject_config
24
+
25
+ # context.md = frontmatter + base instructions + scope section
26
+ # YAML.dump adds opening --- but not closing, we add closing ---
27
+ "#{YAML.dump(frontmatter).strip}\n---\n\n#{base_instructions}\n\n#{scope_section}".strip
28
+ end
29
+
30
+ # Save context.md to specified directory
31
+ # @param context_md [String] The context.md content
32
+ # @param cache_dir [String] Directory to save context.md
33
+ # @return [String] Path to saved context.md file
34
+ def self.save_context_md(context_md, cache_dir)
35
+ context_file_path = File.join(cache_dir, "context.md")
36
+ File.write(context_file_path, context_md)
37
+ context_file_path
38
+ end
39
+
40
+ # Load context.md via ace-bundle
41
+ # @param context_file_path [String] Path to context.md file
42
+ # @return [String] Content with embedded files and context
43
+ def self.load_context_via_ace_bundle(context_file_path)
44
+ require "ace/bundle"
45
+
46
+ # Use ace-bundle to load context.md - processes presets and files from frontmatter
47
+ result = Ace::Bundle.load_file(context_file_path)
48
+ result.content
49
+ rescue LoadError
50
+ raise Ace::Review::Errors::ContextComposerError, "ace-bundle not available - required for context.md pattern"
51
+ rescue => e
52
+ raise Ace::Review::Errors::ContextComposerError, "ace-bundle loading failed: #{e.message}"
53
+ end
54
+
55
+ private
56
+
57
+ # Normalize context configuration to match ace-docs pattern
58
+ # @param config [Hash] Raw context configuration
59
+ # @return [Hash] Normalized configuration
60
+ def self.normalize_context_config(config)
61
+ # Start with base context config following ace-docs pattern
62
+ normalized = {
63
+ "params" => {"format" => "markdown-xml"},
64
+ "embed_document_source" => true
65
+ }
66
+
67
+ # Merge with provided config
68
+ config ||= {}
69
+ normalized.merge!(config)
70
+
71
+ # Ensure arrays are properly initialized
72
+ normalized["presets"] ||= []
73
+ normalized["files"] ||= []
74
+ normalized["diffs"] ||= []
75
+ normalized["commands"] ||= []
76
+
77
+ normalized
78
+ end
79
+
80
+ # Build review scope section explaining what will be reviewed
81
+ # @param subject_config [Hash] Subject configuration
82
+ # @return [String] Review scope section
83
+ def self.build_review_scope_section(subject_config)
84
+ return "" unless subject_config
85
+
86
+ # Extract subject description
87
+ subject_desc = extract_subject_description(subject_config)
88
+
89
+ <<~SECTION
90
+ ## Review Scope
91
+
92
+ **Subject of review**:
93
+ #{subject_desc}
94
+ SECTION
95
+ end
96
+
97
+ # Extract human-readable description from subject configuration
98
+ # @param subject_config [Hash] Subject configuration
99
+ # @return [String] Description string
100
+ def self.extract_subject_description(subject_config)
101
+ if subject_config["diff"]
102
+ "- Git diff changes"
103
+ elsif subject_config["files"]
104
+ files = subject_config["files"]
105
+ if files.is_a?(Array)
106
+ if files.length == 1
107
+ "- File: `#{files.first}`"
108
+ else
109
+ "- Files: #{files.map { |f| "`#{f}`" }.join(", ")}"
110
+ end
111
+ else
112
+ "- File: `#{files}`"
113
+ end
114
+ elsif subject_config["content"]
115
+ "- Inline content"
116
+ else
117
+ "- Repository changes"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Review
7
+ module Molecules
8
+ # Extracts context (background information) for reviews
9
+ # Delegates to ContextComposer for context.md pattern and ace-bundle integration
10
+ class ContextExtractor
11
+ def initialize
12
+ @preset_manager = nil # Lazy load to avoid circular dependency
13
+ end
14
+
15
+ # Extract context from configuration
16
+ # @param context_config [String, Hash, nil] context configuration
17
+ # @param cache_dir [String, nil] Optional cache directory for context.md
18
+ # @return [String] extracted context content
19
+ def extract(context_config, cache_dir = nil)
20
+ case context_config
21
+ when nil, "none", false
22
+ ""
23
+ when "project", "auto", true
24
+ extract_project_context(cache_dir)
25
+ when String
26
+ extract_from_string(context_config, cache_dir)
27
+ when Hash
28
+ extract_from_hash(context_config, cache_dir)
29
+ else
30
+ ""
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def extract_from_string(input, cache_dir = nil)
37
+ # Try to parse as YAML first
38
+ begin
39
+ parsed = YAML.safe_load(input)
40
+ return extract_from_hash(parsed, cache_dir) if parsed.is_a?(Hash)
41
+ rescue Psych::SyntaxError
42
+ # Continue with string processing
43
+ end
44
+
45
+ # Check if it's an ace-review preset name
46
+ if preset_context = load_preset_context(input)
47
+ return extract(preset_context, cache_dir)
48
+ end
49
+
50
+ # Check if it's an ace-bundle preset
51
+ if ace_bundle_preset_exists?(input)
52
+ return use_context_composer({"presets" => [input]}, cache_dir)
53
+ end
54
+
55
+ # Treat as file path
56
+ use_context_composer({"files" => [input]}, cache_dir)
57
+ end
58
+
59
+ def extract_from_hash(config, cache_dir = nil)
60
+ # Check for ace-review preset reference
61
+ if config["preset"]
62
+ if preset_context = load_preset_context(config["preset"])
63
+ return extract(preset_context, cache_dir)
64
+ end
65
+ end
66
+
67
+ # Delegate to ContextComposer for context.md pattern
68
+ use_context_composer(config, cache_dir)
69
+ end
70
+
71
+ def extract_project_context(cache_dir = nil)
72
+ # Build list of existing project docs from config (ADR-022 pattern)
73
+ project_docs = Ace::Review.get("project_docs") || default_project_docs
74
+ existing_docs = project_docs.select { |path| File.exist?(path) }
75
+
76
+ if existing_docs.empty?
77
+ # If no standard docs found, try to find any markdown files
78
+ existing_docs = Dir.glob("{*.md,docs/*.md}").first(3)
79
+ end
80
+
81
+ return "" if existing_docs.empty?
82
+
83
+ # Use ContextComposer to load all docs
84
+ use_context_composer({"files" => existing_docs}, cache_dir)
85
+ end
86
+
87
+ # Fallback defaults used only when Ace::Review.get("project_docs") returns nil
88
+ # (e.g., config cascade initialization fails). Primary source is
89
+ # .ace-defaults/review/config.yml project_docs - keep these in sync.
90
+ def default_project_docs
91
+ %w[
92
+ README.md
93
+ docs/architecture.md
94
+ docs/vision.md
95
+ docs/blueprint.md
96
+ .github/CONTRIBUTING.md
97
+ ARCHITECTURE.md
98
+ ]
99
+ end
100
+
101
+ def load_preset_context(preset_name)
102
+ # Lazy load preset manager for ace-review presets
103
+ @preset_manager ||= PresetManager.new
104
+
105
+ preset = @preset_manager.load_preset(preset_name)
106
+ preset&.dig("bundle")
107
+ end
108
+
109
+ def ace_bundle_preset_exists?(preset_name)
110
+ # Check if this is a valid ace-bundle preset
111
+ Ace::Bundle.list_presets.any? { |p| p[:name] == preset_name }
112
+ rescue => e
113
+ warn "ace-bundle preset check failed: #{e.message}" if Ace::Review.debug?
114
+ raise e if ENV["ACE_TEST_STRICT"]
115
+ false
116
+ end
117
+
118
+ def use_context_composer(config, cache_dir = nil)
119
+ require_relative "context_composer"
120
+
121
+ base_instructions = "Load context for code review analysis."
122
+
123
+ # Create context.md content
124
+ context_md = Ace::Review::Molecules::ContextComposer.create_context_md(
125
+ base_instructions,
126
+ config
127
+ )
128
+
129
+ # If cache_dir provided, save context.md and load via ace-bundle
130
+ if cache_dir
131
+ context_file_path = Ace::Review::Molecules::ContextComposer.save_context_md(
132
+ context_md,
133
+ cache_dir
134
+ )
135
+
136
+ # Load via ace-bundle for embedded content
137
+ Ace::Review::Molecules::ContextComposer.load_context_via_ace_bundle(context_file_path)
138
+ else
139
+ # Fallback to direct ace-bundle loading without file
140
+ begin
141
+ require "ace/bundle"
142
+ result = Ace::Bundle.load_auto(context_md, format: "markdown")
143
+ result.content
144
+ rescue => e
145
+ warn "ace-bundle extraction failed: #{e.message}" if Ace::Review.debug?
146
+ ""
147
+ end
148
+ end
149
+ rescue Ace::Review::Errors::ContextComposerError => e
150
+ # Fail-fast error handling for ace-bundle failures
151
+ raise ContextExtractorError, "Context extraction failed: #{e.message}"
152
+ end
153
+
154
+ # Custom error class for ContextExtractor failures
155
+ class ContextExtractorError < StandardError; end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Review
7
+ module Molecules
8
+ # Manages feedback directory structure and file organization.
9
+ #
10
+ # Handles creation of feedback directories, archiving of resolved items,
11
+ # and listing of feedback files.
12
+ #
13
+ # Directory structure:
14
+ # {base_path}/
15
+ # feedback/
16
+ # {id}-{slug}.s.md
17
+ # {id}-{slug}.s.md
18
+ # _archived/
19
+ # {id}-{slug}.s.md
20
+ #
21
+ # @example Ensure directories exist
22
+ # manager = FeedbackDirectoryManager.new
23
+ # manager.ensure_directory("/project") #=> "/project/feedback"
24
+ # manager.ensure_archive("/project") #=> "/project/feedback/_archived"
25
+ #
26
+ # @example Archive a resolved item
27
+ # manager.archive("/project/feedback/abc123-bug-fix.s.md")
28
+ # #=> { success: true, path: "/project/feedback/_archived/abc123-bug-fix.s.md" }
29
+ #
30
+ class FeedbackDirectoryManager
31
+ # Subdirectory name for feedback files
32
+ FEEDBACK_DIR = "feedback"
33
+
34
+ # Subdirectory name for archived files
35
+ ARCHIVE_DIR = "_archived"
36
+
37
+ # File extension for feedback files
38
+ FILE_EXTENSION = ".s.md"
39
+
40
+ # Get the feedback directory path for a base path
41
+ #
42
+ # @param base_path [String] The base project path
43
+ # @return [String] The feedback directory path
44
+ def feedback_path(base_path)
45
+ File.join(base_path, FEEDBACK_DIR)
46
+ end
47
+
48
+ # Get the archive directory path for a base path
49
+ #
50
+ # @param base_path [String] The base project path
51
+ # @return [String] The archive directory path
52
+ def archive_path(base_path)
53
+ File.join(base_path, FEEDBACK_DIR, ARCHIVE_DIR)
54
+ end
55
+
56
+ # Ensure the feedback directory exists
57
+ #
58
+ # @param base_path [String] The base project path
59
+ # @return [String] The feedback directory path
60
+ def ensure_directory(base_path)
61
+ path = feedback_path(base_path)
62
+ FileUtils.mkdir_p(path)
63
+ path
64
+ end
65
+
66
+ # Ensure the archive directory exists
67
+ #
68
+ # @param base_path [String] The base project path
69
+ # @return [String] The archive directory path
70
+ def ensure_archive(base_path)
71
+ path = archive_path(base_path)
72
+ FileUtils.mkdir_p(path)
73
+ path
74
+ end
75
+
76
+ # Archive a feedback file by moving it to the _archived subdirectory
77
+ #
78
+ # @param file_path [String] Path to the feedback file to archive
79
+ # @return [Hash] Result with :success and :path or :error
80
+ def archive(file_path)
81
+ validate_archive_inputs(file_path)
82
+
83
+ # Determine the archive destination
84
+ feedback_dir = File.dirname(file_path)
85
+ archive_dir = File.join(feedback_dir, ARCHIVE_DIR)
86
+ filename = File.basename(file_path)
87
+ dest_path = File.join(archive_dir, filename)
88
+
89
+ # Ensure archive directory exists
90
+ FileUtils.mkdir_p(archive_dir)
91
+
92
+ # Move file to archive
93
+ FileUtils.mv(file_path, dest_path)
94
+
95
+ {success: true, path: dest_path}
96
+ rescue Errno::ENOENT
97
+ {success: false, error: "File not found: #{file_path}"}
98
+ rescue Errno::EACCES
99
+ {success: false, error: "Permission denied: #{file_path}"}
100
+ rescue => e
101
+ {success: false, error: "Failed to archive file: #{e.message}"}
102
+ end
103
+
104
+ # List all feedback files in a directory
105
+ #
106
+ # @param directory [String] The feedback directory to list
107
+ # @param include_archived [Boolean] Whether to include archived files (default: false)
108
+ # @return [Array<String>] Array of file paths
109
+ def list_files(directory, include_archived: false)
110
+ return [] unless Dir.exist?(directory)
111
+
112
+ files = []
113
+
114
+ # List files in main directory (excluding _archived subdirectory)
115
+ main_files = Dir.glob(File.join(directory, "*#{FILE_EXTENSION}"))
116
+ files.concat(main_files)
117
+
118
+ # Include archived files if requested
119
+ if include_archived
120
+ archive_dir = File.join(directory, ARCHIVE_DIR)
121
+ if Dir.exist?(archive_dir)
122
+ archived_files = Dir.glob(File.join(archive_dir, "*#{FILE_EXTENSION}"))
123
+ files.concat(archived_files)
124
+ end
125
+ end
126
+
127
+ files.sort
128
+ end
129
+
130
+ # Check if a feedback directory exists
131
+ #
132
+ # @param base_path [String] The base project path
133
+ # @return [Boolean] True if feedback directory exists
134
+ def exists?(base_path)
135
+ Dir.exist?(feedback_path(base_path))
136
+ end
137
+
138
+ # Check if the archive directory exists
139
+ #
140
+ # @param base_path [String] The base project path
141
+ # @return [Boolean] True if archive directory exists
142
+ def archive_exists?(base_path)
143
+ Dir.exist?(archive_path(base_path))
144
+ end
145
+
146
+ # Count feedback files in a directory
147
+ #
148
+ # @param directory [String] The feedback directory
149
+ # @param include_archived [Boolean] Whether to include archived files
150
+ # @return [Hash] Counts with :active, :archived, and :total keys
151
+ def count_files(directory)
152
+ return {active: 0, archived: 0, total: 0} unless Dir.exist?(directory)
153
+
154
+ active = Dir.glob(File.join(directory, "*#{FILE_EXTENSION}")).count
155
+ archived = 0
156
+
157
+ archive_dir = File.join(directory, ARCHIVE_DIR)
158
+ if Dir.exist?(archive_dir)
159
+ archived = Dir.glob(File.join(archive_dir, "*#{FILE_EXTENSION}")).count
160
+ end
161
+
162
+ {active: active, archived: archived, total: active + archived}
163
+ end
164
+
165
+ private
166
+
167
+ # Validate inputs for archive operation
168
+ #
169
+ # @param file_path [String] The file path to validate
170
+ # @raise [ArgumentError] If inputs are invalid
171
+ def validate_archive_inputs(file_path)
172
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.empty?
173
+
174
+ unless file_path.end_with?(FILE_EXTENSION)
175
+ raise ArgumentError, "file must have #{FILE_EXTENSION} extension"
176
+ end
177
+
178
+ raise ArgumentError, "file does not exist: #{file_path}" unless File.exist?(file_path)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Review
7
+ module Molecules
8
+ # Reads feedback files from disk and parses them into FeedbackItem instances.
9
+ #
10
+ # Handles YAML frontmatter parsing and markdown section extraction.
11
+ # Returns error hashes for malformed files rather than raising exceptions.
12
+ #
13
+ # @example Read a single file
14
+ # reader = FeedbackFileReader.new
15
+ # result = reader.read("/path/to/8o7abc-missing-error.s.md")
16
+ # result[:success] #=> true
17
+ # result[:feedback_item] #=> FeedbackItem instance
18
+ #
19
+ # @example Read all files in a directory
20
+ # reader = FeedbackFileReader.new
21
+ # results = reader.read_all("/path/to/feedback")
22
+ # results #=> [FeedbackItem, FeedbackItem, ...]
23
+ #
24
+ class FeedbackFileReader
25
+ # YAML frontmatter pattern: content between --- markers at start of file
26
+ FRONTMATTER_PATTERN = /\A---\n(.*?)\n---\n/m
27
+
28
+ # Section header pattern: ## Section Name
29
+ SECTION_PATTERN = /^## (\w+)\n/
30
+
31
+ # Read a single feedback file
32
+ #
33
+ # @param file_path [String] Path to the .s.md file
34
+ # @return [Hash] Result with :success and :feedback_item or :error
35
+ def read(file_path)
36
+ validate_file_path(file_path)
37
+
38
+ content = File.read(file_path)
39
+ parse_content(content, file_path)
40
+ rescue Errno::ENOENT
41
+ {success: false, error: "File not found: #{file_path}"}
42
+ rescue Errno::EACCES
43
+ {success: false, error: "Permission denied: #{file_path}"}
44
+ rescue ArgumentError => e
45
+ {success: false, error: e.message}
46
+ rescue SystemCallError, IOError => e
47
+ {success: false, error: "Failed to read file: #{e.message}"}
48
+ end
49
+
50
+ # Read all .s.md files in a directory
51
+ #
52
+ # @param directory [String] Path to the feedback directory
53
+ # @return [Array<Models::FeedbackItem>] Array of successfully parsed items
54
+ def read_all(directory)
55
+ return [] unless Dir.exist?(directory)
56
+
57
+ files = Dir.glob(File.join(directory, "*.s.md"))
58
+ items = []
59
+
60
+ files.each do |file_path|
61
+ result = read(file_path)
62
+ items << result[:feedback_item] if result[:success]
63
+ end
64
+
65
+ items
66
+ end
67
+
68
+ # Read feedback files filtered by status
69
+ #
70
+ # @param directory [String] Path to the feedback directory
71
+ # @param status [String] Status to filter by (draft, pending, invalid, skip, done)
72
+ # @return [Array<Models::FeedbackItem>] Array of matching items
73
+ def read_by_status(directory, status)
74
+ read_all(directory).select { |item| item.status == status }
75
+ end
76
+
77
+ private
78
+
79
+ # Validate the file path
80
+ #
81
+ # @param file_path [String] The file path to validate
82
+ # @raise [ArgumentError] If path is invalid
83
+ def validate_file_path(file_path)
84
+ raise ArgumentError, "file_path is required" if file_path.nil? || file_path.empty?
85
+ end
86
+
87
+ # Parse file content into a FeedbackItem
88
+ #
89
+ # @param content [String] The file content
90
+ # @param file_path [String] The file path (for error messages)
91
+ # @return [Hash] Result with :success and :feedback_item or :error
92
+ def parse_content(content, file_path)
93
+ # Extract frontmatter
94
+ frontmatter_match = content.match(FRONTMATTER_PATTERN)
95
+ unless frontmatter_match
96
+ return {success: false, error: "Missing YAML frontmatter in: #{file_path}"}
97
+ end
98
+
99
+ frontmatter_yaml = frontmatter_match[1]
100
+ body = content[frontmatter_match.end(0)..]
101
+
102
+ # Parse YAML frontmatter
103
+ frontmatter = parse_frontmatter(frontmatter_yaml, file_path)
104
+ return frontmatter if frontmatter[:error]
105
+
106
+ # Parse markdown sections
107
+ sections = parse_sections(body)
108
+
109
+ # Build FeedbackItem attributes
110
+ attrs = frontmatter[:data].merge(sections)
111
+
112
+ # Create FeedbackItem
113
+ feedback_item = Models::FeedbackItem.new(attrs)
114
+ {success: true, feedback_item: feedback_item}
115
+ rescue ArgumentError => e
116
+ {success: false, error: "Invalid feedback item in #{file_path}: #{e.message}"}
117
+ rescue Psych::SyntaxError, TypeError, KeyError => e
118
+ {success: false, error: "Failed to parse #{file_path}: #{e.message}"}
119
+ end
120
+
121
+ # Parse YAML frontmatter
122
+ #
123
+ # @param yaml_content [String] The YAML content
124
+ # @param file_path [String] The file path (for error messages)
125
+ # @return [Hash] Result with :data or :error
126
+ def parse_frontmatter(yaml_content, file_path)
127
+ data = YAML.safe_load(yaml_content, permitted_classes: [Time, Date])
128
+
129
+ unless data.is_a?(Hash)
130
+ return {error: "Invalid YAML frontmatter in #{file_path}: expected Hash"}
131
+ end
132
+
133
+ {data: data}
134
+ rescue Psych::SyntaxError => e
135
+ {error: "YAML syntax error in #{file_path}: #{e.message}"}
136
+ end
137
+
138
+ # Parse markdown sections from the body
139
+ #
140
+ # @param body [String] The markdown body after frontmatter
141
+ # @return [Hash] Hash with section names as keys and content as values
142
+ def parse_sections(body)
143
+ sections = {}
144
+ return sections if body.nil? || body.empty?
145
+
146
+ # Split by section headers
147
+ parts = body.split(SECTION_PATTERN)
148
+
149
+ # First part is any content before first section (usually empty)
150
+ parts.shift if parts.first&.strip&.empty?
151
+
152
+ # Process pairs of (section_name, content)
153
+ parts.each_slice(2) do |name, content|
154
+ next unless name && content
155
+
156
+ key = section_name_to_key(name)
157
+ sections[key] = content.strip if key
158
+ end
159
+
160
+ sections
161
+ end
162
+
163
+ # Convert section name to attribute key
164
+ #
165
+ # @param name [String] The section name (e.g., "Finding", "Context")
166
+ # @return [String, nil] The attribute key or nil if not recognized
167
+ def section_name_to_key(name)
168
+ case name.downcase
169
+ when "finding" then "finding"
170
+ when "context" then "context"
171
+ when "research" then "research"
172
+ when "resolution" then "resolution"
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end