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.
- checksums.yaml +7 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/prompt-sources/ace-review.yml +36 -0
- data/.ace-defaults/nav/protocols/tmpl-sources/ace-review.yml +10 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-review.yml +19 -0
- data/.ace-defaults/review/config.yml +79 -0
- data/.ace-defaults/review/presets/code-fit.yml +64 -0
- data/.ace-defaults/review/presets/code-shine.yml +44 -0
- data/.ace-defaults/review/presets/code-valid.yml +39 -0
- data/.ace-defaults/review/presets/docs.yml +42 -0
- data/.ace-defaults/review/presets/spec.yml +37 -0
- data/CHANGELOG.md +1780 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-review +27 -0
- data/exe/ace-review-feedback +17 -0
- data/handbook/guides/code-review-process.g.md +234 -0
- data/handbook/prompts/base/sections.md +23 -0
- data/handbook/prompts/base/system.md +60 -0
- data/handbook/prompts/focus/architecture/atom.md +30 -0
- data/handbook/prompts/focus/architecture/reflection.md +60 -0
- data/handbook/prompts/focus/frameworks/rails.md +40 -0
- data/handbook/prompts/focus/frameworks/vue-firebase.md +45 -0
- data/handbook/prompts/focus/languages/ruby.md +50 -0
- data/handbook/prompts/focus/phase/correctness.md +51 -0
- data/handbook/prompts/focus/phase/polish.md +43 -0
- data/handbook/prompts/focus/phase/quality.md +42 -0
- data/handbook/prompts/focus/quality/performance.md +48 -0
- data/handbook/prompts/focus/quality/security.md +47 -0
- data/handbook/prompts/focus/scope/docs.md +38 -0
- data/handbook/prompts/focus/scope/spec.md +58 -0
- data/handbook/prompts/focus/scope/tests.md +36 -0
- data/handbook/prompts/format/compact.md +12 -0
- data/handbook/prompts/format/detailed.md +39 -0
- data/handbook/prompts/format/standard.md +16 -0
- data/handbook/prompts/guidelines/icons.md +19 -0
- data/handbook/prompts/guidelines/tone.md +21 -0
- data/handbook/prompts/synthesis-review-reports.system.md +318 -0
- data/handbook/prompts/synthesize-feedback.system.md +147 -0
- data/handbook/skills/as-review-apply-feedback/SKILL.md +39 -0
- data/handbook/skills/as-review-package/SKILL.md +36 -0
- data/handbook/skills/as-review-pr/SKILL.md +38 -0
- data/handbook/skills/as-review-run/SKILL.md +30 -0
- data/handbook/skills/as-review-verify-feedback/SKILL.md +31 -0
- data/handbook/templates/review-tasks/task-review-summary.template.md +148 -0
- data/handbook/workflow-instructions/review/apply-feedback.wf.md +212 -0
- data/handbook/workflow-instructions/review/package.wf.md +16 -0
- data/handbook/workflow-instructions/review/pr.wf.md +284 -0
- data/handbook/workflow-instructions/review/run.wf.md +262 -0
- data/handbook/workflow-instructions/review/verify-feedback.wf.md +286 -0
- data/lib/ace/review/atoms/context_limit_resolver.rb +162 -0
- data/lib/ace/review/atoms/diff_boundary_finder.rb +133 -0
- data/lib/ace/review/atoms/feedback_id_generator.rb +66 -0
- data/lib/ace/review/atoms/feedback_slug_generator.rb +61 -0
- data/lib/ace/review/atoms/feedback_state_validator.rb +98 -0
- data/lib/ace/review/atoms/pr_comment_formatter.rb +325 -0
- data/lib/ace/review/atoms/preset_validator.rb +103 -0
- data/lib/ace/review/atoms/priority_filter.rb +115 -0
- data/lib/ace/review/atoms/retry_with_backoff.rb +75 -0
- data/lib/ace/review/atoms/slug_generator.rb +50 -0
- data/lib/ace/review/atoms/token_estimator.rb +86 -0
- data/lib/ace/review/cli/commands/feedback/create.rb +173 -0
- data/lib/ace/review/cli/commands/feedback/list.rb +280 -0
- data/lib/ace/review/cli/commands/feedback/resolve.rb +109 -0
- data/lib/ace/review/cli/commands/feedback/session_discovery.rb +70 -0
- data/lib/ace/review/cli/commands/feedback/show.rb +177 -0
- data/lib/ace/review/cli/commands/feedback/skip.rb +125 -0
- data/lib/ace/review/cli/commands/feedback/verify.rb +149 -0
- data/lib/ace/review/cli/commands/feedback.rb +79 -0
- data/lib/ace/review/cli/commands/review.rb +378 -0
- data/lib/ace/review/cli/feedback_cli.rb +71 -0
- data/lib/ace/review/cli.rb +103 -0
- data/lib/ace/review/errors.rb +146 -0
- data/lib/ace/review/models/feedback_item.rb +216 -0
- data/lib/ace/review/models/review_options.rb +208 -0
- data/lib/ace/review/models/reviewer.rb +181 -0
- data/lib/ace/review/molecules/context_composer.rb +123 -0
- data/lib/ace/review/molecules/context_extractor.rb +159 -0
- data/lib/ace/review/molecules/feedback_directory_manager.rb +183 -0
- data/lib/ace/review/molecules/feedback_file_reader.rb +178 -0
- data/lib/ace/review/molecules/feedback_file_writer.rb +210 -0
- data/lib/ace/review/molecules/feedback_synthesizer.rb +588 -0
- data/lib/ace/review/molecules/gh_cli_executor.rb +124 -0
- data/lib/ace/review/molecules/gh_comment_poster.rb +205 -0
- data/lib/ace/review/molecules/gh_comment_resolver.rb +199 -0
- data/lib/ace/review/molecules/gh_pr_comment_fetcher.rb +408 -0
- data/lib/ace/review/molecules/gh_pr_fetcher.rb +240 -0
- data/lib/ace/review/molecules/llm_executor.rb +142 -0
- data/lib/ace/review/molecules/multi_model_executor.rb +278 -0
- data/lib/ace/review/molecules/nav_prompt_resolver.rb +145 -0
- data/lib/ace/review/molecules/pr_task_spec_resolver.rb +58 -0
- data/lib/ace/review/molecules/preset_manager.rb +494 -0
- data/lib/ace/review/molecules/prompt_composer.rb +76 -0
- data/lib/ace/review/molecules/prompt_resolver.rb +168 -0
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +193 -0
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +459 -0
- data/lib/ace/review/molecules/strategies/full_strategy.rb +114 -0
- data/lib/ace/review/molecules/subject_extractor.rb +315 -0
- data/lib/ace/review/molecules/subject_filter.rb +199 -0
- data/lib/ace/review/molecules/subject_strategy.rb +96 -0
- data/lib/ace/review/molecules/task_report_saver.rb +161 -0
- data/lib/ace/review/molecules/task_resolver.rb +48 -0
- data/lib/ace/review/organisms/feedback_manager.rb +386 -0
- data/lib/ace/review/organisms/review_manager.rb +1059 -0
- data/lib/ace/review/version.rb +7 -0
- data/lib/ace/review.rb +135 -0
- 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
|