ace-task 0.38.0 → 0.39.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.
@@ -110,7 +110,7 @@ module Ace
110
110
  issues << {type: :warning, message: "Title exceeds #{Atoms::TaskValidationRules::MAX_TITLE_LENGTH} characters (#{title.length} chars)", location: file_path}
111
111
  end
112
112
 
113
- validate_github_fields(frontmatter, file_path, issues)
113
+ validate_issue_fields(frontmatter, file_path, issues)
114
114
  end
115
115
 
116
116
  def validate_recommended_fields(frontmatter, file_path, issues)
@@ -134,24 +134,50 @@ module Ace
134
134
  end
135
135
  end
136
136
 
137
- def validate_github_fields(frontmatter, file_path, issues)
138
- linked = frontmatter["github_issue"]
139
- unless linked.nil? || (linked.is_a?(Integer) && linked.positive?)
140
- issues << {
141
- type: :error,
142
- message: "Invalid GitHub issue ID '#{linked}' in github_issue (expected positive integer)",
143
- location: file_path
144
- }
145
- end
146
-
147
- pending = frontmatter["github_sync_pending"]
148
- return if pending.nil? || pending == true || pending == false
137
+ def validate_issue_fields(frontmatter, file_path, issues)
138
+ %w[github_issue github_sync_pending].each do |key|
139
+ next unless frontmatter.key?(key)
149
140
 
150
- issues << {
151
- type: :error,
152
- message: "Invalid github_sync_pending value (expected boolean)",
153
- location: file_path
154
- }
141
+ issues << {type: :error, message: "Obsolete #{key}; use remote_issue/issue_sync_pending",
142
+ location: file_path}
143
+ end
144
+ if frontmatter.key?("remote_issue")
145
+ linked = frontmatter["remote_issue"]
146
+ valid = linked.is_a?(Hash) && linked.keys.all? { |key| key.is_a?(String) } &&
147
+ linked.keys.sort ==
148
+ %w[number provider repository_url server_name url] &&
149
+ linked["number"].is_a?(Integer) && linked["number"].positive? &&
150
+ %w[server_name provider repository_url url].all? { |key| linked[key].is_a?(String) && !linked[key].empty? } &&
151
+ linked["url"] == "#{Ace::Git::Atoms::ServerUrl.web_base(linked["repository_url"])}/issues/#{linked["number"]}"
152
+ unless valid
153
+ issues << {type: :error, message: "Invalid complete remote_issue identity", location: file_path}
154
+ end
155
+ end
156
+ pending = frontmatter["issue_sync_pending"]
157
+ unless pending.nil? || pending == true || pending == false
158
+ issues << {type: :error, message: "Invalid issue_sync_pending value (expected boolean)",
159
+ location: file_path}
160
+ end
161
+ if pending == true && !frontmatter.key?("remote_issue")
162
+ issues << {type: :error, message: "issue_sync_pending requires a complete remote_issue identity",
163
+ location: file_path}
164
+ end
165
+ operation = frontmatter["issue_sync_operation"]
166
+ valid_operation = operation.nil? ||
167
+ (%w[clear reconcile-create].include?(operation) && pending == true && frontmatter["remote_issue"].is_a?(Hash))
168
+ unless valid_operation
169
+ issues << {type: :error, message: "Invalid issue_sync_operation", location: file_path}
170
+ end
171
+ previous_id = frontmatter["issue_sync_previous_id"]
172
+ unless previous_id.nil? || (previous_id.is_a?(String) && !previous_id.empty?)
173
+ issues << {type: :error, message: "Invalid issue_sync_previous_id value (expected task ID string)",
174
+ location: file_path}
175
+ end
176
+ reconcile_flag = frontmatter["issue_sync_reconcile_create"]
177
+ unless reconcile_flag.nil? || reconcile_flag == true
178
+ issues << {type: :error, message: "Invalid issue_sync_reconcile_create value (expected boolean)",
179
+ location: file_path}
180
+ end
155
181
  end
156
182
  end
157
183
  end
@@ -76,9 +76,9 @@ module Ace
76
76
  end
77
77
 
78
78
  def run_ace_bundle(input, output_path)
79
- _stdout, status = Open3.capture2(
80
- "ace-bundle", input, "--format", "markdown-xml", "--output", output_path
81
- )
79
+ checkout_binstub = File.expand_path("../../../../../bin/ace-bundle", __dir__)
80
+ executable = File.file?(checkout_binstub) ? checkout_binstub : Gem.bin_path("ace-bundle", "ace-bundle")
81
+ _stdout, status = Open3.capture2(executable, input, "--format", "markdown-xml", "--output", output_path)
82
82
  unless status.success?
83
83
  raise Ace::Support::Cli::Error.new("ace-bundle failed for: #{input}")
84
84
  end
@@ -75,6 +75,11 @@ module Ace
75
75
  fm.delete("parent")
76
76
  end
77
77
 
78
+ # Promoted descendants rebase from the standalone ID.
79
+ id_map = {task.id => new_id}
80
+ rewrite_descendants(new_dir, old_parent_id: task.id, new_parent_id: new_id, id_map: id_map)
81
+ rewrite_dependency_references(id_map)
82
+
78
83
  loader = TaskLoader.new
79
84
  loader.load(new_dir, id: new_id)
80
85
  end
@@ -164,10 +169,71 @@ module Ace
164
169
  fm["parent"] = parent_task.id
165
170
  end
166
171
 
172
+ # Descendant identities hang off the parent ID: rewrite their
173
+ # frontmatter and spec filenames so persisted IDs stay authoritative.
174
+ id_map = {task.id => new_id}
175
+ rewrite_descendants(new_dir, old_parent_id: task.id, new_parent_id: new_id, id_map: id_map)
176
+ rewrite_dependency_references(id_map)
177
+
167
178
  loader = TaskLoader.new
