@guanlin4924/devflow-pi 0.1.16 → 0.1.18
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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +297 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +83 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +54 -2
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Architecture Assessment P0: the assessment never accepts a caller-supplied
|
|
5
|
+
# manifest or delta set. A user-confirmed, Core-written immutable manifest is
|
|
6
|
+
# the only input besides the pinned runtime-context snapshot.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "pathname"
|
|
12
|
+
require "set"
|
|
13
|
+
require "time"
|
|
14
|
+
require_relative "control_io"
|
|
15
|
+
require_relative "runtime_context_collector"
|
|
16
|
+
|
|
17
|
+
module ArchitectureAssessment
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
RULE_VERSION = 1
|
|
20
|
+
ROOT_DIR = File.join(".planning", "architecture-assessment")
|
|
21
|
+
|
|
22
|
+
UNRESOLVED = {
|
|
23
|
+
DELTA_SET_BINDING_FAILED: "DELTA_SET_BINDING_FAILED",
|
|
24
|
+
BASELINE_COVERAGE_INCOMPLETE: "BASELINE_COVERAGE_INCOMPLETE",
|
|
25
|
+
DELTA_SOURCE_UNVERIFIABLE: "DELTA_SOURCE_UNVERIFIABLE"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def safe_component!(value, label)
|
|
31
|
+
text = value.to_s.strip
|
|
32
|
+
unless text.match?(/\A[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}\z/u)
|
|
33
|
+
raise ArgumentError, "#{label} must be a 1-120 character path-safe identifier"
|
|
34
|
+
end
|
|
35
|
+
text
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# RFC 8785-style canonical JSON for the bounded P0 data model. Every key is
|
|
39
|
+
# a String before hashing, so Ruby Hash insertion order cannot change a hash.
|
|
40
|
+
def canonical_json(value)
|
|
41
|
+
case value
|
|
42
|
+
when Hash
|
|
43
|
+
"{" + value.keys.map(&:to_s).sort.map { |key| JSON.generate(key) + ":" + canonical_json(value[key]) }.join(",") + "}"
|
|
44
|
+
when Array
|
|
45
|
+
"[" + value.map { |item| canonical_json(item) }.join(",") + "]"
|
|
46
|
+
when String
|
|
47
|
+
JSON.generate(value)
|
|
48
|
+
when Numeric
|
|
49
|
+
value.to_s
|
|
50
|
+
when true, false
|
|
51
|
+
value.to_s
|
|
52
|
+
when nil
|
|
53
|
+
"null"
|
|
54
|
+
else
|
|
55
|
+
JSON.generate(value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sha256(value)
|
|
60
|
+
Digest::SHA256.hexdigest(value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalize_subject(value)
|
|
64
|
+
value.to_s.strip.downcase
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def canonical_source(source)
|
|
68
|
+
source = source.is_a?(Hash) ? source : {}
|
|
69
|
+
{
|
|
70
|
+
"kind" => source["kind"].to_s,
|
|
71
|
+
"locator" => source["locator"].to_s,
|
|
72
|
+
"input_hash" => source["input_hash"].to_s,
|
|
73
|
+
"repository_revision" => source["repository_revision"].to_s
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Only this projection is confirmed by the user and persisted in a manifest.
|
|
78
|
+
# Model-only presentation fields such as proposer/status are deliberately
|
|
79
|
+
# omitted from the decision input.
|
|
80
|
+
def canonical_delta(delta)
|
|
81
|
+
delta = delta.is_a?(Hash) ? delta : {}
|
|
82
|
+
{
|
|
83
|
+
"op" => delta["op"].to_s,
|
|
84
|
+
"subject" => normalize_subject(delta["subject"]),
|
|
85
|
+
"source" => canonical_source(delta["source"])
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def canonical_delta_hash(delta)
|
|
90
|
+
sha256(canonical_json(canonical_delta(delta)))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stable_sorted_union(lists)
|
|
94
|
+
lists.flatten.compact.uniq.sort
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def manifest_path(root, feature, delta_set_id)
|
|
98
|
+
feature = safe_component!(feature, "feature")
|
|
99
|
+
delta_set_id = safe_component!(delta_set_id, "deltaSetId")
|
|
100
|
+
File.join(root, ROOT_DIR, feature, "delta-set-manifests", "#{delta_set_id}.json")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def assessment_dir(root, feature)
|
|
104
|
+
File.join(root, ROOT_DIR, safe_component!(feature, "feature"))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def assessment_pointer_path(root, feature)
|
|
108
|
+
File.join(assessment_dir(root, feature), "current.json")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def assessment_history_path(root, feature, revision)
|
|
112
|
+
File.join(assessment_dir(root, feature), "revisions", "#{revision}.json")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def assessment_audit_path(root, feature)
|
|
116
|
+
File.join(assessment_dir(root, feature), "audit.jsonl")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def current_assessment_revision(root, feature)
|
|
120
|
+
pointer = assessment_pointer_path(root, feature)
|
|
121
|
+
return 0 unless File.exist?(pointer)
|
|
122
|
+
|
|
123
|
+
JSON.parse(File.read(pointer))["current_revision"].to_i
|
|
124
|
+
rescue JSON::ParserError
|
|
125
|
+
raise ArgumentError, "assessment pointer is invalid JSON: #{pointer}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_locator(locator)
|
|
129
|
+
path, anchor = locator.to_s.split("#", 2)
|
|
130
|
+
path = path.to_s.strip
|
|
131
|
+
anchor = anchor.to_s.strip
|
|
132
|
+
raise ArgumentError, "source locator must be project-relative path#heading" if path.empty? || anchor.empty?
|
|
133
|
+
raise ArgumentError, "source locator cannot be absolute" if Pathname.new(path).absolute?
|
|
134
|
+
normalized = Pathname.new(path).cleanpath.to_s.tr("\\", "/")
|
|
135
|
+
raise ArgumentError, "source locator cannot traverse parent directories" if normalized == ".." || normalized.start_with?("../")
|
|
136
|
+
[normalized, anchor]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def git_capture(root, *args)
|
|
140
|
+
stdout, stderr, status = Open3.capture3("git", "-C", root, *args)
|
|
141
|
+
[stdout, stderr, status.success?]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def markdown_section(content, anchor)
|
|
145
|
+
lines = content.lines
|
|
146
|
+
target = anchor.to_s.strip
|
|
147
|
+
start = nil
|
|
148
|
+
level = nil
|
|
149
|
+
lines.each_with_index do |line, index|
|
|
150
|
+
match = line.match(/\A(#+)\s+(.+?)\s*\z/)
|
|
151
|
+
next unless match
|
|
152
|
+
|
|
153
|
+
title = match[2].sub(/\s+#+\s*\z/, "").strip
|
|
154
|
+
next unless title == target
|
|
155
|
+
|
|
156
|
+
start = index
|
|
157
|
+
level = match[1].length
|
|
158
|
+
break
|
|
159
|
+
end
|
|
160
|
+
return nil unless start
|
|
161
|
+
|
|
162
|
+
finish = lines.length
|
|
163
|
+
((start + 1)...lines.length).each do |index|
|
|
164
|
+
match = lines[index].match(/\A(#+)\s+/)
|
|
165
|
+
next unless match && match[1].length <= level
|
|
166
|
+
|
|
167
|
+
finish = index
|
|
168
|
+
break
|
|
169
|
+
end
|
|
170
|
+
lines[start...finish].join
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Proves the source claim by loading the exact Git object, resolving the
|
|
174
|
+
# declared Markdown section, and comparing its bytes to input_hash.
|
|
175
|
+
def source_valid?(root, source)
|
|
176
|
+
source = canonical_source(source)
|
|
177
|
+
return false if source.values.any?(&:empty?)
|
|
178
|
+
|
|
179
|
+
path, anchor = parse_locator(source["locator"])
|
|
180
|
+
_stdout, _stderr, commit_exists = git_capture(root, "cat-file", "-e", "#{source["repository_revision"]}^{commit}")
|
|
181
|
+
return false unless commit_exists
|
|
182
|
+
|
|
183
|
+
content, _show_error, shown = git_capture(root, "show", "#{source["repository_revision"]}:#{path}")
|
|
184
|
+
return false unless shown
|
|
185
|
+
|
|
186
|
+
section = markdown_section(content, anchor)
|
|
187
|
+
!section.nil? && sha256(section) == source["input_hash"]
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def validate_delta(delta, expected_source_revision: nil)
|
|
193
|
+
errors = []
|
|
194
|
+
errors << "delta must be an object" unless delta.is_a?(Hash)
|
|
195
|
+
return errors unless delta.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
safe_component!(delta["id"], "delta id")
|
|
198
|
+
errors << "delta #{delta["id"]}: only require_runtime_dep is supported in P0" unless delta["op"] == "require_runtime_dep"
|
|
199
|
+
errors << "delta #{delta["id"]}: subject is required" if normalize_subject(delta["subject"]).empty?
|
|
200
|
+
source = canonical_source(delta["source"])
|
|
201
|
+
errors << "delta #{delta["id"]}: source kind is required" if source["kind"].empty?
|
|
202
|
+
errors << "delta #{delta["id"]}: source locator is required" if source["locator"].empty?
|
|
203
|
+
errors << "delta #{delta["id"]}: source input_hash is required" if source["input_hash"].empty?
|
|
204
|
+
errors << "delta #{delta["id"]}: source repository_revision is required" if source["repository_revision"].empty?
|
|
205
|
+
if expected_source_revision && source["repository_revision"] != expected_source_revision
|
|
206
|
+
errors << "delta #{delta["id"]}: source repository_revision does not match source_bundle_revision"
|
|
207
|
+
end
|
|
208
|
+
errors
|
|
209
|
+
rescue ArgumentError => error
|
|
210
|
+
errors << error.message
|
|
211
|
+
errors
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def read_manifest(root, feature, delta_set_id)
|
|
215
|
+
path = manifest_path(root, feature, delta_set_id)
|
|
216
|
+
raise ArgumentError, "confirmed delta-set manifest not found: #{path}" unless File.exist?(path)
|
|
217
|
+
|
|
218
|
+
[JSON.parse(File.read(path)), path]
|
|
219
|
+
rescue JSON::ParserError
|
|
220
|
+
raise ArgumentError, "confirmed delta-set manifest is invalid JSON: #{path}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def manifest_errors(manifest, feature, delta_set_id)
|
|
224
|
+
errors = []
|
|
225
|
+
attestation = manifest["attestation"].is_a?(Hash) ? manifest["attestation"] : {}
|
|
226
|
+
errors << "manifest feature mismatch" unless manifest["feature"] == feature
|
|
227
|
+
errors << "manifest delta_set_id mismatch" unless manifest["delta_set_id"] == delta_set_id
|
|
228
|
+
errors << "manifest attestation.verified_by must be core" unless attestation["verified_by"] == "core"
|
|
229
|
+
errors << "manifest attestation.method must be ui_confirm" unless attestation["method"] == "ui_confirm"
|
|
230
|
+
errors << "manifest attestation.principal must be interactive-user" unless attestation["principal"] == "interactive-user"
|
|
231
|
+
errors << "manifest attestation.confirmed_at is missing" if attestation["confirmed_at"].to_s.empty?
|
|
232
|
+
source_bundle_revision = manifest["source_bundle_revision"].to_s
|
|
233
|
+
errors << "manifest source_bundle_revision is missing" if source_bundle_revision.empty?
|
|
234
|
+
items = Array(manifest["items"])
|
|
235
|
+
ids = items.map { |item| item.is_a?(Hash) ? item["id"] : nil }
|
|
236
|
+
errors << "manifest item ids are duplicated" unless ids.compact.uniq.length == ids.length && ids.none?(&:nil?)
|
|
237
|
+
items.each do |item|
|
|
238
|
+
unless item.is_a?(Hash)
|
|
239
|
+
errors << "manifest item must be an object"
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
delta = item["canonical_delta"]
|
|
243
|
+
errors.concat(validate_delta(delta, expected_source_revision: source_bundle_revision))
|
|
244
|
+
errors << "#{item["id"]}: id does not match canonical delta" unless delta.is_a?(Hash) && item["id"] == delta["id"]
|
|
245
|
+
expected_hash = delta.is_a?(Hash) ? canonical_delta_hash(delta) : nil
|
|
246
|
+
errors << "#{item["id"]}: canonical_delta_hash mismatch" unless item["canonical_delta_hash"] == expected_hash
|
|
247
|
+
end
|
|
248
|
+
errors.uniq
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def in_inventory?(snapshot, subject)
|
|
252
|
+
normalized = normalize_subject(subject)
|
|
253
|
+
Array(snapshot["inventory"]).any? { |item| normalize_subject(item["subject"]) == normalized }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def classify(delta, snapshot, root)
|
|
257
|
+
coverage_complete = snapshot["coverage_status"] == "complete"
|
|
258
|
+
source_is_valid = source_valid?(root, delta["source"])
|
|
259
|
+
return { "predicate" => nil, "confidence" => "incomplete", "source_valid" => source_is_valid } unless coverage_complete && source_is_valid
|
|
260
|
+
|
|
261
|
+
subject = normalize_subject(delta["subject"])
|
|
262
|
+
predicate = in_inventory?(snapshot, subject) ? "dependency_satisfied" : "new_declared_dependency"
|
|
263
|
+
{
|
|
264
|
+
"predicate" => predicate,
|
|
265
|
+
"confidence" => "confirmed",
|
|
266
|
+
"subject" => subject,
|
|
267
|
+
"source_valid" => true,
|
|
268
|
+
"inputs" => ["snapshot:inventory", "snapshot:coverage", "delta:#{delta["id"]}"]
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def assess(root, feature, snapshot, manifest, delta_set_id)
|
|
273
|
+
binding = manifest_errors(manifest, feature, delta_set_id)
|
|
274
|
+
unless binding.empty?
|
|
275
|
+
return {
|
|
276
|
+
"feature" => feature, "outcome" => "lightweight",
|
|
277
|
+
"assessment_completeness" => "incomplete",
|
|
278
|
+
"would_require" => ["re_attest_delta_set"], "would_block" => [],
|
|
279
|
+
"unresolved_questions" => [UNRESOLVED[:DELTA_SET_BINDING_FAILED]],
|
|
280
|
+
"binding_errors" => binding, "findings" => []
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
deltas = Array(manifest["items"]).map { |item| item["canonical_delta"] }
|
|
285
|
+
findings = deltas.map { |delta| classify(delta, snapshot, root).merge("delta_id" => delta["id"]) }
|
|
286
|
+
incomplete = snapshot["coverage_status"] != "complete" || findings.any? { |finding| finding["confidence"] == "incomplete" }
|
|
287
|
+
if incomplete
|
|
288
|
+
codes = []
|
|
289
|
+
codes << UNRESOLVED[:BASELINE_COVERAGE_INCOMPLETE] if snapshot["coverage_status"] != "complete"
|
|
290
|
+
codes << UNRESOLVED[:DELTA_SOURCE_UNVERIFIABLE] if findings.any? { |finding| finding["confidence"] == "incomplete" }
|
|
291
|
+
return {
|
|
292
|
+
"feature" => feature, "outcome" => "lightweight",
|
|
293
|
+
"assessment_completeness" => "incomplete",
|
|
294
|
+
"would_require" => ["clarify_or_rescan"], "would_block" => [],
|
|
295
|
+
"unresolved_questions" => stable_sorted_union([codes]), "findings" => findings
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
new_dependencies = findings.select { |finding| finding["predicate"] == "new_declared_dependency" }
|
|
300
|
+
if new_dependencies.empty?
|
|
301
|
+
{
|
|
302
|
+
"feature" => feature, "outcome" => "none",
|
|
303
|
+
"assessment_completeness" => "complete",
|
|
304
|
+
"would_require" => [], "would_block" => [], "unresolved_questions" => [], "findings" => findings
|
|
305
|
+
}
|
|
306
|
+
else
|
|
307
|
+
{
|
|
308
|
+
"feature" => feature, "outcome" => "full",
|
|
309
|
+
"assessment_completeness" => "complete",
|
|
310
|
+
"would_require" => ["decision"], "would_block" => [], "unresolved_questions" => [], "findings" => findings
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def write_assessment(root, feature, expected_revision, assessment, manifest_path_value, base_snapshot_revision, delta_set_id)
|
|
316
|
+
current = current_assessment_revision(root, feature)
|
|
317
|
+
raise ArgumentError, "assessment revision conflict: expected #{expected_revision}, current #{current}" unless expected_revision == current
|
|
318
|
+
|
|
319
|
+
revision = current + 1
|
|
320
|
+
final = assessment.merge(
|
|
321
|
+
"schema_version" => SCHEMA_VERSION,
|
|
322
|
+
"rule_version" => RULE_VERSION,
|
|
323
|
+
"assessment_revision" => revision,
|
|
324
|
+
"base_snapshot_revision" => base_snapshot_revision,
|
|
325
|
+
"delta_set_id" => delta_set_id,
|
|
326
|
+
"manifest_path" => manifest_path_value.sub("#{root}/", ""),
|
|
327
|
+
"manifest_hash" => sha256(File.read(manifest_path_value))
|
|
328
|
+
)
|
|
329
|
+
history = assessment_history_path(root, feature, revision)
|
|
330
|
+
DevflowControlIO.atomic_create(history, JSON.pretty_generate(final) + "\n")
|
|
331
|
+
DevflowControlIO.atomic_write(assessment_pointer_path(root, feature), JSON.pretty_generate({ "current_revision" => revision, "path" => history.sub("#{root}/", "") }) + "\n")
|
|
332
|
+
DevflowControlIO.append_audit(assessment_audit_path(root, feature), {
|
|
333
|
+
"event" => "assessment_written", "assessment_revision" => revision,
|
|
334
|
+
"base_snapshot_revision" => base_snapshot_revision, "delta_set_id" => delta_set_id,
|
|
335
|
+
"manifest_hash" => final["manifest_hash"], "outcome" => final["outcome"], "at" => Time.now.utc.iso8601
|
|
336
|
+
})
|
|
337
|
+
DevflowControlIO.register_control_artifact(root, {
|
|
338
|
+
"path" => history.sub("#{root}/", ""), "type" => "architecture_assessment",
|
|
339
|
+
"produced_by" => "core", "assessment_revision" => revision,
|
|
340
|
+
"outcome" => final["outcome"], "base_snapshot_revision" => base_snapshot_revision,
|
|
341
|
+
"delta_set_id" => delta_set_id
|
|
342
|
+
})
|
|
343
|
+
final.merge("path" => history.sub("#{root}/", ""))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def write_manifest(root, feature, delta_set_id, source_bundle_revision, deltas)
|
|
347
|
+
raise ArgumentError, "source_bundle_revision is required" if source_bundle_revision.to_s.empty?
|
|
348
|
+
raise ArgumentError, "deltas must be a non-empty array" unless deltas.is_a?(Array) && !deltas.empty?
|
|
349
|
+
errors = deltas.flat_map { |delta| validate_delta(delta, expected_source_revision: source_bundle_revision) }
|
|
350
|
+
ids = deltas.map { |delta| delta.is_a?(Hash) ? delta["id"] : nil }
|
|
351
|
+
errors << "duplicate delta ids" unless ids.compact.uniq.length == ids.length && ids.none?(&:nil?)
|
|
352
|
+
raise ArgumentError, errors.uniq.join("; ") unless errors.empty?
|
|
353
|
+
|
|
354
|
+
manifest = {
|
|
355
|
+
"schema_version" => SCHEMA_VERSION,
|
|
356
|
+
"delta_set_id" => delta_set_id,
|
|
357
|
+
"feature" => feature,
|
|
358
|
+
"source_bundle_revision" => source_bundle_revision,
|
|
359
|
+
"items" => deltas.map do |delta|
|
|
360
|
+
canonical = canonical_delta(delta).merge("id" => delta["id"])
|
|
361
|
+
{ "id" => delta["id"], "canonical_delta" => canonical, "canonical_delta_hash" => canonical_delta_hash(canonical) }
|
|
362
|
+
end,
|
|
363
|
+
"attestation" => {
|
|
364
|
+
"principal" => "interactive-user", "verified_by" => "core", "method" => "ui_confirm", "confirmed_at" => Time.now.utc.iso8601
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
destination = manifest_path(root, feature, delta_set_id)
|
|
368
|
+
DevflowControlIO.atomic_create(destination, JSON.pretty_generate(manifest) + "\n")
|
|
369
|
+
DevflowControlIO.append_audit(assessment_audit_path(root, feature), {
|
|
370
|
+
"event" => "manifest_confirmed", "delta_set_id" => delta_set_id,
|
|
371
|
+
"manifest_hash" => sha256(File.read(destination)), "at" => Time.now.utc.iso8601
|
|
372
|
+
})
|
|
373
|
+
DevflowControlIO.register_control_artifact(root, {
|
|
374
|
+
"path" => destination.sub("#{root}/", ""), "type" => "delta_set_manifest",
|
|
375
|
+
"produced_by" => "core", "delta_set_id" => delta_set_id
|
|
376
|
+
})
|
|
377
|
+
[manifest, destination]
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
if __FILE__ == $PROGRAM_NAME
|
|
382
|
+
options = {
|
|
383
|
+
feature: nil, base_snapshot_revision: nil, delta_set_id: nil, delta_json: nil,
|
|
384
|
+
source_bundle_revision: nil, expected_assessment_revision: nil,
|
|
385
|
+
confirmed_interactively: false, write: false, json: false
|
|
386
|
+
}
|
|
387
|
+
command = ARGV.shift
|
|
388
|
+
OptionParser.new do |o|
|
|
389
|
+
o.on("--feature NAME") { |value| options[:feature] = value }
|
|
390
|
+
o.on("--base-snapshot-revision N", Integer) { |value| options[:base_snapshot_revision] = value }
|
|
391
|
+
o.on("--delta-set-id ID") { |value| options[:delta_set_id] = value }
|
|
392
|
+
o.on("--delta-json JSON") { |value| options[:delta_json] = value }
|
|
393
|
+
o.on("--source-bundle-revision REV") { |value| options[:source_bundle_revision] = value }
|
|
394
|
+
o.on("--expected-assessment-revision N", Integer) { |value| options[:expected_assessment_revision] = value }
|
|
395
|
+
o.on("--confirmed-interactively") { options[:confirmed_interactively] = true }
|
|
396
|
+
o.on("--write") { options[:write] = true }
|
|
397
|
+
o.on("--json") { options[:json] = true }
|
|
398
|
+
end.parse!
|
|
399
|
+
|
|
400
|
+
root = Dir.pwd
|
|
401
|
+
writes = command == "write-manifest" || (command == "assess" && options[:write])
|
|
402
|
+
lock = writes ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
403
|
+
begin
|
|
404
|
+
result =
|
|
405
|
+
case command
|
|
406
|
+
when "inspect"
|
|
407
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
408
|
+
feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
|
|
409
|
+
result = {
|
|
410
|
+
"ok" => true,
|
|
411
|
+
"current_assessment_revision" => ArchitectureAssessment.current_assessment_revision(root, feature)
|
|
412
|
+
}
|
|
413
|
+
unless options[:delta_set_id].to_s.empty?
|
|
414
|
+
delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
|
|
415
|
+
manifest = ArchitectureAssessment.manifest_path(root, feature, delta_set_id)
|
|
416
|
+
result.merge!("manifest_exists" => File.exist?(manifest), "manifest_path" => manifest.sub("#{root}/", ""))
|
|
417
|
+
end
|
|
418
|
+
result
|
|
419
|
+
when "write-manifest"
|
|
420
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
421
|
+
raise ArgumentError, "--delta-set-id is required" if options[:delta_set_id].to_s.empty?
|
|
422
|
+
raise ArgumentError, "--source-bundle-revision is required" if options[:source_bundle_revision].to_s.empty?
|
|
423
|
+
raise ArgumentError, "--delta-json is required" if options[:delta_json].to_s.empty?
|
|
424
|
+
raise ArgumentError, "--confirmed-interactively is required" unless options[:confirmed_interactively]
|
|
425
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
426
|
+
feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
|
|
427
|
+
delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
|
|
428
|
+
deltas = JSON.parse(options[:delta_json])
|
|
429
|
+
manifest, path = ArchitectureAssessment.write_manifest(root, feature, delta_set_id, options[:source_bundle_revision], deltas)
|
|
430
|
+
{ "ok" => true, "delta_set_id" => delta_set_id, "manifest_hash" => ArchitectureAssessment.sha256(File.read(path)), "path" => path.sub("#{root}/", ""), "manifest" => manifest }
|
|
431
|
+
when "assess"
|
|
432
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
433
|
+
raise ArgumentError, "--base-snapshot-revision is required" if options[:base_snapshot_revision].nil?
|
|
434
|
+
raise ArgumentError, "--delta-set-id is required" if options[:delta_set_id].to_s.empty?
|
|
435
|
+
feature = ArchitectureAssessment.safe_component!(options[:feature], "feature")
|
|
436
|
+
delta_set_id = ArchitectureAssessment.safe_component!(options[:delta_set_id], "deltaSetId")
|
|
437
|
+
snapshot_path = File.join(root, RuntimeContextCollector::HISTORY_DIR, "#{options[:base_snapshot_revision]}.json")
|
|
438
|
+
raise ArgumentError, "base snapshot not found: #{snapshot_path}" unless File.exist?(snapshot_path)
|
|
439
|
+
snapshot = JSON.parse(File.read(snapshot_path))
|
|
440
|
+
manifest, path = ArchitectureAssessment.read_manifest(root, feature, delta_set_id)
|
|
441
|
+
assessment = ArchitectureAssessment.assess(root, feature, snapshot, manifest, delta_set_id).merge(
|
|
442
|
+
"base_snapshot_revision" => options[:base_snapshot_revision], "delta_set_id" => delta_set_id
|
|
443
|
+
)
|
|
444
|
+
if options[:write]
|
|
445
|
+
raise ArgumentError, "--expected-assessment-revision is required when writing" if options[:expected_assessment_revision].nil?
|
|
446
|
+
assessment = ArchitectureAssessment.write_assessment(root, feature, options[:expected_assessment_revision], assessment, path, options[:base_snapshot_revision], delta_set_id)
|
|
447
|
+
end
|
|
448
|
+
{ "ok" => true }.merge(assessment)
|
|
449
|
+
else
|
|
450
|
+
raise ArgumentError, "command must be inspect, write-manifest, or assess"
|
|
451
|
+
end
|
|
452
|
+
rescue StandardError => error
|
|
453
|
+
result = { "ok" => false, "reason" => "architecture_assessment_failed", "message" => error.message }
|
|
454
|
+
ensure
|
|
455
|
+
DevflowControlIO.release_lock(lock)
|
|
456
|
+
end
|
|
457
|
+
puts JSON.pretty_generate(result)
|
|
458
|
+
end
|