ace-support-config 0.17.1 ā 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9b56e7f280832462c646dce84cf5565c6ac719dfaa57e3ece2b9974f2561b0a4
|
|
4
|
+
data.tar.gz: 0bf487c765f752a14ea748f06c951a71a7aab45be83126dd42307da23e8cfebd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f2e4c7389588f2b2e894cfb7730e6a226dbbfa36d5b9ba8bd55943e52195f28baca4bc493fed748bfba2388f4e5dfc1824aca28220219adf353ad41e5c083fc
|
|
7
|
+
data.tar.gz: f58924e6b836b963734ef369f1d548cf36a61144a07dc2d0779ed00f4c7f548a7204992badab4be93299a6772bc67b51e20c37497a7c0e57a95329ef5f8483e5
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ 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
|
+
|
|
10
18
|
## [0.17.1] - 2026-06-30
|
|
11
19
|
|
|
12
20
|
### Changed
|
|
@@ -168,7 +168,19 @@ module Ace
|
|
|
168
168
|
end
|
|
169
169
|
|
|
170
170
|
def run_doctor(argv)
|
|
171
|
-
options = {
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
@@ -89,6 +89,223 @@ module Ace
|
|
|
89
89
|
health_blocking?(checks) ? 1 : 0
|
|
90
90
|
end
|
|
91
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
|
+
|
|
92
309
|
private
|
|
93
310
|
|
|
94
311
|
def check_artifact_hygiene
|
|
@@ -1132,6 +1349,115 @@ module Ace
|
|
|
1132
1349
|
token = token.delete_suffix("/")
|
|
1133
1350
|
token
|
|
1134
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
|
|
1135
1461
|
end
|
|
1136
1462
|
end
|
|
1137
1463
|
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.
|
|
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-
|
|
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
|