@guanlin4924/devflow-pi 0.1.39 → 0.1.47
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +61 -24
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Core-owned deterministic aggregate/canonical builder. It never makes a
|
|
5
|
+
# semantic choice: differing definitions remain unresolved conflicts until a
|
|
6
|
+
# later runtime-confirmed adjudication applies a complete candidate delta.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "pathname"
|
|
12
|
+
require "set"
|
|
13
|
+
require "tempfile"
|
|
14
|
+
require "time"
|
|
15
|
+
require "yaml"
|
|
16
|
+
require_relative "control_io"
|
|
17
|
+
require_relative "requirement_graph"
|
|
18
|
+
|
|
19
|
+
module BusinessModelCommit
|
|
20
|
+
COLLECTIONS = %w[actors entities components boundaries resources rules actions assumptions].freeze
|
|
21
|
+
REFERENCE_LISTS = %w[requirement_refs decision_refs resolved_assumption_refs].freeze
|
|
22
|
+
VALIDATOR = File.expand_path("business_model_validate.py", __dir__)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def deep_sort(value)
|
|
27
|
+
case value
|
|
28
|
+
when Hash then value.keys.sort.to_h { |key| [key, deep_sort(value[key])] }
|
|
29
|
+
when Array then value.map { |item| deep_sort(item) }
|
|
30
|
+
else value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fingerprint(value)
|
|
35
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(deep_sort(value)))}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def valid_feature?(feature)
|
|
39
|
+
RequirementGraph.valid_feature?(feature)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def feature_dir(root, feature)
|
|
43
|
+
raise ArgumentError, "invalid feature" unless valid_feature?(feature)
|
|
44
|
+
|
|
45
|
+
root_real = File.realpath(root)
|
|
46
|
+
relative = File.join(".planning", "business-model", feature)
|
|
47
|
+
absolute = File.expand_path(relative, root_real)
|
|
48
|
+
raise ArgumentError, "business model path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
49
|
+
|
|
50
|
+
cursor = root_real
|
|
51
|
+
relative.split(File::SEPARATOR).each do |segment|
|
|
52
|
+
cursor = File.join(cursor, segment)
|
|
53
|
+
next unless File.symlink?(cursor)
|
|
54
|
+
|
|
55
|
+
resolved = File.realpath(cursor)
|
|
56
|
+
raise ArgumentError, "business model path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
absolute
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def proposal_path(root, feature, raw)
|
|
63
|
+
root_real = File.realpath(root)
|
|
64
|
+
value = raw.to_s
|
|
65
|
+
raise ArgumentError, "proposal path is required" if value.empty?
|
|
66
|
+
raise ArgumentError, "proposal path must be project-relative" if Pathname.new(value).absolute?
|
|
67
|
+
raise ArgumentError, "proposal path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
|
|
68
|
+
|
|
69
|
+
normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
|
|
70
|
+
prefix = File.join(".planning", "business-model", feature, "proposals").tr("\\", "/") + "/"
|
|
71
|
+
raise ArgumentError, "proposal path is outside the feature proposal directory" unless normalized.start_with?(prefix)
|
|
72
|
+
absolute = File.expand_path(normalized, root_real)
|
|
73
|
+
raise ArgumentError, "proposal path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
74
|
+
raise ArgumentError, "proposal file is missing" unless File.file?(absolute)
|
|
75
|
+
|
|
76
|
+
cursor = root_real
|
|
77
|
+
normalized.split("/").each do |segment|
|
|
78
|
+
cursor = File.join(cursor, segment)
|
|
79
|
+
next unless File.symlink?(cursor)
|
|
80
|
+
|
|
81
|
+
resolved = File.realpath(cursor)
|
|
82
|
+
raise ArgumentError, "proposal path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
83
|
+
end
|
|
84
|
+
[absolute, normalized]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def proposal_paths(root, feature, requested)
|
|
88
|
+
if requested.empty?
|
|
89
|
+
directory = File.join(feature_dir(root, feature), "proposals")
|
|
90
|
+
requested = Dir.glob(File.join(directory, "*.{yaml,yml,json}")).sort.map do |path|
|
|
91
|
+
Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
raise ArgumentError, "at least one proposal is required" if requested.empty?
|
|
95
|
+
|
|
96
|
+
requested.map { |raw| proposal_path(root, feature, raw) }.sort_by { |_absolute, relative| relative }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_proposal(absolute, relative, feature)
|
|
100
|
+
stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", absolute)
|
|
101
|
+
payload = JSON.parse(stdout)
|
|
102
|
+
unless status.success? && payload["valid"] == true
|
|
103
|
+
details = Array(payload["errors"]).map { |error| error["code"] }.uniq.join(", ")
|
|
104
|
+
raise ArgumentError, "proposal #{relative} is invalid: #{details.empty? ? stderr.strip : details}"
|
|
105
|
+
end
|
|
106
|
+
canonical = payload.fetch("canonical")
|
|
107
|
+
raise ArgumentError, "proposal #{relative} feature does not match" unless canonical["feature"] == feature
|
|
108
|
+
|
|
109
|
+
{ "path" => relative, "fingerprint" => payload.fetch("canonical_fingerprint"), "model" => canonical }
|
|
110
|
+
rescue JSON::ParserError => e
|
|
111
|
+
raise ArgumentError, "proposal validator returned invalid JSON for #{relative}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def grouped_alternatives(entries, field)
|
|
115
|
+
groups = {}
|
|
116
|
+
entries.each do |entry|
|
|
117
|
+
present = entry["value"].key?(field)
|
|
118
|
+
value = present ? entry["value"][field] : nil
|
|
119
|
+
key = fingerprint({ "present" => present, "value" => value })
|
|
120
|
+
groups[key] ||= { "present" => present, "value" => value, "proposal_refs" => [] }
|
|
121
|
+
groups[key]["proposal_refs"] << entry["proposal_ref"]
|
|
122
|
+
end
|
|
123
|
+
groups.values.each { |item| item["proposal_refs"].sort! }
|
|
124
|
+
groups.values.sort_by { |item| fingerprint(item) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def conflict_id(subject, field)
|
|
128
|
+
"CONFLICT-#{subject}-#{field}".gsub(/[^A-Za-z0-9_-]/, "-")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def merge_collection(collection, proposals, conflicts)
|
|
132
|
+
by_id = {}
|
|
133
|
+
proposals.each do |proposal|
|
|
134
|
+
Array(proposal["model"][collection]).each do |value|
|
|
135
|
+
id = value["id"].to_s
|
|
136
|
+
by_id[id] ||= []
|
|
137
|
+
by_id[id] << { "value" => value, "proposal_ref" => "#{proposal['path']}##{id}" }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
merged = []
|
|
141
|
+
# Proposal files are sorted before union. Preserve the first appearance of
|
|
142
|
+
# every ID so business-order arrays remain semantic and fingerprint-stable;
|
|
143
|
+
# sorting IDs here would silently rewrite the model.
|
|
144
|
+
by_id.keys.each do |id|
|
|
145
|
+
entries = by_id[id]
|
|
146
|
+
variants = entries.group_by { |entry| fingerprint(entry["value"]) }
|
|
147
|
+
if variants.length == 1
|
|
148
|
+
merged << entries.first["value"]
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
fields = entries.flat_map { |entry| entry["value"].keys }.uniq.sort
|
|
152
|
+
fields.each do |field|
|
|
153
|
+
alternatives = grouped_alternatives(entries, field)
|
|
154
|
+
next if alternatives.length == 1
|
|
155
|
+
|
|
156
|
+
conflicts << {
|
|
157
|
+
"id" => conflict_id(id, field),
|
|
158
|
+
"subject_ref" => id,
|
|
159
|
+
"collection" => collection,
|
|
160
|
+
"field" => field,
|
|
161
|
+
"alternatives" => alternatives,
|
|
162
|
+
"status" => "unresolved"
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
# The whole subject stays out of merged while any field differs. This
|
|
166
|
+
# prevents an incomplete hybrid object from looking canonical.
|
|
167
|
+
end
|
|
168
|
+
merged
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def detect_cross_collection_ids(proposals, conflicts)
|
|
172
|
+
locations = {}
|
|
173
|
+
proposals.each do |proposal|
|
|
174
|
+
COLLECTIONS.each do |collection|
|
|
175
|
+
Array(proposal["model"][collection]).each do |value|
|
|
176
|
+
id = value["id"].to_s
|
|
177
|
+
locations[id] ||= {}
|
|
178
|
+
locations[id][collection] ||= []
|
|
179
|
+
locations[id][collection] << "#{proposal['path']}##{id}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
collisions = Set.new
|
|
184
|
+
locations.each do |id, collections|
|
|
185
|
+
next unless collections.length > 1
|
|
186
|
+
|
|
187
|
+
collisions << id
|
|
188
|
+
conflicts << {
|
|
189
|
+
"id" => conflict_id(id, "collection"),
|
|
190
|
+
"subject_ref" => id,
|
|
191
|
+
"collection" => "cross_collection",
|
|
192
|
+
"field" => "collection",
|
|
193
|
+
"alternatives" => collections.keys.sort.map { |collection| { "present" => true, "value" => collection, "proposal_refs" => collections[collection].uniq.sort } },
|
|
194
|
+
"status" => "unresolved"
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
collisions
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_aggregate_payload(feature, proposals, revision)
|
|
201
|
+
conflicts = []
|
|
202
|
+
merged = {
|
|
203
|
+
"schema_version" => 1,
|
|
204
|
+
"feature" => feature
|
|
205
|
+
}
|
|
206
|
+
collisions = detect_cross_collection_ids(proposals, conflicts)
|
|
207
|
+
COLLECTIONS.each do |collection|
|
|
208
|
+
merged[collection] = merge_collection(collection, proposals, conflicts).reject { |value| collisions.include?(value["id"].to_s) }
|
|
209
|
+
end
|
|
210
|
+
REFERENCE_LISTS.each do |key|
|
|
211
|
+
merged[key] = proposals.flat_map { |proposal| Array(proposal["model"][key]) }.uniq
|
|
212
|
+
end
|
|
213
|
+
source_registry_groups = proposals.group_by { |proposal| proposal["model"]["source_registry_ref"] }
|
|
214
|
+
source_registry_refs = source_registry_groups.keys
|
|
215
|
+
if source_registry_refs.length == 1
|
|
216
|
+
merged["source_registry_ref"] = source_registry_refs.first
|
|
217
|
+
else
|
|
218
|
+
conflicts << {
|
|
219
|
+
"id" => "CONFLICT-BUSINESS-MODEL-source_registry_ref",
|
|
220
|
+
"subject_ref" => "BUSINESS-MODEL",
|
|
221
|
+
"collection" => "root",
|
|
222
|
+
"field" => "source_registry_ref",
|
|
223
|
+
"alternatives" => source_registry_refs.sort.map do |value|
|
|
224
|
+
{ "present" => true, "value" => value, "proposal_refs" => source_registry_groups[value].map { |proposal| proposal["path"] }.sort }
|
|
225
|
+
end,
|
|
226
|
+
"status" => "unresolved"
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
proposal_meta = proposals.map { |proposal| proposal.slice("path", "fingerprint") }
|
|
230
|
+
payload = {
|
|
231
|
+
"schema_version" => 1,
|
|
232
|
+
"feature" => feature,
|
|
233
|
+
"revision" => revision,
|
|
234
|
+
"proposal_set_fingerprint" => fingerprint(proposal_meta),
|
|
235
|
+
"proposals" => proposal_meta,
|
|
236
|
+
"merged" => merged,
|
|
237
|
+
"conflicts" => conflicts.sort_by { |conflict| conflict["id"] }
|
|
238
|
+
}
|
|
239
|
+
payload["content_fingerprint"] = fingerprint(payload.reject { |key, _| %w[revision content_fingerprint].include?(key) })
|
|
240
|
+
payload
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def load_existing_aggregate(path, feature)
|
|
244
|
+
return nil unless File.file?(path)
|
|
245
|
+
|
|
246
|
+
value = JSON.parse(File.read(path))
|
|
247
|
+
valid = value.is_a?(Hash) && value["schema_version"] == 1 && value["feature"] == feature && value["revision"].is_a?(Integer)
|
|
248
|
+
raise ArgumentError, "invalid existing aggregate" unless valid
|
|
249
|
+
|
|
250
|
+
value
|
|
251
|
+
rescue JSON::ParserError
|
|
252
|
+
raise ArgumentError, "invalid existing aggregate"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def load_delta(root, feature, raw)
|
|
256
|
+
raise ArgumentError, "candidate delta path is required" if raw.to_s.empty?
|
|
257
|
+
path = File.expand_path(raw, root)
|
|
258
|
+
feature_root = File.expand_path(File.join(".planning", "business-model", feature, "candidates"), root)
|
|
259
|
+
raise ArgumentError, "candidate delta is missing" unless File.file?(path)
|
|
260
|
+
path = File.realpath(path)
|
|
261
|
+
feature_root = File.realpath(feature_root)
|
|
262
|
+
raise ArgumentError, "candidate delta must be inside the feature candidate directory" unless path.start_with?("#{feature_root}#{File::SEPARATOR}")
|
|
263
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
264
|
+
raise ArgumentError, "candidate delta must be a mapping" unless value.is_a?(Hash)
|
|
265
|
+
required = %w[schema_version feature aggregate_revision aggregate_fingerprint source_registry_fingerprint conflict_ids assumption_ids proposed_by operations]
|
|
266
|
+
missing = required.reject { |key| value.key?(key) }
|
|
267
|
+
raise ArgumentError, "candidate delta missing #{missing.join(', ')}" unless missing.empty?
|
|
268
|
+
raise ArgumentError, "candidate delta schema_version is invalid" unless value["schema_version"] == 1
|
|
269
|
+
raise ArgumentError, "candidate delta feature mismatch" unless value["feature"] == feature
|
|
270
|
+
raise ArgumentError, "candidate delta must target a conflict or assumption" if Array(value["conflict_ids"]).empty? && Array(value["assumption_ids"]).empty?
|
|
271
|
+
operations = Array(value["operations"])
|
|
272
|
+
raise ArgumentError, "candidate delta operations are required" if operations.empty?
|
|
273
|
+
operations.each_with_index do |operation, index|
|
|
274
|
+
raise ArgumentError, "candidate delta operation #{index} must be a mapping" unless operation.is_a?(Hash)
|
|
275
|
+
%w[op collection target_id].each { |key| raise ArgumentError, "candidate delta operation #{index} missing #{key}" if operation[key].to_s.empty? }
|
|
276
|
+
raise ArgumentError, "candidate delta operation #{index} has invalid op" unless %w[add replace remove].include?(operation["op"].to_s)
|
|
277
|
+
raise ArgumentError, "candidate delta operation #{index} has invalid collection" unless COLLECTIONS.include?(operation["collection"].to_s)
|
|
278
|
+
if %w[add replace].include?(operation["op"].to_s)
|
|
279
|
+
value_object = operation["value"]
|
|
280
|
+
raise ArgumentError, "candidate delta operation #{index} requires a complete value object" unless value_object.is_a?(Hash) && value_object["id"].to_s == operation["target_id"].to_s
|
|
281
|
+
elsif operation.key?("value") && !operation["value"].nil?
|
|
282
|
+
raise ArgumentError, "remove operation cannot carry a value"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
[value, Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s]
|
|
286
|
+
rescue Psych::Exception => e
|
|
287
|
+
raise ArgumentError, "invalid candidate delta YAML: #{e.message}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def source_registry_fingerprint(root, feature, aggregate)
|
|
291
|
+
registry_ref = aggregate.dig("merged", "source_registry_ref").to_s
|
|
292
|
+
return "" if registry_ref.empty?
|
|
293
|
+
path = if registry_ref.start_with?(".planning/")
|
|
294
|
+
File.expand_path(registry_ref, root)
|
|
295
|
+
else
|
|
296
|
+
File.expand_path(registry_ref, feature_dir(root, feature))
|
|
297
|
+
end
|
|
298
|
+
return "" unless File.file?(path)
|
|
299
|
+
"sha256:#{Digest::SHA256.file(path).hexdigest}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def load_canonical(root, feature)
|
|
303
|
+
path = File.join(feature_dir(root, feature), "canonical.json")
|
|
304
|
+
raise ArgumentError, "canonical is missing" unless File.file?(path)
|
|
305
|
+
value = JSON.parse(File.read(path))
|
|
306
|
+
raise ArgumentError, "canonical feature mismatch" unless value.is_a?(Hash) && value["feature"] == feature
|
|
307
|
+
[path, value]
|
|
308
|
+
rescue JSON::ParserError => e
|
|
309
|
+
raise ArgumentError, "invalid canonical JSON: #{e.message}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def canonical_fingerprint_from_python(root, path)
|
|
313
|
+
Tempfile.create(["business-meta", ".json"]) do |output|
|
|
314
|
+
stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", path, "--canonical-out", output.path)
|
|
315
|
+
payload = JSON.parse(stdout)
|
|
316
|
+
raise ArgumentError, "canonical validation failed: #{stderr.strip}" unless status.success? && payload["valid"] == true
|
|
317
|
+
[payload.fetch("canonical_fingerprint"), payload.fetch("canonical")]
|
|
318
|
+
end
|
|
319
|
+
rescue JSON::ParserError => e
|
|
320
|
+
raise ArgumentError, "canonical validator returned invalid JSON: #{e.message}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def meta(root:, feature:, expected_revision:, write:)
|
|
324
|
+
path, canonical = load_canonical(root, feature)
|
|
325
|
+
fingerprint_value, = canonical_fingerprint_from_python(root, path)
|
|
326
|
+
aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
|
|
327
|
+
aggregate_revision = aggregate ? aggregate["revision"].to_i : expected_revision.to_i
|
|
328
|
+
raise ArgumentError, "canonical revision mismatch" unless aggregate.nil? || aggregate_revision == expected_revision.to_i
|
|
329
|
+
source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
|
|
330
|
+
metadata = {
|
|
331
|
+
"schema_version" => 1,
|
|
332
|
+
"feature" => feature,
|
|
333
|
+
"prototype_fingerprint" => fingerprint_value,
|
|
334
|
+
"source_document_fingerprint" => source_fingerprint,
|
|
335
|
+
"aggregate_revision" => aggregate_revision,
|
|
336
|
+
"checker_summary" => { "structural" => "passed", "closure" => "required_before_approval" },
|
|
337
|
+
"generated_at" => Time.now.utc.iso8601
|
|
338
|
+
}
|
|
339
|
+
target = File.join(feature_dir(root, feature), "canonical.meta.json")
|
|
340
|
+
DevflowControlIO.atomic_write(target, JSON.pretty_generate(metadata) + "\n") if write
|
|
341
|
+
{ "ok" => true, "action" => "meta", "meta" => metadata, "path" => relative_to_root(root, target) }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def approve(root:, feature:, expected_revision:, approved_by:, runtime_confirmed:, write:)
|
|
345
|
+
raise ArgumentError, "runtime confirmation is required" unless runtime_confirmed
|
|
346
|
+
raise ArgumentError, "approved_by is required" if approved_by.to_s.empty?
|
|
347
|
+
path, canonical = load_canonical(root, feature)
|
|
348
|
+
fingerprint_value, = canonical_fingerprint_from_python(root, path)
|
|
349
|
+
aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
|
|
350
|
+
revision = aggregate ? aggregate["revision"].to_i : expected_revision.to_i
|
|
351
|
+
raise ArgumentError, "canonical revision mismatch" unless revision == expected_revision.to_i
|
|
352
|
+
source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
|
|
353
|
+
approval = {
|
|
354
|
+
"schema_version" => 1,
|
|
355
|
+
"feature" => feature,
|
|
356
|
+
"status" => "approved",
|
|
357
|
+
"approved_by" => approved_by,
|
|
358
|
+
"approved_at" => Time.now.utc.iso8601,
|
|
359
|
+
"prototype_fingerprint" => fingerprint_value,
|
|
360
|
+
"source_document_fingerprint" => source_fingerprint,
|
|
361
|
+
"revision" => revision,
|
|
362
|
+
"canonical_screens" => [],
|
|
363
|
+
"confirmed_screens" => []
|
|
364
|
+
}
|
|
365
|
+
target = File.join(feature_dir(root, feature), "approval.json")
|
|
366
|
+
DevflowControlIO.atomic_write(target, JSON.pretty_generate(approval) + "\n") if write
|
|
367
|
+
{ "ok" => true, "action" => "approve", "approval" => approval, "path" => relative_to_root(root, target) }
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def approval_status(root:, feature:, expected_revision:)
|
|
371
|
+
approval_path = File.join(feature_dir(root, feature), "approval.json")
|
|
372
|
+
raise ArgumentError, "approval is missing" unless File.file?(approval_path)
|
|
373
|
+
approval = JSON.parse(File.read(approval_path))
|
|
374
|
+
canonical_path, = load_canonical(root, feature)
|
|
375
|
+
fingerprint_value, = canonical_fingerprint_from_python(root, canonical_path)
|
|
376
|
+
aggregate = load_existing_aggregate(File.join(feature_dir(root, feature), "aggregate.json"), feature)
|
|
377
|
+
source_fingerprint = aggregate ? source_registry_fingerprint(root, feature, aggregate) : ""
|
|
378
|
+
current = approval["status"] == "approved" && approval["prototype_fingerprint"] == fingerprint_value && approval["source_document_fingerprint"].to_s == source_fingerprint.to_s && approval["revision"].to_i == expected_revision.to_i
|
|
379
|
+
{ "ok" => true, "action" => "approval-status", "current" => current, "approval" => approval, "actual" => { "prototype_fingerprint" => fingerprint_value, "source_document_fingerprint" => source_fingerprint, "revision" => expected_revision } }
|
|
380
|
+
rescue JSON::ParserError => e
|
|
381
|
+
raise ArgumentError, "invalid approval JSON: #{e.message}"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def write_decision(root, feature, decision)
|
|
385
|
+
path = File.join(feature_dir(root, feature), "business-decisions.json")
|
|
386
|
+
existing = if File.file?(path)
|
|
387
|
+
JSON.parse(File.read(path))
|
|
388
|
+
else
|
|
389
|
+
{ "schema_version" => 1, "feature" => feature, "revision" => 0, "decisions" => [] }
|
|
390
|
+
end
|
|
391
|
+
existing["revision"] = existing.fetch("revision", 0).to_i + 1
|
|
392
|
+
existing["decisions"] = Array(existing["decisions"]) + [decision.merge("revision" => existing["revision"])]
|
|
393
|
+
existing["fingerprint"] = fingerprint(existing.reject { |key, _| key == "fingerprint" })
|
|
394
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(existing) + "\n")
|
|
395
|
+
[path, existing]
|
|
396
|
+
rescue JSON::ParserError => e
|
|
397
|
+
raise ArgumentError, "invalid business decision registry: #{e.message}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def adjudicate(root:, feature:, expected_revision:, delta_path:, decision_id:, decided_by:, accept:, write:)
|
|
401
|
+
directory = feature_dir(root, feature)
|
|
402
|
+
aggregate_path = File.join(directory, "aggregate.json")
|
|
403
|
+
aggregate = load_existing_aggregate(aggregate_path, feature)
|
|
404
|
+
raise ArgumentError, "aggregate is missing" unless aggregate
|
|
405
|
+
delta, relative_delta = load_delta(root, feature, delta_path)
|
|
406
|
+
raise ArgumentError, "aggregate revision mismatch" unless expected_revision == aggregate["revision"]
|
|
407
|
+
raise ArgumentError, "candidate delta aggregate revision is stale" unless delta["aggregate_revision"].to_i == aggregate["revision"].to_i
|
|
408
|
+
raise ArgumentError, "candidate delta aggregate fingerprint is stale" unless delta["aggregate_fingerprint"].to_s == aggregate["content_fingerprint"].to_s
|
|
409
|
+
actual_source = source_registry_fingerprint(root, feature, aggregate)
|
|
410
|
+
if !actual_source.empty? && delta["source_registry_fingerprint"].to_s != actual_source
|
|
411
|
+
raise ArgumentError, "candidate delta source registry fingerprint is stale"
|
|
412
|
+
end
|
|
413
|
+
current_conflicts = Array(aggregate["conflicts"]).select { |item| item["status"].to_s != "resolved" }.map { |item| item["id"].to_s }
|
|
414
|
+
targeted = Array(delta["conflict_ids"]).map(&:to_s) + Array(delta["assumption_ids"]).map(&:to_s)
|
|
415
|
+
raise ArgumentError, "candidate delta targets a changed conflict set" unless (targeted & current_conflicts).sort == Array(delta["conflict_ids"]).map(&:to_s).sort
|
|
416
|
+
|
|
417
|
+
decision = {
|
|
418
|
+
"id" => decision_id.to_s,
|
|
419
|
+
"kind" => accept ? "accept_candidate_delta" : "reject_candidate_delta",
|
|
420
|
+
"decided_by" => decided_by.to_s,
|
|
421
|
+
"delta_path" => relative_delta,
|
|
422
|
+
"aggregate_revision" => aggregate["revision"],
|
|
423
|
+
"aggregate_fingerprint" => aggregate["content_fingerprint"],
|
|
424
|
+
"conflict_ids" => Array(delta["conflict_ids"]),
|
|
425
|
+
"assumption_ids" => Array(delta["assumption_ids"]),
|
|
426
|
+
"created_at" => Time.now.utc.iso8601
|
|
427
|
+
}
|
|
428
|
+
raise ArgumentError, "decision id and decided_by are required" if decision_id.to_s.empty? || decided_by.to_s.empty?
|
|
429
|
+
return { "ok" => true, "action" => "adjudicate", "changed" => false, "decision" => write_decision(root, feature, decision)[1] } unless accept
|
|
430
|
+
|
|
431
|
+
updated = JSON.parse(JSON.generate(aggregate))
|
|
432
|
+
delta["operations"].each do |operation|
|
|
433
|
+
collection = updated.fetch("merged").fetch(operation.fetch("collection"))
|
|
434
|
+
index = collection.index { |item| item["id"].to_s == operation["target_id"].to_s }
|
|
435
|
+
case operation["op"]
|
|
436
|
+
when "add"
|
|
437
|
+
raise ArgumentError, "candidate add target already exists" if index
|
|
438
|
+
collection << operation.fetch("value")
|
|
439
|
+
when "replace"
|
|
440
|
+
raise ArgumentError, "candidate replace target does not exist" unless index
|
|
441
|
+
collection[index] = operation.fetch("value")
|
|
442
|
+
when "remove"
|
|
443
|
+
raise ArgumentError, "candidate remove target does not exist" unless index
|
|
444
|
+
collection.delete_at(index)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
updated["conflicts"].each { |conflict| conflict["status"] = "resolved" if Array(delta["conflict_ids"]).include?(conflict["id"]) }
|
|
448
|
+
updated["merged"]["assumptions"] = [] if Array(delta["assumption_ids"]).any?
|
|
449
|
+
updated["revision"] = aggregate["revision"] + 1
|
|
450
|
+
updated["content_fingerprint"] = fingerprint(updated.reject { |key, _| %w[revision content_fingerprint].include?(key) })
|
|
451
|
+
decision["resulting_aggregate_revision"] = updated["revision"]
|
|
452
|
+
if write
|
|
453
|
+
DevflowControlIO.atomic_write(aggregate_path, JSON.pretty_generate(updated) + "\n")
|
|
454
|
+
write_decision(root, feature, decision)
|
|
455
|
+
end
|
|
456
|
+
{ "ok" => true, "action" => "adjudicate", "changed" => true, "aggregate" => updated, "decision" => decision, "path" => relative_to_root(root, aggregate_path) }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def aggregate(root:, feature:, expected_revision:, requested_proposals:, write:)
|
|
460
|
+
directory = feature_dir(root, feature)
|
|
461
|
+
path = File.join(directory, "aggregate.json")
|
|
462
|
+
existing = load_existing_aggregate(path, feature)
|
|
463
|
+
current_revision = existing ? existing["revision"] : 0
|
|
464
|
+
raise ArgumentError, "aggregate revision mismatch: expected #{expected_revision}, current #{current_revision}" unless expected_revision == current_revision
|
|
465
|
+
|
|
466
|
+
proposals = proposal_paths(root, feature, requested_proposals).map { |absolute, relative| validate_proposal(absolute, relative, feature) }
|
|
467
|
+
candidate = build_aggregate_payload(feature, proposals, current_revision + 1)
|
|
468
|
+
if existing && existing["content_fingerprint"] == candidate["content_fingerprint"]
|
|
469
|
+
return { "ok" => true, "action" => "aggregate", "changed" => false, "aggregate" => existing, "path" => relative_to_root(root, path) }
|
|
470
|
+
end
|
|
471
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n") if write
|
|
472
|
+
{ "ok" => true, "action" => "aggregate", "changed" => true, "aggregate" => candidate, "path" => relative_to_root(root, path) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def canonical(root:, feature:, expected_revision:, write:)
|
|
476
|
+
directory = feature_dir(root, feature)
|
|
477
|
+
aggregate_path = File.join(directory, "aggregate.json")
|
|
478
|
+
aggregate = load_existing_aggregate(aggregate_path, feature)
|
|
479
|
+
raise ArgumentError, "aggregate is missing" unless aggregate
|
|
480
|
+
raise ArgumentError, "aggregate revision mismatch: expected #{expected_revision}, current #{aggregate['revision']}" unless expected_revision == aggregate["revision"]
|
|
481
|
+
unresolved = Array(aggregate["conflicts"]).select { |conflict| conflict["status"] != "resolved" }
|
|
482
|
+
raise ArgumentError, "canonical build requires all conflicts to be resolved" if unresolved.any?
|
|
483
|
+
|
|
484
|
+
model = JSON.parse(JSON.generate(aggregate.fetch("merged")))
|
|
485
|
+
assumptions = Array(model.delete("assumptions"))
|
|
486
|
+
raise ArgumentError, "canonical build requires assumptions to be adjudicated by Core" if assumptions.any?
|
|
487
|
+
|
|
488
|
+
Tempfile.create(["business-canonical", ".json"]) do |proposal_file|
|
|
489
|
+
proposal_file.write(JSON.generate(model))
|
|
490
|
+
proposal_file.flush
|
|
491
|
+
Tempfile.create(["business-canonical-out", ".json"]) do |canonical_file|
|
|
492
|
+
stdout, stderr, status = Open3.capture3("python3", VALIDATOR, "--proposal", proposal_file.path, "--canonical-out", canonical_file.path)
|
|
493
|
+
payload = JSON.parse(stdout)
|
|
494
|
+
unless status.success? && payload["valid"] == true
|
|
495
|
+
details = Array(payload["errors"]).map { |error| error["code"] }.uniq.join(", ")
|
|
496
|
+
raise ArgumentError, "canonical model is invalid: #{details.empty? ? stderr.strip : details}"
|
|
497
|
+
end
|
|
498
|
+
target = File.join(directory, "canonical.json")
|
|
499
|
+
DevflowControlIO.atomic_write(target, File.read(canonical_file.path)) if write
|
|
500
|
+
return {
|
|
501
|
+
"ok" => true,
|
|
502
|
+
"action" => "canonical",
|
|
503
|
+
"canonical_fingerprint" => payload["canonical_fingerprint"],
|
|
504
|
+
"canonical" => payload["canonical"],
|
|
505
|
+
"path" => relative_to_root(root, target),
|
|
506
|
+
"aggregate_revision" => aggregate["revision"]
|
|
507
|
+
}
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
rescue JSON::ParserError => e
|
|
511
|
+
raise ArgumentError, "canonical validator returned invalid JSON: #{e.message}"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def relative_to_root(root, path)
|
|
515
|
+
Pathname.new(path).relative_path_from(Pathname.new(File.realpath(root))).to_s
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def execute(root:, action:, feature:, expected_revision:, requested_proposals:, write:, delta_path: nil, decision_id: nil, decided_by: nil, accept: false, approved_by: nil, runtime_confirmed: false)
|
|
519
|
+
root = File.realpath(root)
|
|
520
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
521
|
+
case action
|
|
522
|
+
when "aggregate"
|
|
523
|
+
aggregate(root: root, feature: feature, expected_revision: expected_revision, requested_proposals: requested_proposals, write: write)
|
|
524
|
+
when "canonical"
|
|
525
|
+
canonical(root: root, feature: feature, expected_revision: expected_revision, write: write)
|
|
526
|
+
when "adjudicate"
|
|
527
|
+
adjudicate(root: root, feature: feature, expected_revision: expected_revision, delta_path: delta_path, decision_id: decision_id, decided_by: decided_by, accept: accept, write: write)
|
|
528
|
+
when "meta"
|
|
529
|
+
meta(root: root, feature: feature, expected_revision: expected_revision, write: write)
|
|
530
|
+
when "approve"
|
|
531
|
+
approve(root: root, feature: feature, expected_revision: expected_revision, approved_by: approved_by, runtime_confirmed: runtime_confirmed, write: write)
|
|
532
|
+
when "approval-status"
|
|
533
|
+
approval_status(root: root, feature: feature, expected_revision: expected_revision)
|
|
534
|
+
else
|
|
535
|
+
raise ArgumentError, "action must be aggregate or canonical"
|
|
536
|
+
end
|
|
537
|
+
rescue ArgumentError, DevflowControlIO::InvalidControlPath => e
|
|
538
|
+
{ "ok" => false, "reason" => "business_model_commit_failed", "message" => e.message }
|
|
539
|
+
ensure
|
|
540
|
+
DevflowControlIO.release_lock(lock)
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
if $PROGRAM_NAME == __FILE__
|
|
545
|
+
options = { root: ".", proposals: [], write: false, accept: false, runtime_confirmed: false }
|
|
546
|
+
OptionParser.new do |opts|
|
|
547
|
+
opts.on("--root PATH") { |value| options[:root] = value }
|
|
548
|
+
opts.on("--action ACTION") { |value| options[:action] = value }
|
|
549
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
550
|
+
opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
|
|
551
|
+
opts.on("--proposal PATH") { |value| options[:proposals] << value }
|
|
552
|
+
opts.on("--delta PATH") { |value| options[:delta_path] = value }
|
|
553
|
+
opts.on("--decision-id ID") { |value| options[:decision_id] = value }
|
|
554
|
+
opts.on("--decided-by USER") { |value| options[:decided_by] = value }
|
|
555
|
+
opts.on("--accept") { options[:accept] = true }
|
|
556
|
+
opts.on("--approved-by USER") { |value| options[:approved_by] = value }
|
|
557
|
+
opts.on("--runtime-confirmed") { options[:runtime_confirmed] = true }
|
|
558
|
+
opts.on("--write") { options[:write] = true }
|
|
559
|
+
opts.on("--json") { options[:json] = true }
|
|
560
|
+
end.parse!
|
|
561
|
+
abort("--action, --feature and --expected-revision are required") unless options[:action] && options[:feature] && !options[:expected_revision].nil?
|
|
562
|
+
abort("--delta, --decision-id and --decided-by are required for adjudicate") if options[:action] == "adjudicate" && [options[:delta_path], options[:decision_id], options[:decided_by]].any?(&:nil?)
|
|
563
|
+
abort("--approved-by and --runtime-confirmed are required for approve") if options[:action] == "approve" && [options[:approved_by], options[:runtime_confirmed]].any? { |value| value == false || value.nil? }
|
|
564
|
+
payload = BusinessModelCommit.execute(
|
|
565
|
+
root: options[:root], action: options[:action], feature: options[:feature], expected_revision: options[:expected_revision],
|
|
566
|
+
requested_proposals: options[:proposals], write: options[:write], delta_path: options[:delta_path], decision_id: options[:decision_id], decided_by: options[:decided_by], accept: options[:accept], approved_by: options[:approved_by], runtime_confirmed: options[:runtime_confirmed]
|
|
567
|
+
)
|
|
568
|
+
puts JSON.pretty_generate(payload)
|
|
569
|
+
exit(payload["ok"] ? 0 : 1)
|
|
570
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "yaml"
|
|
7
|
+
require_relative "gate_result"
|
|
8
|
+
|
|
9
|
+
module BusinessModelGate
|
|
10
|
+
POLICY_PATH = File.expand_path("../contracts/business-model-policy.yaml", __dir__)
|
|
11
|
+
TRANSITIONS = %w[modeling adjudicating freeze consume_downstream].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def load_policy(path = POLICY_PATH)
|
|
16
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
17
|
+
raise ArgumentError, "business model policy must be a mapping" unless value.is_a?(Hash)
|
|
18
|
+
transitions = Array(value["transition_kinds"])
|
|
19
|
+
raise ArgumentError, "business model policy transition kinds are invalid" unless transitions.sort == TRANSITIONS.sort
|
|
20
|
+
value
|
|
21
|
+
rescue Psych::Exception => e
|
|
22
|
+
raise ArgumentError, "invalid business model policy: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verdict_for(policy, code, transition_kind)
|
|
26
|
+
transition = transition_kind.to_s
|
|
27
|
+
raise ArgumentError, "unknown business model transition kind: #{transition}" unless TRANSITIONS.include?(transition)
|
|
28
|
+
rule = Array(policy["rules"]).find { |item| Array(item["codes"]).map(&:to_s).include?(code.to_s) }
|
|
29
|
+
(rule && rule[transition]) || policy.fetch("default").fetch(transition)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def evaluate(violations:, transition_kind:, gate_id: "business-model-closure", step_id: "business-model")
|
|
33
|
+
policy = load_policy
|
|
34
|
+
transition = transition_kind.to_s
|
|
35
|
+
raise ArgumentError, "unknown business model transition kind: #{transition}" unless TRANSITIONS.include?(transition)
|
|
36
|
+
items = Array(violations).select { |item| item.is_a?(Hash) }
|
|
37
|
+
return GateResult.allow(gate_id: gate_id, step_id: step_id, evidence: { "checker" => "business-closure-check", "violation_count" => 0 }) if items.empty?
|
|
38
|
+
|
|
39
|
+
decisions = items.map do |item|
|
|
40
|
+
code = item["code"].to_s
|
|
41
|
+
{ "code" => code, "subject_ref" => item["subject_ref"], "verdict" => verdict_for(policy, code, transition_kind), "facts" => item["facts"] || {} }
|
|
42
|
+
end
|
|
43
|
+
verdict = if decisions.any? { |item| item["verdict"] == "block_unsafe" }
|
|
44
|
+
"block_unsafe"
|
|
45
|
+
else
|
|
46
|
+
"await_user"
|
|
47
|
+
end
|
|
48
|
+
errors = decisions.map do |item|
|
|
49
|
+
{ "code" => item["code"], "message" => "business model closure violation", "subject_ref" => item["subject_ref"], "facts" => item["facts"] }
|
|
50
|
+
end
|
|
51
|
+
if verdict == "await_user"
|
|
52
|
+
GateResult.await_user(
|
|
53
|
+
gate_id: gate_id,
|
|
54
|
+
step_id: step_id,
|
|
55
|
+
errors: errors,
|
|
56
|
+
recovery: { "kind" => "user_adjudication", "transition_kind" => transition_kind },
|
|
57
|
+
evidence: { "checker" => "business-closure-check", "violations" => decisions },
|
|
58
|
+
)
|
|
59
|
+
else
|
|
60
|
+
GateResult.block(
|
|
61
|
+
gate_id: gate_id,
|
|
62
|
+
step_id: step_id,
|
|
63
|
+
code: "BUSINESS_MODEL_GATE_BLOCKED",
|
|
64
|
+
message: "business model cannot enter this transition",
|
|
65
|
+
details: { "violations" => errors, "transition_kind" => transition_kind },
|
|
66
|
+
evidence: { "checker" => "business-closure-check", "violations" => decisions },
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if __FILE__ == $PROGRAM_NAME
|
|
73
|
+
options = { transition_kind: nil, gate_id: "business-model-closure", step_id: "business-model" }
|
|
74
|
+
OptionParser.new do |parser|
|
|
75
|
+
parser.on("--transition-kind KIND") { |value| options[:transition_kind] = value }
|
|
76
|
+
parser.on("--gate-id ID") { |value| options[:gate_id] = value }
|
|
77
|
+
parser.on("--step-id ID") { |value| options[:step_id] = value }
|
|
78
|
+
end.parse!
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
raise ArgumentError, "--transition-kind is required" if options[:transition_kind].to_s.empty?
|
|
82
|
+
input = JSON.parse($stdin.read)
|
|
83
|
+
violations = input.is_a?(Array) ? input : input.fetch("violations", [])
|
|
84
|
+
puts JSON.pretty_generate(BusinessModelGate.evaluate(violations: violations, transition_kind: options[:transition_kind], gate_id: options[:gate_id], step_id: options[:step_id]))
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
puts JSON.pretty_generate(GateResult.block(gate_id: options[:gate_id], step_id: options[:step_id], code: "BUSINESS_GATE_POLICY_INVALID", message: error.message))
|
|
87
|
+
exit 1
|
|
88
|
+
end
|
|
89
|
+
end
|