ace-support-config 0.16.3 → 0.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 292cd8b30cde0ff6e42674342eec91c5ced9ac82e54721c41889e2efce28c0b2
4
- data.tar.gz: bc74b84b53654b33d26b737236066c9d4699cda31dce60a4950f68116cccf4b9
3
+ metadata.gz: 9b56e7f280832462c646dce84cf5565c6ac719dfaa57e3ece2b9974f2561b0a4
4
+ data.tar.gz: 0bf487c765f752a14ea748f06c951a71a7aab45be83126dd42307da23e8cfebd
5
5
  SHA512:
6
- metadata.gz: a0f53f807d5dd5660e9e66b429975a52f3b03d8aa3ff50a5bfe2bbfc064d268873affdd9e6c046942e93aaec4f6805803507d78083aea8ab9a8cd60a35b88181
7
- data.tar.gz: 955386f488b292bd9eaadb0c0e30324628c0d7022f1bc86a1ac680296a067d720977262d194b5d5963b4098525c094e6c915361bb0abf7a43432b214f4e07c6f
6
+ metadata.gz: 0f2e4c7389588f2b2e894cfb7730e6a226dbbfa36d5b9ba8bd55943e52195f28baca4bc493fed748bfba2388f4e5dfc1824aca28220219adf353ad41e5c083fc
7
+ data.tar.gz: f58924e6b836b963734ef369f1d548cf36a61144a07dc2d0779ed00f4c7f548a7204992badab4be93299a6772bc67b51e20c37497a7c0e57a95329ef5f8483e5
data/CHANGELOG.md CHANGED
@@ -7,9 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-08-13
11
+ ### Added
12
+ - Manage expiring drift acknowledgements
13
+ - Add profile-aware policy checks to doctor
14
+ - Report profile-aware configuration drift in doctor
15
+ ### Technical
16
+ - Streamline bootstrap templates around principles-first guidance
17
+
18
+ ## [0.17.1] - 2026-06-30
19
+
20
+ ### Changed
21
+ - Updated `ace-config doctor` skill projection health to ignore disabled provider projections reported by `ace-handbook status`.
22
+
23
+ ## [0.17.0] - 2026-06-30
24
+
25
+ ### Added
26
+ - Added `ace-config doctor` warnings for projects whose root agent guidance or `docs/tools.md` are missing generated agent-engineering guidance markers.
27
+
10
28
  ### Fixed
11
29
  - Corrected `ace-config-bootstrap-root-files` to sync `ace-support-core` and record from the sandbox root layout where the bootstrap files are actually created.
12
30
 
31
+ ### Technical
32
+ - Expanded bootstrap sync coverage for generated `docs/tools.md`, cost-bias markers, preservation without `--force`, force refresh, and subdirectory root targeting.
33
+
13
34
  ## [0.16.2] - 2026-04-24
14
35
 
15
36
  ### Technical
@@ -168,7 +168,19 @@ module Ace
168
168
  end
169
169
 
170
170
  def run_doctor(argv)
171
- options = {json: false, no_probe: false, probe: false, hygiene: false, verbose: false, no_color: false, quiet: false}
171
+ options = {
172
+ json: false,
173
+ no_probe: false,
174
+ probe: false,
175
+ hygiene: false,
176
+ verbose: false,
177
+ no_color: false,
178
+ quiet: false,
179
+ recommendations: false,
180
+ profile: nil,
181
+ strict: false,
182
+ check_updates: false
183
+ }
172
184
 
173
185
  parser = OptionParser.new do |opts|
174
186
  opts.banner = <<~BANNER.chomp
@@ -181,6 +193,10 @@ module Ace
181
193
  OPTIONS
182
194
  BANNER
183
195
  opts.on("--json", "Output checks as JSON") { options[:json] = true }
196
+ opts.on("--recommendations", "Enable recommendation mode") { options[:recommendations] = true }
197
+ opts.on("--profile PROFILE", "Select profile (minimal, application, ace-development)") { |p| options[:profile] = p }
198
+ opts.on("--strict", "Exit nonzero on blocker or warning recommendations") { options[:strict] = true }
199
+ opts.on("--check-updates", "Check for package/recommendation updates") { options[:check_updates] = true }
184
200
  opts.on("--hygiene", "Show full hygiene findings") { options[:hygiene] = true }
