@guanlin4924/devflow-pi 0.1.15 → 0.1.17
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 +425 -54
- 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 +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +646 -0
- 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 +140 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +88 -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
- package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
- package/src/cli.ts +2 -1
- package/src/runtime.ts +2 -1
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Core-owned requirement governance graph. Skills may submit a bounded delta,
|
|
5
|
+
# but only this writer assigns IDs, computes fingerprints/impact, and persists
|
|
6
|
+
# canonical graph and derived views under the project-wide control-plane lock.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "pathname"
|
|
11
|
+
require "set"
|
|
12
|
+
require_relative "control_io"
|
|
13
|
+
require_relative "architecture_constraints"
|
|
14
|
+
|
|
15
|
+
module RequirementGraph
|
|
16
|
+
# v3 adds the two narrowly-scoped architecture inputs. Existing v1/v2
|
|
17
|
+
# graphs are migrated in memory and re-written only by this Core writer.
|
|
18
|
+
SCHEMA_VERSION = 3
|
|
19
|
+
SUPPORTED_SCHEMA_VERSIONS = [1, 2, SCHEMA_VERSION].freeze
|
|
20
|
+
ROOT = ".planning/requirements"
|
|
21
|
+
KINDS = {
|
|
22
|
+
"source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
|
|
23
|
+
"behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
|
|
24
|
+
"slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
|
|
25
|
+
"api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC"
|
|
26
|
+
}.freeze
|
|
27
|
+
VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
|
|
28
|
+
FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
|
|
29
|
+
RESERVED_KEYS = %w[id status revision passed confirmed graph_revision stale_fingerprint severity risk_level priority p0 high_risk].freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def valid_feature?(value)
|
|
34
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(FEATURE_INVALID)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def graph_path(root, feature)
|
|
38
|
+
raise ArgumentError, "invalid feature" unless valid_feature?(feature)
|
|
39
|
+
relative = File.join(ROOT, feature, "governance.json")
|
|
40
|
+
root_real = File.realpath(root)
|
|
41
|
+
absolute = File.expand_path(relative, root_real)
|
|
42
|
+
raise ArgumentError, "graph path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
43
|
+
[absolute, relative]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def feature_dir(root, feature)
|
|
47
|
+
path, = graph_path(root, feature)
|
|
48
|
+
File.dirname(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def empty_graph(feature)
|
|
52
|
+
{ "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
|
|
53
|
+
"nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def canonical_json(value)
|
|
57
|
+
JSON.generate(value)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fingerprint(value)
|
|
61
|
+
Digest::SHA256.hexdigest(canonical_json(value))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_legacy_graph(graph)
|
|
65
|
+
graph["schema_version"] = SCHEMA_VERSION
|
|
66
|
+
graph["views"] ||= {}
|
|
67
|
+
graph["audit"] ||= []
|
|
68
|
+
graph["stale"] = Array(graph["stale"]).map do |entry|
|
|
69
|
+
entry.is_a?(Hash) ? entry : { "node_id" => entry.to_s, "state" => "needs_recheck", "reason" => "legacy_unknown" }
|
|
70
|
+
end
|
|
71
|
+
Array(graph["nodes"]).each { |node| node["fingerprint"] ||= fingerprint(node.reject { |key, _| key == "fingerprint" }) if node.is_a?(Hash) }
|
|
72
|
+
graph
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_graph(root, feature)
|
|
76
|
+
path, relative = graph_path(root, feature)
|
|
77
|
+
return [empty_graph(feature), path, relative] unless File.exist?(path)
|
|
78
|
+
parsed = JSON.parse(File.read(path))
|
|
79
|
+
valid_shape = parsed.is_a?(Hash) && SUPPORTED_SCHEMA_VERSIONS.include?(parsed["schema_version"]) && parsed["feature"] == feature &&
|
|
80
|
+
parsed["revision"].is_a?(Integer) && parsed["nodes"].is_a?(Array) && parsed["edges"].is_a?(Array) && parsed["stale"].is_a?(Array)
|
|
81
|
+
raise ArgumentError, "invalid governance graph" unless valid_shape
|
|
82
|
+
[normalize_legacy_graph(parsed), path, relative]
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
raise ArgumentError, "invalid governance graph"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def contains_reserved_key?(value)
|
|
88
|
+
case value
|
|
89
|
+
when Hash
|
|
90
|
+
value.any? { |key, child| RESERVED_KEYS.include?(key.to_s) || contains_reserved_key?(child) }
|
|
91
|
+
when Array then value.any? { |child| contains_reserved_key?(child) }
|
|
92
|
+
else false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_data(value)
|
|
97
|
+
raise ArgumentError, "delta data must be an object" unless value.is_a?(Hash)
|
|
98
|
+
raise ArgumentError, "delta data cannot set reserved keys: #{RESERVED_KEYS.join(", ")}" if contains_reserved_key?(value)
|
|
99
|
+
value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def next_id(graph, prefix)
|
|
103
|
+
maximum = graph["nodes"].map do |node|
|
|
104
|
+
match = node.is_a?(Hash) && node["id"].is_a?(String) ? node["id"].match(/\A#{Regexp.escape(prefix)}-(\d+)\z/) : nil
|
|
105
|
+
match && match[1].to_i
|
|
106
|
+
end.compact.max || 0
|
|
107
|
+
format("%s-%04d", prefix, maximum + 1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def node_ids(graph)
|
|
111
|
+
graph["nodes"].map { |node| node["id"] if node.is_a?(Hash) && node["id"].is_a?(String) }.compact.to_set
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def set_node_fingerprint!(node)
|
|
115
|
+
node["fingerprint"] = fingerprint(node.reject { |key, _| key == "fingerprint" })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def downstream(graph, id, mode)
|
|
119
|
+
direct = Array(graph["edges"]).map { |edge| edge["to"] if edge.is_a?(Hash) && edge["from"] == id }.compact
|
|
120
|
+
return direct.uniq if mode == "one_hop"
|
|
121
|
+
seen = Set.new([id])
|
|
122
|
+
queue = direct.dup
|
|
123
|
+
result = []
|
|
124
|
+
until queue.empty?
|
|
125
|
+
current = queue.shift
|
|
126
|
+
next if seen.include?(current)
|
|
127
|
+
seen << current
|
|
128
|
+
result << current
|
|
129
|
+
Array(graph["edges"]).each { |edge| queue << edge["to"] if edge.is_a?(Hash) && edge["from"] == current }
|
|
130
|
+
end
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def mark_stale!(graph, upstream, affected)
|
|
135
|
+
affected.each do |node_id|
|
|
136
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
|
|
137
|
+
graph["stale"] << { "node_id" => node_id, "state" => "needs_recheck", "reason" => "upstream_changed",
|
|
138
|
+
"upstream" => upstream, "stale_fingerprint" => node && node["fingerprint"] }
|
|
139
|
+
end
|
|
140
|
+
graph["stale"].uniq! { |entry| [entry["node_id"], entry["upstream"], entry["stale_fingerprint"]] }
|
|
141
|
+
graph["stale"].each { |entry| entry["graph_revision"] = graph["revision"] + 1 }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Re-submitting a changed downstream node is the explicit Core-mediated
|
|
145
|
+
# acknowledgement that its stale fingerprint was reviewed. It may itself
|
|
146
|
+
# invalidate later consumers, so only that node's stale marker is cleared.
|
|
147
|
+
def clear_stale_for!(graph, node_id)
|
|
148
|
+
graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def proposal(graph, delta, allowed_kinds = nil)
|
|
152
|
+
raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
|
|
153
|
+
case delta["op"]
|
|
154
|
+
when "add"
|
|
155
|
+
kind = delta["kind"]
|
|
156
|
+
prefix = KINDS[kind]
|
|
157
|
+
raise ArgumentError, "unsupported node kind" unless prefix
|
|
158
|
+
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
159
|
+
data = normalize_data(delta["data"])
|
|
160
|
+
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
161
|
+
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
|
|
162
|
+
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
163
|
+
set_node_fingerprint!(node)
|
|
164
|
+
graph["nodes"] << node
|
|
165
|
+
{ "operation" => "add", "node" => node }
|
|
166
|
+
when "link"
|
|
167
|
+
from, to, relation = delta.values_at("from", "to", "relation")
|
|
168
|
+
raise ArgumentError, "link requires known from/to IDs" unless from.is_a?(String) && to.is_a?(String) && node_ids(graph).include?(from) && node_ids(graph).include?(to)
|
|
169
|
+
raise ArgumentError, "link relation is required" unless relation.is_a?(String) && relation.match?(/\A[a-z][a-z0-9_-]{0,63}\z/)
|
|
170
|
+
edge = { "from" => from, "to" => to, "relation" => relation }
|
|
171
|
+
raise ArgumentError, "duplicate graph edge" if graph["edges"].include?(edge)
|
|
172
|
+
graph["edges"] << edge
|
|
173
|
+
{ "operation" => "link", "edge" => edge }
|
|
174
|
+
when "update"
|
|
175
|
+
id = delta["id"]
|
|
176
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
177
|
+
raise ArgumentError, "update requires a known node id" unless node
|
|
178
|
+
if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
|
|
179
|
+
raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; add a new candidate and relink consumers"
|
|
180
|
+
end
|
|
181
|
+
data = normalize_data(delta["data"])
|
|
182
|
+
node["data"] = node.fetch("data", {}).merge(data)
|
|
183
|
+
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
|
|
184
|
+
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
185
|
+
set_node_fingerprint!(node)
|
|
186
|
+
clear_stale_for!(graph, id)
|
|
187
|
+
mode = delta["propagation"] || "full"
|
|
188
|
+
raise ArgumentError, "propagation must be one_hop or full" unless %w[one_hop full].include?(mode)
|
|
189
|
+
affected = downstream(graph, id, mode)
|
|
190
|
+
mark_stale!(graph, id, affected)
|
|
191
|
+
{ "operation" => "update", "node" => node, "stale" => affected, "propagation" => mode }
|
|
192
|
+
else
|
|
193
|
+
raise ArgumentError, "delta op must be add, link, or update"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Core-only 审核转换:candidate → confirmed,要求人工 CP 记录或运行证据。
|
|
198
|
+
def confirm(graph, node_id, evidence, confirmed_interactively: false)
|
|
199
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
|
|
200
|
+
raise ArgumentError, "confirm requires a known node id" unless node
|
|
201
|
+
raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
|
|
202
|
+
unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
|
|
203
|
+
raise ArgumentError, "confirm requires cp_record or evidence_ref"
|
|
204
|
+
end
|
|
205
|
+
if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
|
|
206
|
+
raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
|
|
207
|
+
end
|
|
208
|
+
confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
|
|
209
|
+
raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
|
|
210
|
+
node["status"] = "confirmed"
|
|
211
|
+
node["confirmation"] = { "origin" => "core", "evidence" => evidence }
|
|
212
|
+
set_node_fingerprint!(node)
|
|
213
|
+
clear_stale_for!(graph, node_id)
|
|
214
|
+
{ "operation" => "confirm", "node" => node }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def present?(value)
|
|
218
|
+
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# 风险等级不是模型可填写的枚举,而由 Core 从图谱结构事实推导。
|
|
222
|
+
def high_risk_by_structure?(node)
|
|
223
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
224
|
+
if node["kind"] == "behavior"
|
|
225
|
+
has_writes = present?(data["writes"]) || present?(data["side_effects"])
|
|
226
|
+
return true if has_writes && !present?(data["recovery_strategy"])
|
|
227
|
+
return true if data["concurrent"] == true && !present?(data["concurrency_control"])
|
|
228
|
+
end
|
|
229
|
+
if %w[requirement slice].include?(node["kind"])
|
|
230
|
+
scope = data["data_scope"]
|
|
231
|
+
return true if scope.is_a?(String) && !%w[current_org_only self self_only].include?(scope)
|
|
232
|
+
end
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def derived_high_risk_ids(graph)
|
|
237
|
+
Array(graph["nodes"]).select { |node| node.is_a?(Hash) && high_risk_by_structure?(node) }.map { |node| node["id"] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def required(errors, node, keys)
|
|
241
|
+
keys.each { |key| errors << "#{node["id"]}: missing #{key}" unless present?(node.dig("data", key)) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def refs_known?(graph, refs, prefixes)
|
|
245
|
+
known = node_ids(graph)
|
|
246
|
+
Array(refs).all? { |ref| ref.is_a?(String) && known.include?(ref) && prefixes.any? { |prefix| ref.start_with?("#{prefix}-") } }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def incoming_edge?(graph, from, to, relation)
|
|
250
|
+
Array(graph["edges"]).any? do |edge|
|
|
251
|
+
edge.is_a?(Hash) && edge["from"] == from && edge["to"] == to && edge["relation"] == relation
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# WSH/WLC are graph-owned facts, not a second unbounded document schema.
|
|
256
|
+
# The candidate validator owns field rules; this layer adds only the facts
|
|
257
|
+
# it can prove: graph references, traceability edges, and confirmation state.
|
|
258
|
+
def workload_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
259
|
+
return [] unless %w[workload_shape workload_constraint].include?(node["kind"])
|
|
260
|
+
|
|
261
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
262
|
+
payload = data.merge("feature" => graph["feature"], "status" => node["status"])
|
|
263
|
+
errors =
|
|
264
|
+
if node["kind"] == "workload_shape"
|
|
265
|
+
ArchitectureConstraints.validate_shape(payload, allow_confirmed: true)
|
|
266
|
+
else
|
|
267
|
+
ArchitectureConstraints.validate_constraint(payload, allow_confirmed: true)
|
|
268
|
+
end
|
|
269
|
+
errors = errors.map { |error| "#{node["id"]}: #{error}" }
|
|
270
|
+
|
|
271
|
+
if node["kind"] == "workload_shape"
|
|
272
|
+
refs = Array(data["source_refs"]) + Array(data["behavior_refs"])
|
|
273
|
+
refs.uniq.each do |ref|
|
|
274
|
+
expected = ref.to_s.start_with?("BEH-") ? %w[BEH] : %w[REQ BEH API]
|
|
275
|
+
errors << "#{node["id"]}: workload source #{ref} must exist in the graph" unless refs_known?(graph, [ref], expected)
|
|
276
|
+
errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
|
|
277
|
+
errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
workload_ref = data["workload_ref"]
|
|
281
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
|
|
282
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must link with constrains_workload" if require_edges && !incoming_edge?(graph, workload_ref, node["id"], "constrains_workload")
|
|
283
|
+
errors << "#{node["id"]}: workload_ref #{workload_ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, workload_ref)
|
|
284
|
+
|
|
285
|
+
evidence = data["evidence"]
|
|
286
|
+
if evidence.is_a?(Hash) && evidence["kind"] == "source_file"
|
|
287
|
+
evidence_ref = evidence["ref"]
|
|
288
|
+
errors << "#{node["id"]}: source_file evidence.ref must reference SRC/CODE" unless refs_known?(graph, [evidence_ref], %w[SRC CODE])
|
|
289
|
+
errors << "#{node["id"]}: source_file evidence.ref must be confirmed" if require_confirmed_inputs && !confirmed?(graph, evidence_ref)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
errors.uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def workload_confirmation_errors(graph, node, evidence)
|
|
296
|
+
errors = workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
297
|
+
if node["kind"] == "workload_constraint" && %w[approved_assumption unknown].include?(node.dig("data", "source_status")) && !evidence["cp_record"]
|
|
298
|
+
errors << "#{node["id"]}: #{node.dig("data", "source_status")} confirmation requires cp_record"
|
|
299
|
+
end
|
|
300
|
+
errors
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def architecture_option_decision?(node)
|
|
304
|
+
node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Keep architecture comparison inside the existing DECISION vocabulary. The
|
|
308
|
+
# typed payload prevents a prose-only "A/B" list from masquerading as a
|
|
309
|
+
# decision: every option needs a mechanism, fact-bound evidence and tradeoffs.
|
|
310
|
+
def architecture_decision_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
311
|
+
return [] unless architecture_option_decision?(node)
|
|
312
|
+
|
|
313
|
+
data = node.fetch("data", {})
|
|
314
|
+
errors = []
|
|
315
|
+
subject_refs = data["subject_refs"]
|
|
316
|
+
errors << "#{node["id"]}: architecture decision subject_refs must be a non-empty array" unless subject_refs.is_a?(Array) && !subject_refs.empty?
|
|
317
|
+
Array(subject_refs).each do |ref|
|
|
318
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
|
|
319
|
+
relation = ref.to_s.start_with?("WSH-") ? "informs_decision" : "constrains_decision"
|
|
320
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
|
|
321
|
+
errors << "#{node["id"]}: architecture decision subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
comparison = data["comparison"]
|
|
325
|
+
unless comparison.is_a?(Hash)
|
|
326
|
+
errors << "#{node["id"]}: architecture decision comparison is required"
|
|
327
|
+
return errors.uniq
|
|
328
|
+
end
|
|
329
|
+
mode = comparison["mode"]
|
|
330
|
+
errors << "#{node["id"]}: architecture decision comparison.mode is invalid" unless %w[multiple_options single_path].include?(mode)
|
|
331
|
+
options = comparison["options"]
|
|
332
|
+
errors << "#{node["id"]}: architecture decision comparison.options must be an array" unless options.is_a?(Array)
|
|
333
|
+
options = Array(options)
|
|
334
|
+
errors << "#{node["id"]}: multiple_options requires at least two materially different options" if mode == "multiple_options" && options.length < 2
|
|
335
|
+
errors << "#{node["id"]}: single_path requires exactly one option" if mode == "single_path" && options.length != 1
|
|
336
|
+
option_ids = []
|
|
337
|
+
options.each do |option|
|
|
338
|
+
unless option.is_a?(Hash)
|
|
339
|
+
errors << "#{node["id"]}: comparison option must be an object"
|
|
340
|
+
next
|
|
341
|
+
end
|
|
342
|
+
option_key = option["option_key"].to_s
|
|
343
|
+
option_ids << option_key unless option_key.empty?
|
|
344
|
+
label = option_key.empty? ? "<missing>" : option_key
|
|
345
|
+
errors << "#{node["id"]}: comparison option option_key is required" if option_key.empty?
|
|
346
|
+
errors << "#{node["id"]}: option #{label} requires mechanism" unless present?(option["mechanism"])
|
|
347
|
+
errors << "#{node["id"]}: option #{label} requires non-empty tradeoffs" unless option["tradeoffs"].is_a?(Array) && !option["tradeoffs"].empty?
|
|
348
|
+
refs = option["fact_refs"]
|
|
349
|
+
errors << "#{node["id"]}: option #{label} requires fact_refs" unless refs.is_a?(Array) && !refs.empty?
|
|
350
|
+
Array(refs).each do |ref|
|
|
351
|
+
errors << "#{node["id"]}: option #{label} fact_ref #{ref} must reference WSH/WLC" unless refs_known?(graph, [ref], %w[WSH WLC])
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
errors << "#{node["id"]}: architecture decision option ids must be unique" unless option_ids.uniq.length == option_ids.length
|
|
355
|
+
recommendation = comparison["recommendation_option_id"]
|
|
356
|
+
errors << "#{node["id"]}: architecture decision recommendation_option_id must name a listed option" unless option_ids.include?(recommendation)
|
|
357
|
+
errors.uniq
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def architecture_decision_confirmation_errors(graph, node)
|
|
361
|
+
architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def architecture_option_risk?(node)
|
|
365
|
+
node["kind"] == "risk" && node.dig("data", "risk_type") == "architecture_option_risk"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def architecture_risk_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
369
|
+
return [] unless architecture_option_risk?(node)
|
|
370
|
+
|
|
371
|
+
data = node.fetch("data", {})
|
|
372
|
+
errors = []
|
|
373
|
+
refs = data["subject_refs"]
|
|
374
|
+
errors << "#{node["id"]}: architecture risk subject_refs must be a non-empty array" unless refs.is_a?(Array) && !refs.empty?
|
|
375
|
+
Array(refs).each do |ref|
|
|
376
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must reference DECISION/WSH/WLC" unless refs_known?(graph, [ref], %w[DECISION WSH WLC])
|
|
377
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must link with has_architecture_risk" if require_edges && !incoming_edge?(graph, ref, node["id"], "has_architecture_risk")
|
|
378
|
+
errors << "#{node["id"]}: architecture risk subject_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
379
|
+
end
|
|
380
|
+
%w[failure_mode impact mitigation owner review_condition].each do |field|
|
|
381
|
+
errors << "#{node["id"]}: architecture risk requires #{field}" unless present?(data[field])
|
|
382
|
+
end
|
|
383
|
+
errors << "#{node["id"]}: architecture risk treatment is invalid" unless %w[mitigate accept].include?(data["treatment"])
|
|
384
|
+
errors.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def architecture_risk_confirmation_errors(graph, node)
|
|
388
|
+
architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def architecture_control_node?(node)
|
|
392
|
+
architecture_option_decision?(node) || architecture_option_risk?(node)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def confirmed?(graph, id)
|
|
396
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
397
|
+
!node.nil? && node["status"] == "confirmed"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def checker_results(graph)
|
|
401
|
+
results = {
|
|
402
|
+
"requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
|
|
403
|
+
"change-impact-check" => [], "acceptance-coverage-check" => [], "risk-derivation-check" => []
|
|
404
|
+
}
|
|
405
|
+
Array(graph["nodes"]).each do |node|
|
|
406
|
+
next unless node.is_a?(Hash)
|
|
407
|
+
case node["kind"]
|
|
408
|
+
when "requirement"
|
|
409
|
+
required(results["requirement-graph-check"], node, %w[source_refs outcome actor_and_authority data_scope behavior_ref failure_refs acceptance_refs])
|
|
410
|
+
results["requirement-graph-check"] << "#{node["id"]}: source_refs must reference SRC/CODE nodes" unless refs_known?(graph, node.dig("data", "source_refs"), %w[SRC CODE])
|
|
411
|
+
results["requirement-graph-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
|
|
412
|
+
results["requirement-graph-check"] << "#{node["id"]}: acceptance_refs must reference AC" unless refs_known?(graph, node.dig("data", "acceptance_refs"), ["AC"])
|
|
413
|
+
if node["status"] == "confirmed"
|
|
414
|
+
Array(node.dig("data", "source_refs")).each do |ref|
|
|
415
|
+
results["requirement-graph-check"] << "#{node["id"]}: source_ref #{ref} must be confirmed before backing a confirmed requirement" unless confirmed?(graph, ref)
|
|
416
|
+
end
|
|
417
|
+
beh = node.dig("data", "behavior_ref")
|
|
418
|
+
results["requirement-graph-check"] << "#{node["id"]}: behavior_ref #{beh} must be confirmed" if beh && !confirmed?(graph, beh)
|
|
419
|
+
end
|
|
420
|
+
when "behavior"
|
|
421
|
+
required(results["requirement-graph-check"], node, %w[trigger actor_and_authority preconditions state_transition invariants writes side_effects failure_paths recovery_strategy])
|
|
422
|
+
when "slice"
|
|
423
|
+
required(results["slice-independence-check"], node, %w[outcome state_change actor_and_authority data_scope reads writes side_effects dependencies acceptance_refs])
|
|
424
|
+
results["slice-independence-check"] << "#{node["id"]}: technical_only slice is not an independently verifiable business result" if node.dig("data", "technical_only") == true
|
|
425
|
+
when "api"
|
|
426
|
+
required(results["contract-coverage-check"], node, %w[request response errors permission data_scope compatibility behavior_ref requirement_refs])
|
|
427
|
+
results["contract-coverage-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
|
|
428
|
+
results["contract-coverage-check"] << "#{node["id"]}: requirement_refs must reference REQ" unless refs_known?(graph, node.dig("data", "requirement_refs"), ["REQ"])
|
|
429
|
+
when "decision"
|
|
430
|
+
results["requirement-graph-check"].concat(
|
|
431
|
+
architecture_decision_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
432
|
+
)
|
|
433
|
+
when "risk"
|
|
434
|
+
results["requirement-graph-check"].concat(
|
|
435
|
+
architecture_risk_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
436
|
+
)
|
|
437
|
+
when "workload_shape", "workload_constraint"
|
|
438
|
+
results["requirement-graph-check"].concat(
|
|
439
|
+
workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
440
|
+
)
|
|
441
|
+
when "acceptance"
|
|
442
|
+
required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
|
|
443
|
+
types = Array(node.dig("data", "verification_types"))
|
|
444
|
+
req = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node.dig("data", "requirement_ref") }
|
|
445
|
+
if req && high_risk_by_structure?(req)
|
|
446
|
+
missing = %w[business contract resilience] - types
|
|
447
|
+
results["acceptance-coverage-check"] << "#{node["id"]}: high-risk acceptance missing #{missing.join(", ")}" if missing.any?
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
stale = Array(graph["stale"])
|
|
452
|
+
results["change-impact-check"] << "#{stale.length} downstream nodes need recheck" if stale.any?
|
|
453
|
+
Array(graph["nodes"]).each do |node|
|
|
454
|
+
next unless node.is_a?(Hash)
|
|
455
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
456
|
+
%w[severity risk_level priority p0 high_risk].each do |key|
|
|
457
|
+
results["risk-derivation-check"] << "#{node["id"]}: #{key} must be Core-derived, not model-authored" if data.key?(key)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
derived = derived_high_risk_ids(graph)
|
|
461
|
+
results["risk-derivation-check"] << "derived high-risk nodes: #{derived.join(", ")}" unless derived.empty?
|
|
462
|
+
results
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def validate(graph)
|
|
466
|
+
errors = []
|
|
467
|
+
ids = []
|
|
468
|
+
Array(graph["nodes"]).each do |node|
|
|
469
|
+
unless node.is_a?(Hash) && node["id"].is_a?(String) && KINDS.key?(node["kind"]) && %w[candidate confirmed].include?(node["status"]) && node["data"].is_a?(Hash)
|
|
470
|
+
errors << "invalid node"
|
|
471
|
+
next
|
|
472
|
+
end
|
|
473
|
+
ids << node["id"]
|
|
474
|
+
end
|
|
475
|
+
errors << "duplicate node ids" unless ids.uniq.length == ids.length
|
|
476
|
+
known = ids.to_set
|
|
477
|
+
Array(graph["edges"]).each do |edge|
|
|
478
|
+
errors << "invalid edge" unless edge.is_a?(Hash) && known.include?(edge["from"]) && known.include?(edge["to"]) && edge["relation"].is_a?(String)
|
|
479
|
+
end
|
|
480
|
+
checker_results(graph).each_value { |issues| errors.concat(issues) }
|
|
481
|
+
Array(graph["nodes"]).each do |node|
|
|
482
|
+
next unless node.is_a?(Hash) && high_risk_by_structure?(node)
|
|
483
|
+
errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
|
|
484
|
+
end
|
|
485
|
+
errors.uniq
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def view_payload(graph, name)
|
|
489
|
+
kinds = case name
|
|
490
|
+
when "evidence" then %w[source code]
|
|
491
|
+
when "definitions" then %w[term object rule boundary]
|
|
492
|
+
when "behavior-graph" then %w[behavior]
|
|
493
|
+
when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint]
|
|
494
|
+
when "slices" then %w[slice]
|
|
495
|
+
when "acceptance" then %w[acceptance risk decision]
|
|
496
|
+
else []
|
|
497
|
+
end
|
|
498
|
+
nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
|
|
499
|
+
{ "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
500
|
+
"view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def write_views!(root, graph, stale: false)
|
|
504
|
+
directory = feature_dir(root, graph["feature"])
|
|
505
|
+
graph["views"] ||= {}
|
|
506
|
+
VIEW_NAMES.each do |name|
|
|
507
|
+
payload = view_payload(graph, name)
|
|
508
|
+
path = File.join(directory, "#{name}.json")
|
|
509
|
+
prior = graph["views"][name] || {}
|
|
510
|
+
state = stale ? "stale" : "fresh"
|
|
511
|
+
meta = { "path" => File.join(ROOT, graph["feature"], "#{name}.json"), "fingerprint" => fingerprint(payload),
|
|
512
|
+
"graph_revision" => graph["revision"], "state" => state, "needs_recheck" => stale,
|
|
513
|
+
"stale_fingerprint" => stale ? prior["fingerprint"] : nil }.compact
|
|
514
|
+
graph["views"][name] = meta
|
|
515
|
+
payload["view_meta"] = meta
|
|
516
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(payload) + "\n")
|
|
517
|
+
end
|
|
518
|
+
meta = { "schema_version" => SCHEMA_VERSION, "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
519
|
+
"content_fingerprint" => fingerprint(graph.reject { |key, _| %w[views audit].include?(key) }), "views" => graph["views"], "stale" => graph["stale"] }
|
|
520
|
+
DevflowControlIO.atomic_write(File.join(directory, "graph.meta.json"), JSON.pretty_generate(meta) + "\n")
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def append_audit!(root, graph, event)
|
|
524
|
+
event = event.merge("schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
|
|
525
|
+
graph["audit"] << event
|
|
526
|
+
graph["audit"] = graph["audit"].last(200)
|
|
527
|
+
path = File.join(feature_dir(root, graph["feature"]), "control-events.jsonl")
|
|
528
|
+
existing = File.exist?(path) ? File.read(path) : ""
|
|
529
|
+
DevflowControlIO.atomic_write(path, existing + JSON.generate(event) + "\n")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def context(graph, slice_id)
|
|
533
|
+
raise ArgumentError, "slice_id is required" unless slice_id.is_a?(String) && slice_id.match?(/\ASL-\d+\z/)
|
|
534
|
+
ids = Set.new([slice_id])
|
|
535
|
+
Array(graph["edges"]).each do |edge|
|
|
536
|
+
next unless edge.is_a?(Hash)
|
|
537
|
+
ids << edge["to"] if edge["from"] == slice_id
|
|
538
|
+
ids << edge["from"] if edge["to"] == slice_id
|
|
539
|
+
end
|
|
540
|
+
nodes = Array(graph["nodes"]).select { |node| ids.include?(node["id"]) }
|
|
541
|
+
payload = { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
542
|
+
"slice_id" => slice_id, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| ids.include?(edge["from"]) && ids.include?(edge["to"]) } }
|
|
543
|
+
payload["fingerprint"] = fingerprint(payload)
|
|
544
|
+
payload
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# 消费者读已持久化 L2 包并强制校验 revision/fingerprint。
|
|
548
|
+
def load_context_package(root, feature, slice_id)
|
|
549
|
+
directory = File.join(feature_dir(root, feature), "context")
|
|
550
|
+
path = File.join(directory, "#{slice_id}.json")
|
|
551
|
+
return nil unless File.exist?(path)
|
|
552
|
+
JSON.parse(File.read(path))
|
|
553
|
+
rescue JSON::ParserError
|
|
554
|
+
nil
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
if __FILE__ == $PROGRAM_NAME
|
|
559
|
+
options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, confirmed_interactively: false, write: false, read: false, json: false }
|
|
560
|
+
command = ARGV.shift
|
|
561
|
+
OptionParser.new do |opts|
|
|
562
|
+
opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
|
|
563
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
564
|
+
opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
|
|
565
|
+
opts.on("--delta-json JSON") { |value| options[:delta_json] = value }
|
|
566
|
+
opts.on("--slice-id ID") { |value| options[:slice_id] = value }
|
|
567
|
+
opts.on("--node-id ID") { |value| options[:node_id] = value }
|
|
568
|
+
opts.on("--evidence-json JSON") { |value| options[:evidence_json] = value }
|
|
569
|
+
opts.on("--allowed-kinds LIST") { |value| options[:allowed_kinds] = value }
|
|
570
|
+
opts.on("--confirmed-interactively") { options[:confirmed_interactively] = true }
|
|
571
|
+
opts.on("--write") { options[:write] = true }
|
|
572
|
+
opts.on("--read") { options[:read] = true }
|
|
573
|
+
opts.on("--json") { options[:json] = true }
|
|
574
|
+
end.parse!
|
|
575
|
+
root = Dir.pwd
|
|
576
|
+
lock = %w[propose context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
577
|
+
begin
|
|
578
|
+
graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
|
|
579
|
+
result = case command
|
|
580
|
+
when "inspect"
|
|
581
|
+
{ "ok" => true, "path" => relative, "graph" => graph, "errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph) }
|
|
582
|
+
when "validate"
|
|
583
|
+
errors = RequirementGraph.validate(graph)
|
|
584
|
+
{ "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors, "checkers" => RequirementGraph.checker_results(graph), "stale" => graph["stale"] }
|
|
585
|
+
when "context"
|
|
586
|
+
if options[:read]
|
|
587
|
+
pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
|
|
588
|
+
if pkg.nil?
|
|
589
|
+
{ "ok" => false, "reason" => "context_package_missing", "slice_id" => options[:slice_id] }
|
|
590
|
+
elsif pkg["graph_revision"] != graph["revision"] || pkg["fingerprint"].nil?
|
|
591
|
+
{ "ok" => false, "reason" => "stale_context_package", "slice_id" => options[:slice_id], "package_revision" => pkg["graph_revision"], "graph_revision" => graph["revision"] }
|
|
592
|
+
else
|
|
593
|
+
{ "ok" => true, "path" => relative, "context" => pkg, "fresh" => true }
|
|
594
|
+
end
|
|
595
|
+
else
|
|
596
|
+
ctx = RequirementGraph.context(graph, options[:slice_id])
|
|
597
|
+
if options[:write]
|
|
598
|
+
destination = File.join(RequirementGraph.feature_dir(root, graph["feature"]), "context", "#{options[:slice_id]}.json")
|
|
599
|
+
DevflowControlIO.atomic_write(destination, JSON.pretty_generate(ctx) + "\n")
|
|
600
|
+
ctx["path"] = File.join(RequirementGraph::ROOT, graph["feature"], "context", "#{options[:slice_id]}.json")
|
|
601
|
+
end
|
|
602
|
+
{ "ok" => true, "path" => relative, "context" => ctx }
|
|
603
|
+
end
|
|
604
|
+
when "propose"
|
|
605
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
606
|
+
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
607
|
+
if graph["revision"] != options[:expected_revision]
|
|
608
|
+
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
609
|
+
else
|
|
610
|
+
delta = JSON.parse(options[:delta_json].to_s)
|
|
611
|
+
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
612
|
+
applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
|
|
613
|
+
graph["revision"] += 1
|
|
614
|
+
stale = Array(graph["stale"]).any?
|
|
615
|
+
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
616
|
+
RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
|
|
617
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
618
|
+
{ "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
|
|
619
|
+
end
|
|
620
|
+
when "confirm"
|
|
621
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
622
|
+
raise ArgumentError, "--node-id is required" unless options[:node_id]
|
|
623
|
+
raise ArgumentError, "--evidence-json is required" unless options[:evidence_json]
|
|
624
|
+
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
625
|
+
if graph["revision"] != options[:expected_revision]
|
|
626
|
+
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
627
|
+
else
|
|
628
|
+
evidence = JSON.parse(options[:evidence_json].to_s)
|
|
629
|
+
applied = RequirementGraph.confirm(graph, options[:node_id], evidence, confirmed_interactively: options[:confirmed_interactively])
|
|
630
|
+
graph["revision"] += 1
|
|
631
|
+
stale = Array(graph["stale"]).any?
|
|
632
|
+
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
633
|
+
RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
|
|
634
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
635
|
+
{ "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
|
|
636
|
+
end
|
|
637
|
+
else
|
|
638
|
+
{ "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, inspect, validate, context, or confirm" }
|
|
639
|
+
end
|
|
640
|
+
rescue ArgumentError, JSON::ParserError => error
|
|
641
|
+
result = { "ok" => false, "reason" => "invalid_requirement_graph_input", "message" => error.message }
|
|
642
|
+
ensure
|
|
643
|
+
DevflowControlIO.release_lock(lock)
|
|
644
|
+
end
|
|
645
|
+
puts JSON.pretty_generate(result)
|
|
646
|
+
end
|