@guanlin4924/devflow-pi 0.1.14 → 0.1.16
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 +179 -54
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +4 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +93 -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 +111 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +471 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/traceability_check.py +160 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +85 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +59 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/traceability.json +23 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -1
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +10 -0
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +8 -0
- 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 +13 -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,471 @@
|
|
|
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
|
+
|
|
14
|
+
module RequirementGraph
|
|
15
|
+
SCHEMA_VERSION = 2
|
|
16
|
+
ROOT = ".planning/requirements"
|
|
17
|
+
KINDS = {
|
|
18
|
+
"source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
|
|
19
|
+
"behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
|
|
20
|
+
"slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
|
|
21
|
+
"api" => "API"
|
|
22
|
+
}.freeze
|
|
23
|
+
VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
|
|
24
|
+
FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
|
|
25
|
+
RESERVED_KEYS = %w[id status revision passed confirmed graph_revision stale_fingerprint severity risk_level priority p0 high_risk].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def valid_feature?(value)
|
|
30
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(FEATURE_INVALID)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def graph_path(root, feature)
|
|
34
|
+
raise ArgumentError, "invalid feature" unless valid_feature?(feature)
|
|
35
|
+
relative = File.join(ROOT, feature, "governance.json")
|
|
36
|
+
absolute = File.expand_path(relative, root)
|
|
37
|
+
root_real = File.realpath(root)
|
|
38
|
+
raise ArgumentError, "graph path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
39
|
+
[absolute, relative]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def feature_dir(root, feature)
|
|
43
|
+
path, = graph_path(root, feature)
|
|
44
|
+
File.dirname(path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty_graph(feature)
|
|
48
|
+
{ "schema_version" => SCHEMA_VERSION, "feature" => feature, "revision" => 0,
|
|
49
|
+
"nodes" => [], "edges" => [], "stale" => [], "views" => {}, "audit" => [] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def canonical_json(value)
|
|
53
|
+
JSON.generate(value)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fingerprint(value)
|
|
57
|
+
Digest::SHA256.hexdigest(canonical_json(value))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_legacy_graph(graph)
|
|
61
|
+
graph["schema_version"] = SCHEMA_VERSION
|
|
62
|
+
graph["views"] ||= {}
|
|
63
|
+
graph["audit"] ||= []
|
|
64
|
+
graph["stale"] = Array(graph["stale"]).map do |entry|
|
|
65
|
+
entry.is_a?(Hash) ? entry : { "node_id" => entry.to_s, "state" => "needs_recheck", "reason" => "legacy_unknown" }
|
|
66
|
+
end
|
|
67
|
+
Array(graph["nodes"]).each { |node| node["fingerprint"] ||= fingerprint(node.reject { |key, _| key == "fingerprint" }) if node.is_a?(Hash) }
|
|
68
|
+
graph
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_graph(root, feature)
|
|
72
|
+
path, relative = graph_path(root, feature)
|
|
73
|
+
return [empty_graph(feature), path, relative] unless File.exist?(path)
|
|
74
|
+
parsed = JSON.parse(File.read(path))
|
|
75
|
+
valid_shape = parsed.is_a?(Hash) && [1, SCHEMA_VERSION].include?(parsed["schema_version"]) && parsed["feature"] == feature &&
|
|
76
|
+
parsed["revision"].is_a?(Integer) && parsed["nodes"].is_a?(Array) && parsed["edges"].is_a?(Array) && parsed["stale"].is_a?(Array)
|
|
77
|
+
raise ArgumentError, "invalid governance graph" unless valid_shape
|
|
78
|
+
[normalize_legacy_graph(parsed), path, relative]
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
raise ArgumentError, "invalid governance graph"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def contains_reserved_key?(value)
|
|
84
|
+
case value
|
|
85
|
+
when Hash
|
|
86
|
+
value.any? { |key, child| RESERVED_KEYS.include?(key.to_s) || contains_reserved_key?(child) }
|
|
87
|
+
when Array then value.any? { |child| contains_reserved_key?(child) }
|
|
88
|
+
else false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_data(value)
|
|
93
|
+
raise ArgumentError, "delta data must be an object" unless value.is_a?(Hash)
|
|
94
|
+
raise ArgumentError, "delta data cannot set reserved keys: #{RESERVED_KEYS.join(", ")}" if contains_reserved_key?(value)
|
|
95
|
+
value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def next_id(graph, prefix)
|
|
99
|
+
maximum = graph["nodes"].map do |node|
|
|
100
|
+
match = node.is_a?(Hash) && node["id"].is_a?(String) ? node["id"].match(/\A#{Regexp.escape(prefix)}-(\d+)\z/) : nil
|
|
101
|
+
match && match[1].to_i
|
|
102
|
+
end.compact.max || 0
|
|
103
|
+
format("%s-%04d", prefix, maximum + 1)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def node_ids(graph)
|
|
107
|
+
graph["nodes"].map { |node| node["id"] if node.is_a?(Hash) && node["id"].is_a?(String) }.compact.to_set
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def set_node_fingerprint!(node)
|
|
111
|
+
node["fingerprint"] = fingerprint(node.reject { |key, _| key == "fingerprint" })
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def downstream(graph, id, mode)
|
|
115
|
+
direct = Array(graph["edges"]).map { |edge| edge["to"] if edge.is_a?(Hash) && edge["from"] == id }.compact
|
|
116
|
+
return direct.uniq if mode == "one_hop"
|
|
117
|
+
seen = Set.new([id])
|
|
118
|
+
queue = direct.dup
|
|
119
|
+
result = []
|
|
120
|
+
until queue.empty?
|
|
121
|
+
current = queue.shift
|
|
122
|
+
next if seen.include?(current)
|
|
123
|
+
seen << current
|
|
124
|
+
result << current
|
|
125
|
+
Array(graph["edges"]).each { |edge| queue << edge["to"] if edge.is_a?(Hash) && edge["from"] == current }
|
|
126
|
+
end
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def mark_stale!(graph, upstream, affected)
|
|
131
|
+
affected.each do |node_id|
|
|
132
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
|
|
133
|
+
graph["stale"] << { "node_id" => node_id, "state" => "needs_recheck", "reason" => "upstream_changed",
|
|
134
|
+
"upstream" => upstream, "stale_fingerprint" => node && node["fingerprint"] }
|
|
135
|
+
end
|
|
136
|
+
graph["stale"].uniq! { |entry| [entry["node_id"], entry["upstream"], entry["stale_fingerprint"]] }
|
|
137
|
+
graph["stale"].each { |entry| entry["graph_revision"] = graph["revision"] + 1 }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Re-submitting a changed downstream node is the explicit Core-mediated
|
|
141
|
+
# acknowledgement that its stale fingerprint was reviewed. It may itself
|
|
142
|
+
# invalidate later consumers, so only that node's stale marker is cleared.
|
|
143
|
+
def clear_stale_for!(graph, node_id)
|
|
144
|
+
graph["stale"].reject! { |entry| entry.is_a?(Hash) && entry["node_id"] == node_id }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def proposal(graph, delta, allowed_kinds = nil)
|
|
148
|
+
raise ArgumentError, "delta must be an object" unless delta.is_a?(Hash)
|
|
149
|
+
case delta["op"]
|
|
150
|
+
when "add"
|
|
151
|
+
kind = delta["kind"]
|
|
152
|
+
prefix = KINDS[kind]
|
|
153
|
+
raise ArgumentError, "unsupported node kind" unless prefix
|
|
154
|
+
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
155
|
+
data = normalize_data(delta["data"])
|
|
156
|
+
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
157
|
+
set_node_fingerprint!(node)
|
|
158
|
+
graph["nodes"] << node
|
|
159
|
+
{ "operation" => "add", "node" => node }
|
|
160
|
+
when "link"
|
|
161
|
+
from, to, relation = delta.values_at("from", "to", "relation")
|
|
162
|
+
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)
|
|
163
|
+
raise ArgumentError, "link relation is required" unless relation.is_a?(String) && relation.match?(/\A[a-z][a-z0-9_-]{0,63}\z/)
|
|
164
|
+
edge = { "from" => from, "to" => to, "relation" => relation }
|
|
165
|
+
raise ArgumentError, "duplicate graph edge" if graph["edges"].include?(edge)
|
|
166
|
+
graph["edges"] << edge
|
|
167
|
+
{ "operation" => "link", "edge" => edge }
|
|
168
|
+
when "update"
|
|
169
|
+
id = delta["id"]
|
|
170
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
171
|
+
raise ArgumentError, "update requires a known node id" unless node
|
|
172
|
+
data = normalize_data(delta["data"])
|
|
173
|
+
node["data"] = node.fetch("data", {}).merge(data)
|
|
174
|
+
set_node_fingerprint!(node)
|
|
175
|
+
clear_stale_for!(graph, id)
|
|
176
|
+
mode = delta["propagation"] || "full"
|
|
177
|
+
raise ArgumentError, "propagation must be one_hop or full" unless %w[one_hop full].include?(mode)
|
|
178
|
+
affected = downstream(graph, id, mode)
|
|
179
|
+
mark_stale!(graph, id, affected)
|
|
180
|
+
{ "operation" => "update", "node" => node, "stale" => affected, "propagation" => mode }
|
|
181
|
+
else
|
|
182
|
+
raise ArgumentError, "delta op must be add, link, or update"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Core-only 审核转换:candidate → confirmed,要求人工 CP 记录或运行证据。
|
|
187
|
+
def confirm(graph, node_id, evidence)
|
|
188
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
|
|
189
|
+
raise ArgumentError, "confirm requires a known node id" unless node
|
|
190
|
+
raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
|
|
191
|
+
unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
|
|
192
|
+
raise ArgumentError, "confirm requires cp_record or evidence_ref"
|
|
193
|
+
end
|
|
194
|
+
node["status"] = "confirmed"
|
|
195
|
+
node["confirmation"] = { "origin" => "core", "evidence" => evidence }
|
|
196
|
+
set_node_fingerprint!(node)
|
|
197
|
+
clear_stale_for!(graph, node_id)
|
|
198
|
+
{ "operation" => "confirm", "node" => node }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def present?(value)
|
|
202
|
+
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# 风险等级不是模型可填写的枚举,而由 Core 从图谱结构事实推导。
|
|
206
|
+
def high_risk_by_structure?(node)
|
|
207
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
208
|
+
if node["kind"] == "behavior"
|
|
209
|
+
has_writes = present?(data["writes"]) || present?(data["side_effects"])
|
|
210
|
+
return true if has_writes && !present?(data["recovery_strategy"])
|
|
211
|
+
return true if data["concurrent"] == true && !present?(data["concurrency_control"])
|
|
212
|
+
end
|
|
213
|
+
if %w[requirement slice].include?(node["kind"])
|
|
214
|
+
scope = data["data_scope"]
|
|
215
|
+
return true if scope.is_a?(String) && !%w[current_org_only self self_only].include?(scope)
|
|
216
|
+
end
|
|
217
|
+
false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def derived_high_risk_ids(graph)
|
|
221
|
+
Array(graph["nodes"]).select { |node| node.is_a?(Hash) && high_risk_by_structure?(node) }.map { |node| node["id"] }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def required(errors, node, keys)
|
|
225
|
+
keys.each { |key| errors << "#{node["id"]}: missing #{key}" unless present?(node.dig("data", key)) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def refs_known?(graph, refs, prefixes)
|
|
229
|
+
known = node_ids(graph)
|
|
230
|
+
Array(refs).all? { |ref| ref.is_a?(String) && known.include?(ref) && prefixes.any? { |prefix| ref.start_with?("#{prefix}-") } }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def confirmed?(graph, id)
|
|
234
|
+
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
235
|
+
!node.nil? && node["status"] == "confirmed"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def checker_results(graph)
|
|
239
|
+
results = {
|
|
240
|
+
"requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
|
|
241
|
+
"change-impact-check" => [], "acceptance-coverage-check" => [], "risk-derivation-check" => []
|
|
242
|
+
}
|
|
243
|
+
Array(graph["nodes"]).each do |node|
|
|
244
|
+
next unless node.is_a?(Hash)
|
|
245
|
+
case node["kind"]
|
|
246
|
+
when "requirement"
|
|
247
|
+
required(results["requirement-graph-check"], node, %w[source_refs outcome actor_and_authority data_scope behavior_ref failure_refs acceptance_refs])
|
|
248
|
+
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])
|
|
249
|
+
results["requirement-graph-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
|
|
250
|
+
results["requirement-graph-check"] << "#{node["id"]}: acceptance_refs must reference AC" unless refs_known?(graph, node.dig("data", "acceptance_refs"), ["AC"])
|
|
251
|
+
if node["status"] == "confirmed"
|
|
252
|
+
Array(node.dig("data", "source_refs")).each do |ref|
|
|
253
|
+
results["requirement-graph-check"] << "#{node["id"]}: source_ref #{ref} must be confirmed before backing a confirmed requirement" unless confirmed?(graph, ref)
|
|
254
|
+
end
|
|
255
|
+
beh = node.dig("data", "behavior_ref")
|
|
256
|
+
results["requirement-graph-check"] << "#{node["id"]}: behavior_ref #{beh} must be confirmed" if beh && !confirmed?(graph, beh)
|
|
257
|
+
end
|
|
258
|
+
when "behavior"
|
|
259
|
+
required(results["requirement-graph-check"], node, %w[trigger actor_and_authority preconditions state_transition invariants writes side_effects failure_paths recovery_strategy])
|
|
260
|
+
when "slice"
|
|
261
|
+
required(results["slice-independence-check"], node, %w[outcome state_change actor_and_authority data_scope reads writes side_effects dependencies acceptance_refs])
|
|
262
|
+
results["slice-independence-check"] << "#{node["id"]}: technical_only slice is not an independently verifiable business result" if node.dig("data", "technical_only") == true
|
|
263
|
+
when "api"
|
|
264
|
+
required(results["contract-coverage-check"], node, %w[request response errors permission data_scope compatibility behavior_ref requirement_refs])
|
|
265
|
+
results["contract-coverage-check"] << "#{node["id"]}: behavior_ref must reference BEH" unless refs_known?(graph, [node.dig("data", "behavior_ref")], ["BEH"])
|
|
266
|
+
results["contract-coverage-check"] << "#{node["id"]}: requirement_refs must reference REQ" unless refs_known?(graph, node.dig("data", "requirement_refs"), ["REQ"])
|
|
267
|
+
when "acceptance"
|
|
268
|
+
required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
|
|
269
|
+
types = Array(node.dig("data", "verification_types"))
|
|
270
|
+
req = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node.dig("data", "requirement_ref") }
|
|
271
|
+
if req && high_risk_by_structure?(req)
|
|
272
|
+
missing = %w[business contract resilience] - types
|
|
273
|
+
results["acceptance-coverage-check"] << "#{node["id"]}: high-risk acceptance missing #{missing.join(", ")}" if missing.any?
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
stale = Array(graph["stale"])
|
|
278
|
+
results["change-impact-check"] << "#{stale.length} downstream nodes need recheck" if stale.any?
|
|
279
|
+
Array(graph["nodes"]).each do |node|
|
|
280
|
+
next unless node.is_a?(Hash)
|
|
281
|
+
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
282
|
+
%w[severity risk_level priority p0 high_risk].each do |key|
|
|
283
|
+
results["risk-derivation-check"] << "#{node["id"]}: #{key} must be Core-derived, not model-authored" if data.key?(key)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
derived = derived_high_risk_ids(graph)
|
|
287
|
+
results["risk-derivation-check"] << "derived high-risk nodes: #{derived.join(", ")}" unless derived.empty?
|
|
288
|
+
results
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate(graph)
|
|
292
|
+
errors = []
|
|
293
|
+
ids = []
|
|
294
|
+
Array(graph["nodes"]).each do |node|
|
|
295
|
+
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)
|
|
296
|
+
errors << "invalid node"
|
|
297
|
+
next
|
|
298
|
+
end
|
|
299
|
+
ids << node["id"]
|
|
300
|
+
end
|
|
301
|
+
errors << "duplicate node ids" unless ids.uniq.length == ids.length
|
|
302
|
+
known = ids.to_set
|
|
303
|
+
Array(graph["edges"]).each do |edge|
|
|
304
|
+
errors << "invalid edge" unless edge.is_a?(Hash) && known.include?(edge["from"]) && known.include?(edge["to"]) && edge["relation"].is_a?(String)
|
|
305
|
+
end
|
|
306
|
+
checker_results(graph).each_value { |issues| errors.concat(issues) }
|
|
307
|
+
Array(graph["nodes"]).each do |node|
|
|
308
|
+
next unless node.is_a?(Hash) && high_risk_by_structure?(node)
|
|
309
|
+
errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
|
|
310
|
+
end
|
|
311
|
+
errors.uniq
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def view_payload(graph, name)
|
|
315
|
+
kinds = case name
|
|
316
|
+
when "evidence" then %w[source code]
|
|
317
|
+
when "definitions" then %w[term object rule boundary]
|
|
318
|
+
when "behavior-graph" then %w[behavior]
|
|
319
|
+
when "requirement-graph", "traceability" then %w[requirement rule boundary api]
|
|
320
|
+
when "slices" then %w[slice]
|
|
321
|
+
when "acceptance" then %w[acceptance risk decision]
|
|
322
|
+
else []
|
|
323
|
+
end
|
|
324
|
+
nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
|
|
325
|
+
{ "schema_version" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
326
|
+
"view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def write_views!(root, graph, stale: false)
|
|
330
|
+
directory = feature_dir(root, graph["feature"])
|
|
331
|
+
graph["views"] ||= {}
|
|
332
|
+
VIEW_NAMES.each do |name|
|
|
333
|
+
payload = view_payload(graph, name)
|
|
334
|
+
path = File.join(directory, "#{name}.json")
|
|
335
|
+
prior = graph["views"][name] || {}
|
|
336
|
+
state = stale ? "stale" : "fresh"
|
|
337
|
+
meta = { "path" => File.join(ROOT, graph["feature"], "#{name}.json"), "fingerprint" => fingerprint(payload),
|
|
338
|
+
"graph_revision" => graph["revision"], "state" => state, "needs_recheck" => stale,
|
|
339
|
+
"stale_fingerprint" => stale ? prior["fingerprint"] : nil }.compact
|
|
340
|
+
graph["views"][name] = meta
|
|
341
|
+
payload["view_meta"] = meta
|
|
342
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(payload) + "\n")
|
|
343
|
+
end
|
|
344
|
+
meta = { "schema_version" => 2, "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
345
|
+
"content_fingerprint" => fingerprint(graph.reject { |key, _| %w[views audit].include?(key) }), "views" => graph["views"], "stale" => graph["stale"] }
|
|
346
|
+
DevflowControlIO.atomic_write(File.join(directory, "graph.meta.json"), JSON.pretty_generate(meta) + "\n")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def append_audit!(root, graph, event)
|
|
350
|
+
event = event.merge("schema_version" => 2, "origin" => "core_requirement_graph", "graph_revision" => graph["revision"])
|
|
351
|
+
graph["audit"] << event
|
|
352
|
+
graph["audit"] = graph["audit"].last(200)
|
|
353
|
+
path = File.join(feature_dir(root, graph["feature"]), "control-events.jsonl")
|
|
354
|
+
existing = File.exist?(path) ? File.read(path) : ""
|
|
355
|
+
DevflowControlIO.atomic_write(path, existing + JSON.generate(event) + "\n")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def context(graph, slice_id)
|
|
359
|
+
raise ArgumentError, "slice_id is required" unless slice_id.is_a?(String) && slice_id.match?(/\ASL-\d+\z/)
|
|
360
|
+
ids = Set.new([slice_id])
|
|
361
|
+
Array(graph["edges"]).each do |edge|
|
|
362
|
+
next unless edge.is_a?(Hash)
|
|
363
|
+
ids << edge["to"] if edge["from"] == slice_id
|
|
364
|
+
ids << edge["from"] if edge["to"] == slice_id
|
|
365
|
+
end
|
|
366
|
+
nodes = Array(graph["nodes"]).select { |node| ids.include?(node["id"]) }
|
|
367
|
+
payload = { "schema_version" => 2, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
|
|
368
|
+
"slice_id" => slice_id, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| ids.include?(edge["from"]) && ids.include?(edge["to"]) } }
|
|
369
|
+
payload["fingerprint"] = fingerprint(payload)
|
|
370
|
+
payload
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# 消费者读已持久化 L2 包并强制校验 revision/fingerprint。
|
|
374
|
+
def load_context_package(root, feature, slice_id)
|
|
375
|
+
directory = File.join(feature_dir(root, feature), "context")
|
|
376
|
+
path = File.join(directory, "#{slice_id}.json")
|
|
377
|
+
return nil unless File.exist?(path)
|
|
378
|
+
JSON.parse(File.read(path))
|
|
379
|
+
rescue JSON::ParserError
|
|
380
|
+
nil
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if __FILE__ == $PROGRAM_NAME
|
|
385
|
+
options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, write: false, read: false, json: false }
|
|
386
|
+
command = ARGV.shift
|
|
387
|
+
OptionParser.new do |opts|
|
|
388
|
+
opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
|
|
389
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
390
|
+
opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
|
|
391
|
+
opts.on("--delta-json JSON") { |value| options[:delta_json] = value }
|
|
392
|
+
opts.on("--slice-id ID") { |value| options[:slice_id] = value }
|
|
393
|
+
opts.on("--node-id ID") { |value| options[:node_id] = value }
|
|
394
|
+
opts.on("--evidence-json JSON") { |value| options[:evidence_json] = value }
|
|
395
|
+
opts.on("--allowed-kinds LIST") { |value| options[:allowed_kinds] = value }
|
|
396
|
+
opts.on("--write") { options[:write] = true }
|
|
397
|
+
opts.on("--read") { options[:read] = true }
|
|
398
|
+
opts.on("--json") { options[:json] = true }
|
|
399
|
+
end.parse!
|
|
400
|
+
root = Dir.pwd
|
|
401
|
+
lock = %w[propose context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
402
|
+
begin
|
|
403
|
+
graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
|
|
404
|
+
result = case command
|
|
405
|
+
when "inspect"
|
|
406
|
+
{ "ok" => true, "path" => relative, "graph" => graph, "errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph) }
|
|
407
|
+
when "validate"
|
|
408
|
+
errors = RequirementGraph.validate(graph)
|
|
409
|
+
{ "ok" => errors.empty?, "path" => relative, "revision" => graph["revision"], "errors" => errors, "checkers" => RequirementGraph.checker_results(graph), "stale" => graph["stale"] }
|
|
410
|
+
when "context"
|
|
411
|
+
if options[:read]
|
|
412
|
+
pkg = RequirementGraph.load_context_package(root, graph["feature"], options[:slice_id])
|
|
413
|
+
if pkg.nil?
|
|
414
|
+
{ "ok" => false, "reason" => "context_package_missing", "slice_id" => options[:slice_id] }
|
|
415
|
+
elsif pkg["graph_revision"] != graph["revision"] || pkg["fingerprint"].nil?
|
|
416
|
+
{ "ok" => false, "reason" => "stale_context_package", "slice_id" => options[:slice_id], "package_revision" => pkg["graph_revision"], "graph_revision" => graph["revision"] }
|
|
417
|
+
else
|
|
418
|
+
{ "ok" => true, "path" => relative, "context" => pkg, "fresh" => true }
|
|
419
|
+
end
|
|
420
|
+
else
|
|
421
|
+
ctx = RequirementGraph.context(graph, options[:slice_id])
|
|
422
|
+
if options[:write]
|
|
423
|
+
destination = File.join(RequirementGraph.feature_dir(root, graph["feature"]), "context", "#{options[:slice_id]}.json")
|
|
424
|
+
DevflowControlIO.atomic_write(destination, JSON.pretty_generate(ctx) + "\n")
|
|
425
|
+
ctx["path"] = File.join(RequirementGraph::ROOT, graph["feature"], "context", "#{options[:slice_id]}.json")
|
|
426
|
+
end
|
|
427
|
+
{ "ok" => true, "path" => relative, "context" => ctx }
|
|
428
|
+
end
|
|
429
|
+
when "propose"
|
|
430
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
431
|
+
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
432
|
+
if graph["revision"] != options[:expected_revision]
|
|
433
|
+
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
434
|
+
else
|
|
435
|
+
delta = JSON.parse(options[:delta_json].to_s)
|
|
436
|
+
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
437
|
+
applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
|
|
438
|
+
graph["revision"] += 1
|
|
439
|
+
stale = Array(graph["stale"]).any?
|
|
440
|
+
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
441
|
+
RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
|
|
442
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
443
|
+
{ "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
|
|
444
|
+
end
|
|
445
|
+
when "confirm"
|
|
446
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
447
|
+
raise ArgumentError, "--node-id is required" unless options[:node_id]
|
|
448
|
+
raise ArgumentError, "--evidence-json is required" unless options[:evidence_json]
|
|
449
|
+
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
450
|
+
if graph["revision"] != options[:expected_revision]
|
|
451
|
+
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
452
|
+
else
|
|
453
|
+
evidence = JSON.parse(options[:evidence_json].to_s)
|
|
454
|
+
applied = RequirementGraph.confirm(graph, options[:node_id], evidence)
|
|
455
|
+
graph["revision"] += 1
|
|
456
|
+
stale = Array(graph["stale"]).any?
|
|
457
|
+
RequirementGraph.write_views!(root, graph, stale: stale)
|
|
458
|
+
RequirementGraph.append_audit!(root, graph, { "event" => "graph_confirm", "node_id" => options[:node_id] })
|
|
459
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
|
|
460
|
+
{ "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
|
|
461
|
+
end
|
|
462
|
+
else
|
|
463
|
+
{ "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, inspect, validate, context, or confirm" }
|
|
464
|
+
end
|
|
465
|
+
rescue ArgumentError, JSON::ParserError => error
|
|
466
|
+
result = { "ok" => false, "reason" => "invalid_requirement_graph_input", "message" => error.message }
|
|
467
|
+
ensure
|
|
468
|
+
DevflowControlIO.release_lock(lock)
|
|
469
|
+
end
|
|
470
|
+
puts JSON.pretty_generate(result)
|
|
471
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed requirement-to-delivery traceability validation."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
STAGES = ("baseline", "design", "change-plan", "verify")
|
|
11
|
+
DIMENSIONS = ("input", "output", "behavior", "orchestration", "failure", "verification")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fail(errors: list[str], message: str) -> None:
|
|
15
|
+
errors.append(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def references(items: object) -> set[str]:
|
|
19
|
+
if not isinstance(items, list):
|
|
20
|
+
return set()
|
|
21
|
+
result = set()
|
|
22
|
+
for item in items:
|
|
23
|
+
if isinstance(item, str):
|
|
24
|
+
result.add(item)
|
|
25
|
+
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
26
|
+
result.add(item["id"])
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check(data: dict, stage: str) -> list[str]:
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
requirements = data.get("requirements")
|
|
33
|
+
if data.get("schema_version") != 1:
|
|
34
|
+
fail(errors, "schema_version must be 1")
|
|
35
|
+
if not isinstance(data.get("feature"), str) or not data["feature"].strip():
|
|
36
|
+
fail(errors, "feature is required")
|
|
37
|
+
if not isinstance(requirements, list) or not requirements:
|
|
38
|
+
return errors + ["requirements must be a non-empty array"]
|
|
39
|
+
|
|
40
|
+
active: set[str] = set()
|
|
41
|
+
known: set[str] = set()
|
|
42
|
+
for req in requirements:
|
|
43
|
+
if not isinstance(req, dict):
|
|
44
|
+
fail(errors, "every requirement must be an object")
|
|
45
|
+
continue
|
|
46
|
+
rid = req.get("id")
|
|
47
|
+
if not isinstance(rid, str) or not rid.startswith("REQ-"):
|
|
48
|
+
fail(errors, f"invalid requirement id: {rid!r}")
|
|
49
|
+
continue
|
|
50
|
+
known.add(rid)
|
|
51
|
+
if req.get("status", "confirmed") in {"confirmed", "approved"} and req.get("scope", "in") == "in":
|
|
52
|
+
active.add(rid)
|
|
53
|
+
dimensions = req.get("dimensions")
|
|
54
|
+
if not isinstance(dimensions, dict):
|
|
55
|
+
fail(errors, f"{rid}: dimensions is required")
|
|
56
|
+
else:
|
|
57
|
+
missing = [d for d in DIMENSIONS if not isinstance(dimensions.get(d), str) or not dimensions[d].strip()]
|
|
58
|
+
if missing:
|
|
59
|
+
fail(errors, f"{rid}: missing dimensions: {', '.join(missing)}")
|
|
60
|
+
|
|
61
|
+
if stage == "baseline":
|
|
62
|
+
return errors
|
|
63
|
+
|
|
64
|
+
designs = data.get("designs")
|
|
65
|
+
if not isinstance(designs, list) or not designs:
|
|
66
|
+
fail(errors, "designs must be a non-empty array after baseline")
|
|
67
|
+
covered_by_design = set()
|
|
68
|
+
for item in designs or []:
|
|
69
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
70
|
+
fail(errors, "every design needs an id")
|
|
71
|
+
continue
|
|
72
|
+
refs = references(item.get("requirements"))
|
|
73
|
+
unknown = refs - known
|
|
74
|
+
if unknown:
|
|
75
|
+
fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
76
|
+
covered_by_design |= refs
|
|
77
|
+
if item.get("kind") == "api":
|
|
78
|
+
contract = item.get("contract")
|
|
79
|
+
if not isinstance(contract, dict) or any(not contract.get(k) for k in ("request", "response", "errors", "behavior")):
|
|
80
|
+
fail(errors, f"{item['id']}: API design must include request, response, errors and behavior")
|
|
81
|
+
missing = active - covered_by_design
|
|
82
|
+
if missing:
|
|
83
|
+
fail(errors, "requirements missing design coverage: " + ", ".join(sorted(missing)))
|
|
84
|
+
if stage == "design":
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
tasks = data.get("tasks")
|
|
88
|
+
if not isinstance(tasks, list) or not tasks:
|
|
89
|
+
fail(errors, "tasks must be a non-empty array after design")
|
|
90
|
+
covered_by_task = set()
|
|
91
|
+
for task in tasks or []:
|
|
92
|
+
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
|
|
93
|
+
fail(errors, "every task needs an id")
|
|
94
|
+
continue
|
|
95
|
+
refs = task.get("requirements")
|
|
96
|
+
if not isinstance(refs, list) or not refs:
|
|
97
|
+
fail(errors, f"{task['id']}: requirements is required")
|
|
98
|
+
else:
|
|
99
|
+
covered_by_task.update(refs)
|
|
100
|
+
unknown = set(refs) - known
|
|
101
|
+
if unknown:
|
|
102
|
+
fail(errors, f"{task['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
103
|
+
criteria = task.get("acceptance")
|
|
104
|
+
if not isinstance(criteria, dict):
|
|
105
|
+
fail(errors, f"{task['id']}: acceptance is required")
|
|
106
|
+
else:
|
|
107
|
+
missing = [d for d in DIMENSIONS if not isinstance(criteria.get(d), str) or not criteria[d].strip()]
|
|
108
|
+
if missing:
|
|
109
|
+
fail(errors, f"{task['id']}: missing acceptance dimensions: {', '.join(missing)}")
|
|
110
|
+
missing = active - covered_by_task
|
|
111
|
+
if missing:
|
|
112
|
+
fail(errors, "requirements missing task coverage: " + ", ".join(sorted(missing)))
|
|
113
|
+
if stage == "change-plan":
|
|
114
|
+
return errors
|
|
115
|
+
|
|
116
|
+
verification = data.get("verification")
|
|
117
|
+
if not isinstance(verification, list) or not verification:
|
|
118
|
+
fail(errors, "verification must be a non-empty array after change-plan")
|
|
119
|
+
covered_by_verification = set()
|
|
120
|
+
for item in verification or []:
|
|
121
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
122
|
+
fail(errors, "every verification item needs an id")
|
|
123
|
+
continue
|
|
124
|
+
refs = item.get("requirements")
|
|
125
|
+
if not isinstance(refs, list) or not refs:
|
|
126
|
+
fail(errors, f"{item['id']}: requirements is required")
|
|
127
|
+
else:
|
|
128
|
+
covered_by_verification.update(refs)
|
|
129
|
+
unknown = set(refs) - known
|
|
130
|
+
if unknown:
|
|
131
|
+
fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
132
|
+
if not isinstance(item.get("evidence"), str) or not item["evidence"].strip():
|
|
133
|
+
fail(errors, f"{item['id']}: evidence is required")
|
|
134
|
+
missing = active - covered_by_verification
|
|
135
|
+
if missing:
|
|
136
|
+
fail(errors, "requirements missing verification coverage: " + ", ".join(sorted(missing)))
|
|
137
|
+
return errors
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
parser = argparse.ArgumentParser()
|
|
142
|
+
parser.add_argument("--file", required=True)
|
|
143
|
+
parser.add_argument("--stage", choices=STAGES, required=True)
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(Path(args.file).read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
148
|
+
print(f"TRACEABILITY_FAIL: cannot read manifest: {exc}", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
print("TRACEABILITY_FAIL: manifest root must be an object", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
errors = check(data, args.stage)
|
|
154
|
+
result = {"status": "passed" if not errors else "failed", "stage": args.stage, "errors": errors}
|
|
155
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
156
|
+
return 0 if not errors else 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
raise SystemExit(main())
|