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,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Atoms
6
+ # Pure function for resolving model context limits
7
+ #
8
+ # Maps model names to their context window sizes. Handles provider prefixes
9
+ # (google:, anthropic:, openai:) and uses pattern matching for model families.
10
+ #
11
+ # Resolution order:
12
+ # 1. ace-llm provider config (context_limit in providers/*.yml)
13
+ # 2. Hardcoded pattern matching (MODEL_LIMITS)
14
+ # 3. Conservative default for unknown models
15
+ #
16
+ # @example Basic usage
17
+ # ContextLimitResolver.resolve("google:gemini-2.5-pro")
18
+ # #=> 1_000_000
19
+ #
20
+ # @example Without provider prefix
21
+ # ContextLimitResolver.resolve("claude-3-sonnet")
22
+ # #=> 200_000
23
+ module ContextLimitResolver
24
+ # Conservative default for unknown models
25
+ DEFAULT_LIMIT = 200_000
26
+
27
+ # Model patterns and their context limits (fallback when ace-llm config unavailable)
28
+ # Order matters - first match wins
29
+ # Patterns use regex for flexible matching
30
+ MODEL_LIMITS = [
31
+ # Gemini models
32
+ {pattern: /gemini-1\.5-pro/i, limit: 2_000_000},
33
+ {pattern: /gemini-1\.5-flash/i, limit: 1_000_000},
34
+ {pattern: /gemini-2\.5-pro/i, limit: 1_000_000},
35
+ {pattern: /gemini-2\.5-flash/i, limit: 1_000_000},
36
+ {pattern: /gemini-2\.0/i, limit: 1_000_000},
37
+ # Fallback for any other gemini model
38
+ {pattern: /gemini/i, limit: 1_000_000},
39
+
40
+ # Claude models (all variants: opus, sonnet, haiku)
41
+ {pattern: /claude.*opus/i, limit: 1_000_000},
42
+ {pattern: /claude.*sonnet/i, limit: 1_000_000},
43
+ {pattern: /claude.*haiku/i, limit: 1_000_000},
44
+ # Fallback for any other claude model
45
+ {pattern: /claude/i, limit: 1_000_000},
46
+
47
+ # OpenAI models
48
+ {pattern: /gpt-5\.\d/i, limit: 1_050_000},
49
+ {pattern: /o4-/i, limit: 1_050_000},
50
+ {pattern: /gpt-4o/i, limit: 128_000},
51
+ {pattern: /gpt-4-turbo/i, limit: 128_000},
52
+ {pattern: /gpt-4-32k/i, limit: 32_768},
53
+ {pattern: /gpt-4-\d+-preview/i, limit: 128_000}, # gpt-4-1106-preview, gpt-4-0125-preview
54
+ {pattern: /gpt-4-\d+$/i, limit: 8_192}, # legacy gpt-4-0613, etc.
55
+ {pattern: /gpt-4$/i, limit: 8_192}, # base gpt-4 model
56
+ {pattern: /o1-/i, limit: 200_000},
57
+ {pattern: /o3-/i, limit: 200_000}
58
+ ].freeze
59
+
60
+ # Resolve context limit for a model
61
+ #
62
+ # Resolution order:
63
+ # 1. ace-llm provider config (if available and provider specified)
64
+ # 2. Hardcoded pattern matching
65
+ # 3. Default limit
66
+ #
67
+ # @param model_name [String, nil] Model identifier, optionally with provider prefix
68
+ # @return [Integer] Context limit in tokens
69
+ #
70
+ # @example With provider prefix
71
+ # ContextLimitResolver.resolve("google:gemini-2.5-pro")
72
+ # #=> 1_000_000
73
+ #
74
+ # @example Without provider prefix
75
+ # ContextLimitResolver.resolve("claude-3-opus")
76
+ # #=> 200_000
77
+ #
78
+ # @example Unknown model
79
+ # ContextLimitResolver.resolve("unknown-model")
80
+ # #=> 128_000
81
+ def self.resolve(model_name)
82
+ return DEFAULT_LIMIT if model_name.nil? || model_name.empty?
83
+
84
+ # Try to get limit from ace-llm provider config first
85
+ limit = load_from_ace_llm(model_name)
86
+ return limit if limit
87
+
88
+ # Fall back to hardcoded pattern matching
89
+ normalized = strip_provider_prefix(model_name)
90
+ match = MODEL_LIMITS.find { |entry| normalized.match?(entry[:pattern]) }
91
+ match ? match[:limit] : DEFAULT_LIMIT
92
+ end
93
+
94
+ # Get the default limit for unknown models
95
+ #
96
+ # @return [Integer] Default context limit
97
+ def self.default_limit
98
+ DEFAULT_LIMIT
99
+ end
100
+
101
+ # Load context limit from ace-llm provider configuration
102
+ #
103
+ # @param model_name [String] Model identifier with provider prefix
104
+ # @return [Integer, nil] Context limit from config, or nil if not found
105
+ def self.load_from_ace_llm(model_name)
106
+ # Extract provider prefix (e.g., "google" from "google:gemini-2.5-pro")
107
+ return nil unless model_name.include?(":")
108
+
109
+ provider = model_name.split(":").first
110
+ return nil if provider.nil? || provider.empty?
111
+
112
+ # Try to load provider config via ace-llm
113
+ config = load_provider_config(provider)
114
+ return nil unless config
115
+
116
+ # Get context_limit from provider config
117
+ limit = config["context_limit"]
118
+ limit.is_a?(Integer) ? limit : nil
119
+ rescue
120
+ nil # Fall back to hardcoded on any error
121
+ end
122
+ private_class_method :load_from_ace_llm
123
+
124
+ # Load provider configuration from ace-llm
125
+ #
126
+ # @param provider [String] Provider name (e.g., "google", "anthropic")
127
+ # @return [Hash, nil] Provider config hash or nil
128
+ def self.load_provider_config(provider)
129
+ # Try to use ace-llm's config loader if available
130
+ return nil unless defined?(Ace::LLM::Molecules::ConfigLoader)
131
+
132
+ resolver = Ace::Support::Config.create(
133
+ config_dir: ".ace",
134
+ defaults_dir: ".ace-defaults",
135
+ gem_path: Ace::LLM::Molecules::ConfigLoader.gem_root
136
+ )
137
+
138
+ config = resolver.resolve_namespace("llm", filename: "providers/#{provider}")
139
+ config.to_h
140
+ rescue
141
+ nil
142
+ end
143
+ private_class_method :load_provider_config
144
+
145
+ # Strip provider prefix from model name
146
+ #
147
+ # @param model_name [String] Model identifier
148
+ # @return [String] Model name without provider prefix
149
+ #
150
+ # @example
151
+ # strip_provider_prefix("google:gemini-2.5-pro")
152
+ # #=> "gemini-2.5-pro"
153
+ def self.strip_provider_prefix(model_name)
154
+ # Common provider prefixes
155
+ model_name.sub(/\A(google|anthropic|openai|codex|cli):/, "")
156
+ end
157
+
158
+ private_class_method :strip_provider_prefix
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Atoms
6
+ # Pure function for parsing unified diffs into file blocks
7
+ #
8
+ # Parses `diff --git` format diffs and extracts individual file blocks
9
+ # with their paths and content. Used by the chunked strategy to split
10
+ # large diffs at file boundaries.
11
+ #
12
+ # Thread-safe: This module uses only class methods with no mutable
13
+ # instance state. All methods are pure functions that can be safely
14
+ # called concurrently from multiple threads.
15
+ #
16
+ # @example Basic usage
17
+ # blocks = DiffBoundaryFinder.parse(diff_text)
18
+ # #=> [
19
+ # # { path: "lib/foo.rb", content: "diff --git...", lines: 45, change_type: :modified },
20
+ # # { path: "test/foo_test.rb", content: "diff --git...", lines: 30, change_type: :modified }
21
+ # # ]
22
+ module DiffBoundaryFinder
23
+ # Pattern to match the start of a file diff block
24
+ # Matches: diff --git a/path/to/file b/path/to/file
25
+ DIFF_HEADER_PATTERN = /^diff --git a\/(.+?) b\/(.+?)$/
26
+
27
+ # Pattern to detect new file mode
28
+ NEW_FILE_PATTERN = /^new file mode/
29
+
30
+ # Pattern to detect deleted file mode
31
+ DELETED_FILE_PATTERN = /^deleted file mode/
32
+
33
+ # Parse a unified diff into individual file blocks
34
+ #
35
+ # @param diff_text [String, nil] The unified diff text to parse
36
+ # @return [Array<Hash>] Array of file blocks, each with:
37
+ # - :path [String] - File path (uses 'b/' side)
38
+ # - :content [String] - Full diff content for this file
39
+ # - :lines [Integer] - Number of lines in the diff block
40
+ # - :change_type [Symbol] - :added, :deleted, or :modified
41
+ #
42
+ # @example
43
+ # DiffBoundaryFinder.parse(diff)
44
+ # #=> [{ path: "lib/foo.rb", content: "diff --git...", lines: 45, change_type: :modified }]
45
+ def self.parse(diff_text)
46
+ return [] if diff_text.nil? || diff_text.empty?
47
+
48
+ blocks = []
49
+ current_block = nil
50
+ current_lines = []
51
+
52
+ diff_text.each_line do |line|
53
+ if (match = DIFF_HEADER_PATTERN.match(line))
54
+ # Save the previous block if exists
55
+ if current_block
56
+ current_block[:content] = current_lines.join
57
+ current_block[:lines] = current_lines.length
58
+ blocks << current_block
59
+ end
60
+
61
+ # Start a new block
62
+ current_block = {
63
+ path: match[2], # Use the 'b/' side (destination path)
64
+ content: "",
65
+ lines: 0,
66
+ change_type: :modified # Default, may be updated below
67
+ }
68
+ current_lines = [line]
69
+ elsif current_block
70
+ current_lines << line
71
+
72
+ # Detect change type from mode lines
73
+ if NEW_FILE_PATTERN.match?(line)
74
+ current_block[:change_type] = :added
75
+ elsif DELETED_FILE_PATTERN.match?(line)
76
+ current_block[:change_type] = :deleted
77
+ end
78
+ end
79
+ end
80
+
81
+ # Don't forget the last block
82
+ if current_block
83
+ current_block[:content] = current_lines.join
84
+ current_block[:lines] = current_lines.length
85
+ blocks << current_block
86
+ end
87
+
88
+ blocks
89
+ end
90
+
91
+ # Parse and return just the file paths from a diff
92
+ #
93
+ # @param diff_text [String, nil] The unified diff text to parse
94
+ # @return [Array<String>] List of file paths in the diff
95
+ #
96
+ # @example
97
+ # DiffBoundaryFinder.file_paths(diff)
98
+ # #=> ["lib/foo.rb", "test/foo_test.rb"]
99
+ def self.file_paths(diff_text)
100
+ parse(diff_text).map { |block| block[:path] }
101
+ end
102
+
103
+ # Count the number of files in a diff
104
+ #
105
+ # @param diff_text [String, nil] The unified diff text
106
+ # @return [Integer] Number of files in the diff
107
+ #
108
+ # @example
109
+ # DiffBoundaryFinder.file_count(diff)
110
+ # #=> 5
111
+ def self.file_count(diff_text)
112
+ return 0 if diff_text.nil? || diff_text.empty?
113
+
114
+ diff_text.scan(DIFF_HEADER_PATTERN).length
115
+ end
116
+
117
+ # Group file blocks by directory
118
+ #
119
+ # @param blocks [Array<Hash>] Array of file blocks from #parse
120
+ # @return [Hash<String, Array<Hash>>] Files grouped by directory
121
+ #
122
+ # @example
123
+ # DiffBoundaryFinder.group_by_directory(blocks)
124
+ # #=> { "lib/atoms" => [...], "test/atoms" => [...] }
125
+ def self.group_by_directory(blocks)
126
+ blocks.group_by do |block|
127
+ File.dirname(block[:path])
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/b36ts"
4
+
5
+ module Ace
6
+ module Review
7
+ module Atoms
8
+ # Generates unique 8-character Base36 IDs for feedback items.
9
+ #
10
+ # Uses ace-b36ts to generate timestamp-based IDs with
11
+ # millisecond precision that sort chronologically. This enables
12
+ # natural ordering of feedback items by creation time.
13
+ #
14
+ # @example Generate a new ID
15
+ # FeedbackIdGenerator.generate
16
+ # #=> "i50jj3ab"
17
+ #
18
+ # @example IDs are chronologically sortable
19
+ # id1 = FeedbackIdGenerator.generate
20
+ # sleep(0.1)
21
+ # id2 = FeedbackIdGenerator.generate
22
+ # id1 < id2 #=> true
23
+ #
24
+ class FeedbackIdGenerator
25
+ # Generate a new 8-character Base36 ID based on current timestamp
26
+ # with millisecond precision
27
+ #
28
+ # @return [String] 8-character Base36 ID (e.g., "i50jj3ab")
29
+ def self.generate
30
+ Ace::B36ts.encode(Time.now.utc, format: :ms)
31
+ end
32
+
33
+ # Generate a new ID for a specific time (useful for testing)
34
+ #
35
+ # @param time [Time] The time to encode
36
+ # @return [String] 8-character Base36 ID
37
+ def self.generate_for(time)
38
+ Ace::B36ts.encode(time.utc, format: :ms)
39
+ end
40
+
41
+ # Generate a sequence of unique, sequential IDs
42
+ #
43
+ # Uses ace-b36ts's encode_sequence to generate IDs that are
44
+ # guaranteed unique even when created in rapid succession. Each ID is
45
+ # strictly greater than the previous (lexicographically).
46
+ #
47
+ # @param count [Integer] Number of IDs to generate
48
+ # @return [Array<String>] Array of unique 8-character Base36 IDs
49
+ # @raise [ArgumentError] If count <= 0
50
+ #
51
+ # @example Generate 5 unique IDs
52
+ # ids = FeedbackIdGenerator.generate_sequence(5)
53
+ # ids.length #=> 5
54
+ # ids.uniq.length #=> 5 # All unique
55
+ # ids == ids.sort #=> true # Already sorted
56
+ def self.generate_sequence(count)
57
+ Ace::B36ts::Atoms::CompactIdEncoder.encode_sequence(
58
+ Time.now.utc,
59
+ count: count,
60
+ format: :ms
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Atoms
6
+ # Generates URL-safe slugs from feedback item titles.
7
+ #
8
+ # Creates slugs suitable for use in filenames and URLs, handling
9
+ # edge cases like unicode, special characters, and length limits.
10
+ #
11
+ # @example Basic usage
12
+ # FeedbackSlugGenerator.generate("Missing error handling")
13
+ # #=> "missing-error-handling"
14
+ #
15
+ # @example Long titles are truncated
16
+ # FeedbackSlugGenerator.generate("A very long title that exceeds forty characters limit")
17
+ # #=> "a-very-long-title-that-exceeds-forty" (truncated to 40 chars)
18
+ #
19
+ # @example Unicode is transliterated or removed
20
+ # FeedbackSlugGenerator.generate("Fix bug in caf\u00e9 module")
21
+ # #=> "fix-bug-in-caf-module"
22
+ #
23
+ class FeedbackSlugGenerator
24
+ DEFAULT_MAX_LENGTH = 40
25
+
26
+ # Generate a URL-safe slug from a title
27
+ #
28
+ # @param title [String] The title to convert to a slug
29
+ # @param max_length [Integer] Maximum slug length (default: 40)
30
+ # @return [String] URL-safe slug
31
+ #
32
+ # @example Basic title
33
+ # FeedbackSlugGenerator.generate("Fix authentication bug")
34
+ # #=> "fix-authentication-bug"
35
+ #
36
+ # @example With special characters
37
+ # FeedbackSlugGenerator.generate("Add try/catch block (urgent!)")
38
+ # #=> "add-try-catch-block-urgent"
39
+ #
40
+ # @example Empty or nil input
41
+ # FeedbackSlugGenerator.generate(nil)
42
+ # #=> ""
43
+ # FeedbackSlugGenerator.generate("")
44
+ # #=> ""
45
+ def self.generate(title, max_length: DEFAULT_MAX_LENGTH)
46
+ return "" if title.nil? || title.empty?
47
+
48
+ title
49
+ .unicode_normalize(:nfkd) # Decompose unicode (e.g., é -> e + combining accent)
50
+ .encode("ASCII", undef: :replace, replace: "") # Strip non-ASCII
51
+ .gsub(/[^a-zA-Z0-9\-_\s]/, "") # Remove special chars (keep spaces for now)
52
+ .gsub(/[\s_]+/, "-").squeeze("-") # Collapse consecutive hyphens
53
+ .gsub(/\A-|-\z/, "") # Remove leading/trailing hyphens
54
+ .downcase
55
+ .slice(0, max_length) # Truncate to max length
56
+ .gsub(/-\z/, "") # Remove trailing hyphen after truncation
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Atoms
6
+ # Validates state transitions for feedback items.
7
+ #
8
+ # Implements the feedback item state machine:
9
+ # draft -> (verify valid=true) -> pending -> (resolve) -> done [archived]
10
+ # draft -> (verify valid=false) -> invalid [archived]
11
+ # draft -> (skip) -> skip [archived]
12
+ # pending -> (skip) -> skip [archived]
13
+ #
14
+ # Terminal states (invalid, skip, done) cannot transition further.
15
+ #
16
+ # @example Check if a transition is valid
17
+ # FeedbackStateValidator.valid_transition?("draft", "pending")
18
+ # #=> true
19
+ #
20
+ # FeedbackStateValidator.valid_transition?("done", "pending")
21
+ # #=> false
22
+ #
23
+ # @example Get allowed transitions from a status
24
+ # FeedbackStateValidator.allowed_transitions("draft")
25
+ # #=> ["pending", "invalid", "skip"]
26
+ #
27
+ class FeedbackStateValidator
28
+ # Define the state machine transitions
29
+ # Maps from_status => [allowed target statuses]
30
+ TRANSITIONS = {
31
+ "draft" => %w[pending invalid skip],
32
+ "pending" => %w[done skip],
33
+ "invalid" => [], # Terminal state
34
+ "skip" => [], # Terminal state
35
+ "done" => [] # Terminal state
36
+ }.freeze
37
+
38
+ # Terminal states that require archiving
39
+ TERMINAL_STATES = %w[invalid skip done].freeze
40
+
41
+ # Check if a state transition is valid
42
+ #
43
+ # @param from_status [String] Current status
44
+ # @param to_status [String] Target status
45
+ # @return [Boolean] True if the transition is allowed
46
+ #
47
+ # @example Valid transition
48
+ # FeedbackStateValidator.valid_transition?("draft", "pending")
49
+ # #=> true
50
+ #
51
+ # @example Invalid transition
52
+ # FeedbackStateValidator.valid_transition?("draft", "done")
53
+ # #=> false
54
+ def self.valid_transition?(from_status, to_status)
55
+ allowed = TRANSITIONS[from_status]
56
+ return false if allowed.nil?
57
+
58
+ allowed.include?(to_status)
59
+ end
60
+
61
+ # Get the list of allowed target statuses from a given status
62
+ #
63
+ # @param status [String] Current status
64
+ # @return [Array<String>] List of valid target statuses (empty for terminal states)
65
+ #
66
+ # @example
67
+ # FeedbackStateValidator.allowed_transitions("draft")
68
+ # #=> ["pending", "invalid", "skip"]
69
+ def self.allowed_transitions(status)
70
+ TRANSITIONS.fetch(status, []).dup
71
+ end
72
+
73
+ # Check if a status is terminal (requires archiving, no further transitions)
74
+ #
75
+ # @param status [String] Status to check
76
+ # @return [Boolean] True if status is terminal
77
+ #
78
+ # @example
79
+ # FeedbackStateValidator.terminal?("done")
80
+ # #=> true
81
+ #
82
+ # FeedbackStateValidator.terminal?("pending")
83
+ # #=> false
84
+ def self.terminal?(status)
85
+ TERMINAL_STATES.include?(status)
86
+ end
87
+
88
+ # Check if a status requires archiving
89
+ #
90
+ # @param status [String] Status to check
91
+ # @return [Boolean] True if items with this status should be archived
92
+ def self.should_archive?(status)
93
+ terminal?(status)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end