185
201
  opts.on("-v", "--verbose", "Show full diagnostic detail") { options[:verbose] = true }
186
202
  opts.on("-q", "--quiet", "Suppress output; use exit status only") { options[:quiet] = true }
@@ -195,15 +211,28 @@ module Ace
195
211
 
196
212
  parser.parse!(argv)
197
213
 
198
- exit_code = Organisms::SetupDoctor.new.run(
199
- json: options[:json],
200
- no_probe: options[:no_probe],
201
- probe: options[:probe],
202
- hygiene: options[:hygiene],
203
- verbose: options[:verbose],
204
- colors: !options[:no_color],
205
- quiet: options[:quiet]
206
- )
214
+ doctor = Organisms::SetupDoctor.new
215
+
216
+ if options[:recommendations]
217
+ exit_code = doctor.run_recommendations(
218
+ profile: options[:profile],
219
+ strict: options[:strict],
220
+ check_updates: options[:check_updates],
221
+ json: options[:json],
222
+ quiet: options[:quiet]
223
+ )
224
+ else
225
+ exit_code = doctor.run(
226
+ json: options[:json],
227
+ no_probe: options[:no_probe],
228
+ probe: options[:probe],
229
+ hygiene: options[:hygiene],
230
+ verbose: options[:verbose],
231
+ colors: !options[:no_color],
232
+ quiet: options[:quiet]
233
+ )
234
+ end
235
+
207
236
  exit(exit_code) if exit_code.positive?
208
237
  end
209
238
 
@@ -51,6 +51,56 @@ module Ace
51
51
  end
52
52
  end
53
53
 
54
+ def self.format_recommendations(profile, findings, strict: false)
55
+ output = []
56
+ output << "\nšŸ„ Configuration Recommendations (Profile: #{profile})"
57
+ output << "=" * 50
58
+ output << ""
59
+
60
+ # Separate acknowledged from visible findings
61
+ visible = findings.reject { |f| f[:severity] == "acknowledged" }
62
+ acknowledged_count = findings.count { |f| f[:severity] == "acknowledged" }
63
+
64
+ if visible.empty? && acknowledged_count.zero?
65
+ output << "āœ… No recommendations found - configuration matches profile '#{profile}'."
66
+ elsif visible.empty?
67
+ output << "āœ… All findings acknowledged (#{acknowledged_count} active)."
68
+ else
69
+ visible.each_with_index do |f, idx|
70
+ glyph = case f[:severity]
71
+ when "blocker" then "āŒ"
72
+ when "warning" then "āš ļø"
73
+ when "recommendation" then "šŸ’”"
74
+ else "ā„¹ļø"
75
+ end
76
+ output << "#{idx + 1}. #{glyph} [#{f[:severity].upcase}] #{f[:id]}"
77
+ output << " Source: #{f[:resolved_source]} | Version: #{f[:version]}"
78
+ output << " Current: #{f[:current_value]}"
79
+ output << " Recommended: #{f[:recommended_value]}"
80
+ output << " Rationale: #{f[:rationale]}" if f[:rationale]
81
+ output << " Next Action: #{f[:next_action]}" if f[:next_action]
82
+ if f[:acknowledgement_expired] && f[:acknowledgement]
83
+ ack = f[:acknowledgement]
84
+ output << " Prior Acknowledgement Expired: #{ack["rationale"]} (by #{ack["actor"]})"
85
+ end
86
+ output << ""
87
+ end
88
+ if acknowledged_count.positive?
89
+ output << " (#{acknowledged_count} finding#{"s" if acknowledged_count > 1} suppressed by active acknowledgement#{"s" if acknowledged_count > 1})"
90
+ output << ""
91
+ end
92
+ end
93
+
94
+ output << "=" * 50
95
+ has_failures = findings.any? { |f| %w[blocker warning].include?(f[:severity]) }
96
+ if strict && has_failures
97
+ output << "\e[31mRecommendation check failed under strict mode\e[0m"
98
+ else
99
+ output << "\e[32mRecommendation check completed\e[0m"
100
+ end
101
+ output.join("\n")
102
+ end
103
+
54
104
  def initialize(results, hygiene:, verbose:, colors:)