168
179
  loader.load(new_dir, id: new_id)
169
180
  end
170
181
 
182
+ # Rewrite dependency references in every task spec after an ID map
183
+ # changes (stale references would read as unmet dependencies).
184
+ def rewrite_dependency_references(id_map)
185
+ return if id_map.empty?
186
+
187
+ Dir.glob(File.join(@root_dir, "**", "*.s.md")).sort.each do |spec|
188
+ content = File.read(spec)
189
+ frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
190
+ deps = frontmatter["dependencies"]
191
+ next unless deps.is_a?(Array)
192
+
193
+ mapped = deps.map { |dep| id_map.fetch(dep, dep) }
194
+ next if mapped == deps
195
+
196
+ frontmatter["dependencies"] = mapped
197
+ tmp_path = "#{spec}.tmp.#{Process.pid}"
198
+ File.write(tmp_path, Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body))
199
+ File.rename(tmp_path, spec)
200
+ end
201
+ end
202
+
203
+ # Recursively rewrite descendant frontmatter IDs, parent references and
204
+ # spec filenames after their ancestor's ID changed.
205
+ def rewrite_descendants(dir, old_parent_id:, new_parent_id:, id_map: {})
206
+ return unless Dir.exist?(dir)
207
+
208
+ Dir.entries(dir).sort.each do |entry|
209
+ next if entry.start_with?(".")
210
+ next unless (short_match = entry.match(/\A([a-z0-9])-(.+)/))
211
+
212
+ char = short_match[1]
213
+ slug = short_match[2]
214
+ child_dir = File.join(dir, entry)
215
+ next unless File.directory?(child_dir)
216
+
217
+ old_child_id = "#{old_parent_id}.#{char}"
218
+ new_child_id = "#{new_parent_id}.#{char}"
219
+ id_map[old_child_id] = new_child_id
220
+
221
+ spec = Dir.glob(File.join(child_dir, "*.s.md")).sort.first
222
+ if spec
223
+ new_spec = File.join(child_dir, "#{new_child_id}-#{slug}.s.md")
224
+ File.rename(spec, new_spec) if spec != new_spec && !File.exist?(new_spec)
225
+ if File.exist?(new_spec)
226
+ update_frontmatter(new_spec) do |fm|
227
+ fm["id"] = new_child_id
228
+ fm["parent"] = new_parent_id
229
+ end
230
+ end
231
+ end
232
+
233
+ rewrite_descendants(child_dir, old_parent_id: old_child_id, new_parent_id: new_child_id, id_map: id_map)
234
+ end
235
+ end
236
+
171
237
  # Extract the base task ID (without subtask char) from a potentially dotted ID
172
238
  # "8pp.t.q7w.a" => "8pp.t.q7w"
173
239
  def base_task_id(id)
@@ -19,8 +19,9 @@ module Ace
19
19
  # Short reference pattern: "t.q7w" → extract suffix "q7w"
20
20
  SHORT_REF_PATTERN = /^[a-z]\.([0-9a-z]{3})$/
21
21
 
22
- # Subtask reference pattern: "8pp.t.q7w.a" (parent ID + dot + single char)
23
- SUBTASK_REF_PATTERN = /^([0-9a-z]{3}\.[a-z]\.[0-9a-z]{3})\.([a-z0-9])$/
22
+ # Subtask reference pattern: "8pp.t.q7w.a" or nested "8pp.t.q7w.0.a"
23
+ # (parent ID + one or more dot-separated subtask chars)
24
+ SUBTASK_REF_PATTERN = /^([0-9a-z]{3}\.[a-z]\.[0-9a-z]{3}(?:\.[a-z0-9])*)\.([a-z0-9])$/
24
25
 
25
26
  # Short subtask reference: "q7w.a" or "t.q7w.a" (suffix + subtask char)
26
27
  SHORT_SUBTASK_REF_PATTERN = /^(?:[a-z]\.)?([0-9a-z]{3})\.([a-z0-9])$/
@@ -76,7 +77,17 @@ module Ace
76
77
  # Resolve a subtask reference by finding the parent's scan result,
77
78
  # then looking for the subtask folder within that parent's directory.
78
79
  def resolve_subtask(parent_id, subtask_char)
79
- parent_result = @scan_results.find { |sr| sr.id == parent_id }
80
+ parent_result = if parent_id.count(".") >= 3
81
+ # Nested parent: resolve level by level from the base task.
82
+ base_id = parent_id.split(".")[0, 3].join(".")
83
+ nested_chars = parent_id.split(".")[3..]
84
+ current = resolve_subtask_chain(base_id, nested_chars)
85
+ return nil unless current
86
+
87
+ current
88
+ else
89
+ @scan_results.find { |sr| sr.id == parent_id }
90
+ end
80
91
  return nil unless parent_result
81
92
 
82
93
  subtask_id = "#{parent_id}.#{subtask_char}"
@@ -109,6 +120,23 @@ module Ace
109
120
 
110
121
  nil
111
122
  end
123
+
124
+ # Walk nested subtask chars from the base task, returning the final
125
+ # scan result (ids are rebuilt per level exactly like the folders).
126
+ def resolve_subtask_chain(base_id, nested_chars)
127
+ current = @scan_results.find { |sr| sr.id == base_id }
128
+ return nil unless current
129
+
130
+ current_id = base_id
131
+ nested_chars.each do |char|
132
+ next_level = resolve_subtask(current_id, char)
133
+ return nil unless next_level
134
+
135
+ current = next_level
136
+ current_id = next_level.id
137
+ end
138
+ current
139
+ end
112
140
  end
113
141
  end
114
142
  end