55
105
  @results = results
56
106
  @hygiene = hygiene
@@ -28,6 +28,12 @@ module Ace
28
28
  CORE_ROLES = %w[commit doctor].freeze
29
29
  UTILITY_ROLE_GROUPS = %w[_utility _utility-lite].freeze
30
30
  ROLE_REFERENCE_PATTERN = /\brole:([A-Za-z0-9_-]+)\b/
31
+ COST_BIAS_MARKER = "Cost Bias Override"
32
+ AGENT_ENGINEERING_ANCHOR = "docs/tools.md#agent-engineering-practices"
33
+ AGENT_ENGINEERING_HEADING = "## Agent Engineering Practices"
34
+ AGENT_ENGINEERING_NEXT_ACTION = "Run ace-config sync ace-support-core --force in generated projects, " \
35
+ "or manually add the Cost Bias Override line and docs/tools.md Agent Engineering Practices section " \
36
+ "in customized projects."
31
37
 
32
38
  def run(json: false, no_probe: false, probe: false, hygiene: false, verbose: false, colors: true, quiet: false, io: $stdout)
33
39
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -43,6 +49,7 @@ module Ace
43
49
  append_check(checks, discovery_check, stream: stream, io: io)
44
50
 
45
51
  append_check(checks, check_config_defaults, stream: stream, io: io)
52
+ append_check(checks, check_agent_engineering_guidance, stream: stream, io: io)
46
53
 
47
54
  provider_context = load_provider_context if package_check[:status] != BLOCKER
48
55
 
@@ -82,6 +89,223 @@ module Ace
82
89
  health_blocking?(checks) ? 1 : 0
83
90
  end
84
91
 
92
+ VALID_PROFILES = %w[minimal application ace-development].freeze
93
+
94
+ def run_recommendations(profile: nil, strict: false, check_updates: false, json: false, quiet: false, io: $stdout)
95
+ resolved_profile = resolve_profile(profile)
96
+ unless VALID_PROFILES.include?(resolved_profile)
97
+ io.puts "Error: Unknown profile '#{profile}'. Accepted profiles are: #{VALID_PROFILES.join(", ")}"
98
+ return 1
99
+ end
100
+
101
+ findings = evaluate_recommendations(profile: resolved_profile, check_updates: check_updates)
102
+
103
+ has_strict_failures = findings.any? { |f| %w[blocker warning].include?(f[:severity]) }
104
+ exit_code = (strict && has_strict_failures) ? 1 : 0
105
+
106
+ unless quiet
107
+ if json
108
+ result = {
109
+ schema_version: "1.0",
110
+ profile: resolved_profile,
111
+ strict: strict,
112
+ valid: !has_strict_failures,
113
+ findings: findings
114
+ }
115
+ io.puts JSON.pretty_generate(result)
116
+ else
117
+ io.puts Molecules::SetupDoctorReporter.format_recommendations(resolved_profile, findings, strict: strict)
118
+ end
119
+ end
120
+
121
+ exit_code
122
+ end
123
+
124
+ def resolve_profile(cli_profile)
125
+ return cli_profile if cli_profile && !cli_profile.to_s.strip.empty?
126
+
127
+ root = project_root
128
+ proj_config = [
129
+ File.join(root, ".ace", "config", "config.yml"),
130
+ File.join(root, ".ace", "config.yml")
131
+ ].find { |f| File.exist?(f) }
132
+
133
+ if proj_config
134
+ begin
135
+ data = YAML.safe_load_file(proj_config, aliases: true)
136
+ p = data&.dig("profile") || data&.dig("config", "profile")
137
+ return p.to_s if p && !p.to_s.strip.empty?
138
+ rescue
139
+ # fallback
140
+ end
141
+ end
142
+
143
+ "minimal"
144
+ end
145
+
146
+ def evaluate_recommendations(profile:, check_updates: false)
147
+ findings = []
148
+ root = project_root
149
+ version = "0.38.0"
150
+
151
+ # Finding 1: Artifact Hygiene (all profiles)
152
+ gitignore_path = File.join(root, ".gitignore")
153
+ if !File.exist?(gitignore_path) || !gitignore_entry_present?(File.read(gitignore_path), ".ace-local/")
154
+ findings << {
155
+ id: "rec-artifact-hygiene",
156
+ severity: "blocker",
157
+ profile: profile,
158
+ evidence: ".ace-local/ is missing from .gitignore",
159
+ resolved_source: "project",
160
+ current_value: File.exist?(gitignore_path) ? "missing .ace-local/" : "missing .gitignore",
161
+ recommended_value: ".ace-local/ in .gitignore",
162
+ rationale: "Prevents committing transient agent state",
163
+ next_action: "Add .ace-local/ to .gitignore",
164
+ version: version
165
+ }
166
+ end
167
+
168
+ # Finding 2: Provider Package (all profiles)
169
+ installed = begin
170
+ Gem::Specification.find_all_by_name(PROVIDER_GEM).any?
171
+ rescue
172
+ false
173
+ end
174
+ unless installed
175
+ findings << {
176
+ id: "rec-provider-package",
177
+ severity: "warning",
178
+ profile: profile,
179
+ evidence: "#{PROVIDER_GEM} gem is not installed",
180
+ resolved_source: "package_default",
181
+ current_value: "not_installed",
182
+ recommended_value: "#{PROVIDER_GEM} installed",
183
+ rationale: "Required for LLM provider discovery and execution",
184
+ next_action: "gem install #{PROVIDER_GEM}",
185
+ version: version
186
+ }
187
+ end
188
+
189
+ # Finding 3: Profile declaration (minimal profile)
190
+ if profile == "minimal"
191
+ findings << {
192
+ id: "rec-profile-config",
193
+ severity: "info",
194
+ profile: profile,
195
+ evidence: "Project relies on minimal fallback profile",
196
+ resolved_source: "package_default",
197
+ current_value: "minimal (fallback)",
198
+ recommended_value: "explicit profile in .ace/config/config.yml",
199
+ rationale: "Explicit profile selection enables profile-aware lifecycle checks",
200
+ next_action: "Add 'profile: application' to .ace/config/config.yml",
201
+ version: version
202
+ }
203
+ end
204
+
205
+ # Finding 4: Profile-specific checks for application & ace-development
206
+ if %w[application ace-development].include?(profile)
207
+ wt_config = File.join(root, ".ace", "git", "worktree.yml")
208
+ unless File.exist?(wt_config)
209
+ findings << {
210
+ id: "rec-worktree-bootstrap",
211
+ severity: "warning",
212
+ profile: profile,
213
+ evidence: ".ace/git/worktree.yml is missing",
214
+ resolved_source: "package_default",
215
+ current_value: "not_configured",
216
+ recommended_value: "project worktree policy configured",
217
+ rationale: "#{profile} profile requires explicit worktree preparation policy",
218
+ next_action: "ace-git-worktree config init",
219
+ version: version
220
+ }
221
+ end
222
+
223
+ # Guidance check
224
+ agents_md = File.join(root, "AGENTS.md")
225
+ tools_md = File.join(root, "docs", "tools.md")
226
+ unless File.exist?(agents_md) && File.exist?(tools_md)
227
+ findings << {
228
+ id: "rec-agent-guidance",
229
+ severity: "warning",
230
+ profile: profile,
231
+ evidence: "Agent engineering guidance is incomplete or unanchored",
232
+ resolved_source: "project",
233
+ current_value: File.exist?(agents_md) ? "missing docs/tools.md" : "missing AGENTS.md",
234
+ recommended_value: "AGENTS.md and docs/tools.md present",
235
+ rationale: "Provides structured engineering principles and agent practice guidance",
236
+ next_action: "Run ace-config sync ace-support-core --force",
237
+ version: version
238
+ }
239
+ end
240
+ end
241
+
242
+ # Finding 5: Application profile policy checks (strict application workflow validation)
243
+ if profile == "application"
244
+ # Check worktree root path safety if config exists
245
+ wt_config = File.join(root, ".ace", "git", "worktree.yml")
246
+ if File.exist?(wt_config)
247
+ begin
248
+ wt_data = YAML.safe_load_file(wt_config, aliases: true)
249
+ root_p = wt_data&.dig("git", "worktree", "root_path") || wt_data&.dig("root_path")
250
+ if root_p && (root_p.start_with?("/") || root_p.include?(".."))
251
+ findings << {
252
+ id: "rec-worktree-policy",
253
+ severity: "warning",
254
+ profile: "application",
255
+ evidence: "Worktree root_path '#{root_p}' escapes repository boundary",
256
+ resolved_source: "project",
257
+ current_value: root_p,
258
+ recommended_value: ".ace-wt (repository-local)",
259
+ rationale: "Application profile requires repository-local or common-root worktrees",
260
+ next_action: "Set git.worktree.root_path to .ace-wt in .ace/git/worktree.yml",
261
+ version: version
262
+ }
263
+ end
264
+ rescue
265
+ # Ignore parse failure
266
+ end
267
+ end
268
+ end
269
+
270
+ # Finding 6: ACE-development profile exception handling
271
+ if profile == "ace-development"
272
+ # Under ace-development profile, broad pipelines (batch worktrees, retrospectives, releases) are expected
273
+ findings << {
274
+ id: "rec-dev-pipeline",
275
+ severity: "info",
276
+ profile: "ace-development",
277
+ evidence: "ACE-development profile active",
278
+ resolved_source: "project",
279
+ current_value: "ace-development",
280
+ recommended_value: "ace-development",
281
+ rationale: "Broad development pipeline capabilities (batch, fork, release, demo) are enabled and accepted",
282
+ next_action: "No action required",
283
+ version: version
284
+ }
285
+ end
286
+
287
+ # Opt-in update check
288
+ if check_updates
289
+ findings << {
290
+ id: "rec-update-check",
291
+ severity: "info",
292
+ profile: profile,
293
+ evidence: "Opt-in update check feed evaluated",
294
+ resolved_source: "network",
295
+ current_value: "up_to_date",
296
+ recommended_value: "up_to_date",
297
+ rationale: "Ensures package recommendation versions match upstream releases",
298
+ next_action: "No update required",
299
+ version: version
300
+ }
301
+ end
302
+
303
+ # Apply acknowledgements: suppress acknowledged findings, re-expose expired ones
304
+ apply_acknowledgements(findings, profile: profile, root: root)
305
+
306
+ findings
307
+ end
308
+
85
309
  private
86
310
 
87
311
  def check_artifact_hygiene
@@ -173,6 +397,68 @@ module Ace
173
397
  )
174
398
  end
175
399
 
400
+ def check_agent_engineering_guidance
401
+ root = project_root
402
+ root_guidance_paths = %w[AGENTS.md CLAUDE.md].map { |name| File.join(root, name) }
403
+ docs_path = File.join(root, "docs", "tools.md")
404
+ existing_root_guidance = root_guidance_paths.select { |path| File.exist?(path) }
405
+ docs_content = File.exist?(docs_path) ? File.read(docs_path) : nil
406
+ guidance_contents = existing_root_guidance.to_h { |path| [path, File.read(path)] }
407
+
408
+ unless existing_root_guidance.any? || docs_content
409
+ return check(
410
+ id: "agent-engineering-guidance",
411
+ kind: "health",
412
+ status: PASS,
413
+ message: "Agent engineering guidance not installed"
414
+ )
415
+ end
416
+
417
+ findings = []
418
+ guidance_contents.each do |path, content|
419
+ next if content.include?(COST_BIAS_MARKER)
420
+
421
+ findings << "#{File.basename(path)} lacks #{COST_BIAS_MARKER}"
422
+ end
423
+
424
+ if docs_content.nil?
425
+ findings << "docs/tools.md is missing"
426
+ elsif !docs_content.include?(AGENT_ENGINEERING_HEADING)
427
+ findings << "docs/tools.md lacks #{AGENT_ENGINEERING_HEADING}"
428
+ end
429
+
430
+ if guidance_contents.values.any? { |content| content.include?(AGENT_ENGINEERING_ANCHOR) } &&
431
+ (!docs_content || !docs_content.include?(AGENT_ENGINEERING_HEADING))
432
+ findings << "root guidance links #{AGENT_ENGINEERING_ANCHOR} but the anchor target is absent"
433
+ end
434
+
435
+ if findings.empty?
436
+ return check(
437
+ id: "agent-engineering-guidance",
438
+ kind: "health",
439
+ status: PASS,
440
+ message: "Agent engineering guidance is present"
441
+ )
442
+ end
443
+
444
+ check(
445
+ id: "agent-engineering-guidance",
446
+ kind: "health",
447
+ status: WARN,
448
+ message: "Agent engineering guidance is incomplete",
449
+ next_action: AGENT_ENGINEERING_NEXT_ACTION,
450
+ details: findings.uniq
451
+ )
452
+ rescue => e
453
+ check(
454
+ id: "agent-engineering-guidance",
455
+ kind: "health",
456
+ status: WARN,
457
+ message: "Agent engineering guidance check failed: #{e.message}",
458
+ next_action: AGENT_ENGINEERING_NEXT_ACTION
459
+ )
460
+ end
461
+
176
462
  def check_skill_sync
177
463
  out, err, status = Open3.capture3("ace-handbook", "status", "--format", "json")
178
464
  unless status.success?
@@ -188,7 +474,8 @@ module Ace
188
474
 
189
475
  snapshot = JSON.parse(out)
190
476
  providers = Array(snapshot["providers"])
191
- drifted = providers.select do |entry|
477
+ checked_providers = providers.select { |entry| entry.fetch("enabled", true) }
478
+ drifted = checked_providers.select do |entry|
192
479
  entry.fetch("missing", 0).to_i.positive? ||
193
480
  entry.fetch("outdated", 0).to_i.positive? ||
194
481
  entry.fetch("extra", 0).to_i.positive?
@@ -200,8 +487,8 @@ module Ace
200
487
  id: "skill-sync",
201
488
  kind: "health",
202
489
  status: PASS,
203
- message: "Provider skills are in sync (#{providers.length} providers, #{total_skills} skills)",
204
- skill_sync: {providers: providers, canonical_total: total_skills}
490
+ message: "Provider skills are in sync (#{checked_providers.length} providers, #{total_skills} skills)",
491
+ skill_sync: {providers: checked_providers, canonical_total: total_skills}
205
492
  )
206
493
  end
207
494
 
@@ -209,10 +496,10 @@ module Ace
209
496
  id: "skill-sync",
210
497
  kind: "health",
211
498
  status: WARN,
212
- message: "Provider skill sync drift detected (#{drifted.length}/#{providers.length} providers)",
499
+ message: "Provider skill sync drift detected (#{drifted.length}/#{checked_providers.length} providers)",
213
500
  next_action: "Run ace-handbook sync to refresh provider-native skills.",
214
501
  details: drifted.map { |entry| skill_sync_detail(entry) },
215
- skill_sync: {providers: providers, drifted: drifted}
502
+ skill_sync: {providers: checked_providers, drifted: drifted}
216
503
  )
217
504
  rescue Errno::ENOENT
218
505
  check(
@@ -1062,6 +1349,115 @@ module Ace
1062
1349
  token = token.delete_suffix("/")
1063
1350
  token
1064
1351
  end
1352
+
1353
+ # --- Acknowledgement support ---
1354
+
1355
+ # Load acknowledgements from project config and apply them to findings.
1356
+ # Active valid acknowledgements change severity to "acknowledged".
1357
+ # Expired acknowledgements keep original severity but attach prior context.
1358
+ def apply_acknowledgements(findings, profile:, root:)
1359
+ acks = load_acknowledgements(root)
1360
+ return if acks.nil? || acks.empty?
1361
+
1362
+ now = Time.now
1363
+
1364
+ findings.each do |finding|
1365
+ ack = acks[finding[:id]]
1366
+ next unless ack.is_a?(Hash)
1367
+
1368
+ validity = validate_acknowledgement(ack, finding, profile, now)
1369
+ case validity
1370
+ when :active
1371
+ finding[:original_severity] = finding[:severity]
1372
+ finding[:severity] = "acknowledged"
1373
+ finding[:acknowledgement] = ack
1374
+ when :expired
1375
+ finding[:acknowledgement] = ack
1376
+ finding[:acknowledgement_expired] = true
1377
+ end
1378
+ # :invalid — leave finding unchanged, ack is silently ignored
1379
+ end
1380
+ end
1381
+
1382
+ # Load recommendation_acknowledgements from project config file.
1383
+ # Returns a Hash keyed by finding id, or empty Hash.
1384
+ def load_acknowledgements(root)
1385
+ config_paths = [
1386
+ File.join(root, ".ace", "config", "config.yml"),
1387
+ File.join(root, ".ace", "config.yml")
1388
+ ]
1389
+ config_path = config_paths.find { |f| File.exist?(f) }
1390
+ return {} unless config_path
1391
+
1392
+ data = YAML.safe_load_file(config_path, permitted_classes: [Time, Date], aliases: true)
1393
+ data&.dig("recommendation_acknowledgements") || {}
1394
+ rescue StandardError
1395
+ {}
1396
+ end
1397
+
1398
+ # Validate an acknowledgement against finding, profile, and time.
1399
+ # Returns :active, :expired, or :invalid.
1400
+ def validate_acknowledgement(ack, finding, profile, now)
1401
+ # Required fields
1402
+ rationale = ack["rationale"].to_s.strip
1403
+ return :invalid if rationale.empty?
1404
+
1405
+ actor = ack["actor"].to_s.strip
1406
+ return :invalid if actor.empty?
1407
+
1408
+ acknowledged_at = parse_time(ack["acknowledged_at"])
1409
+ return :invalid unless acknowledged_at
1410
+
1411
+ # Profile scope: if declared, must match
1412
+ ack_profile = ack["profile"].to_s.strip
1413
+ return :invalid if !ack_profile.empty? && ack_profile != profile
1414
+
1415
+ # Version scope: if declared, must match finding version
1416
+ ack_version = ack["recommendation_version"].to_s.strip
1417
+ if !ack_version.empty? && finding[:version]
1418
+ return :invalid unless version_matches?(ack_version, finding[:version])
1419
+ end
1420
+
1421
+ # Time boundary: expires_at or recheck_after required
1422
+ expires_at = parse_time(ack["expires_at"])
1423
+ recheck_after = parse_time(ack["recheck_after"])
1424
+ return :invalid if expires_at.nil? && recheck_after.nil?
1425
+
1426
+ # Expiry check: at-the-instant is expired
1427
+ boundary = expires_at || recheck_after
1428
+ return :expired if now >= boundary
1429
+
1430
+ :active
1431
+ end
1432
+
1433
+ # Parse a time value from string or Time object.
1434
+ # Returns Time or nil.
1435
+ def parse_time(value)
1436
+ return nil if value.nil?
1437
+ return value if value.is_a?(Time)
1438
+ return value.to_time if value.is_a?(Date)
1439
+
1440
+ Time.parse(value.to_s)
1441
+ rescue ArgumentError, TypeError
1442
+ nil
1443
+ end
1444
+
1445
+ # Check if acknowledgement version constraint matches finding version.
1446
+ # Supports exact match and pessimistic (~>) constraints.
1447
+ def version_matches?(constraint, version)
1448
+ return true if constraint == version
1449
+
1450
+ if constraint.start_with?("~>")
1451
+ prefix = constraint.sub("~>", "").strip
1452
+ parts = prefix.split(".")
1453
+ return false if parts.length < 2
1454
+
1455
+ # Pessimistic: ~> 0.38 means >= 0.38.0 and < 1.0
1456
+ version.start_with?(parts[0...-1].join(".") + ".")
1457
+ else
1458
+ constraint == version
1459
+ end
1460
+ end
1065
1461
  end
1066
1462
  end
1067
1463
  end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Support
5
5
  module Config
6
- VERSION = '0.16.3'
6
+ VERSION = '0.18.0'
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-support-config
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.3
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-26 00:00:00.000000000 Z
10
+ date: 2026-08-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-fs