@guanlin4924/devflow-pi 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +93 -18
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +94 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +30 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +88 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +73 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +55 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +24 -0
- package/src/constants.ts +1 -1
|
@@ -10,6 +10,7 @@ require "optparse"
|
|
|
10
10
|
require "yaml"
|
|
11
11
|
require_relative "control_io"
|
|
12
12
|
require_relative "requirement_graph"
|
|
13
|
+
require_relative "checker_registry"
|
|
13
14
|
|
|
14
15
|
options = { feature: nil, step: nil, contract: nil, write: false, json: false }
|
|
15
16
|
OptionParser.new do |o|
|
|
@@ -33,6 +34,69 @@ def interpolate(path, state)
|
|
|
33
34
|
path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def file_evidence(root, relative)
|
|
38
|
+
absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact)
|
|
39
|
+
return { "path" => relative, "exists" => false } unless File.file?(absolute)
|
|
40
|
+
|
|
41
|
+
{ "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results)
|
|
45
|
+
source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
|
|
46
|
+
authority = source_nodes.map do |node|
|
|
47
|
+
evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
|
|
48
|
+
{
|
|
49
|
+
"source_id" => node["id"], "authority" => evidence["authority"], "path" => evidence["path"] || evidence["ref"],
|
|
50
|
+
"fingerprint" => evidence["sha256"] || evidence["source_document_fingerprint"]
|
|
51
|
+
}.compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The harness may publish an append-only event log beside the machine pack.
|
|
55
|
+
# Absence is represented as unavailable, never as false/zero, so the pack
|
|
56
|
+
# cannot manufacture proof that an engine did not invoke AI.
|
|
57
|
+
event_path = File.join(".planning", "evidence", feature, "#{step}.engine-events.jsonl")
|
|
58
|
+
event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
|
|
59
|
+
events = if File.file?(event_absolute)
|
|
60
|
+
File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
|
|
61
|
+
next if line.strip.empty?
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
parsed << JSON.parse(line)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
# A malformed event is not evidence of a clean execution;
|
|
67
|
+
# retain the valid events and let the collector expose the
|
|
68
|
+
# observed subset without inventing a zero count.
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
else
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
"collector" => "core.machine_evidence/v1",
|
|
78
|
+
"state" => { "revision" => state["revision"], "step" => step },
|
|
79
|
+
"source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
|
|
80
|
+
"candidate_binding" => {
|
|
81
|
+
"status" => "observed",
|
|
82
|
+
"graph_revision" => graph["revision"],
|
|
83
|
+
"test_case_ids" => Array(graph["nodes"]).each_with_object([]) { |node, ids| ids << node["id"] if node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
84
|
+
},
|
|
85
|
+
"structured_execution" => {
|
|
86
|
+
"status" => "observed",
|
|
87
|
+
"required_files" => files,
|
|
88
|
+
"checker_ids" => checker_results.map { |item| item["id"] }
|
|
89
|
+
},
|
|
90
|
+
"engine_evidence" => {
|
|
91
|
+
"collector" => events ? "harness.engine-events/v1" : "unavailable",
|
|
92
|
+
"event_log" => event_path,
|
|
93
|
+
"ai_runtime_initialized" => events ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
|
|
94
|
+
"ai_invocation_count" => events ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
|
|
95
|
+
"evidence_status" => events ? "observed" : "unavailable"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
36
100
|
def configured_checkers(workflow, step_id)
|
|
37
101
|
explicit = workflow.dig("governance", "step_checkers", step_id)
|
|
38
102
|
checkers = if explicit
|
|
@@ -98,7 +162,7 @@ begin
|
|
|
98
162
|
unless state["current_feature"] == options[:feature] && state["current_step"] == options[:step]
|
|
99
163
|
result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
|
|
100
164
|
puts JSON.pretty_generate(result)
|
|
101
|
-
exit
|
|
165
|
+
exit 1
|
|
102
166
|
end
|
|
103
167
|
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
104
168
|
workflow_raw = File.read(workflow_path)
|
|
@@ -121,6 +185,7 @@ begin
|
|
|
121
185
|
graph, = RequirementGraph.load_graph(root, options[:feature])
|
|
122
186
|
checkers = RequirementGraph.checker_results(graph)
|
|
123
187
|
selected = configured_checkers(workflow, options[:step])
|
|
188
|
+
CheckerRegistry.validate!(selected)
|
|
124
189
|
gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
125
190
|
enforced = gate == "enforced"
|
|
126
191
|
checker_results = selected.map do |id|
|
|
@@ -133,6 +198,7 @@ begin
|
|
|
133
198
|
end
|
|
134
199
|
graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
|
|
135
200
|
checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
|
|
201
|
+
engine_evidence_gate = (step["engine_evidence_gate"] || workflow.dig("governance", "engine_evidence_gate")).to_s
|
|
136
202
|
contract_hash = nil
|
|
137
203
|
if options[:contract]
|
|
138
204
|
contract_path = File.expand_path(options[:contract], root)
|
|
@@ -140,14 +206,18 @@ begin
|
|
|
140
206
|
contract_hash = Digest::SHA256.file(contract_path).hexdigest
|
|
141
207
|
end
|
|
142
208
|
node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
|
|
209
|
+
independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results)
|
|
210
|
+
engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
|
|
211
|
+
evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
|
|
143
212
|
pack = {
|
|
144
213
|
"schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
|
|
145
214
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
|
|
146
|
-
"contract_hash" => contract_hash, "files" => files, "checkers" => checker_results,
|
|
215
|
+
"contract_hash" => contract_hash, "files" => files, "checkers" => checker_results, "evidence" => independent,
|
|
147
216
|
"counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
|
|
148
217
|
"stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
|
|
149
218
|
"view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
|
|
150
|
-
"complete" => files.all? { |item| item["exists"] } && checker_passed,
|
|
219
|
+
"complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
|
|
220
|
+
"evidence_complete" => evidence_complete,
|
|
151
221
|
"gate_mode" => gate.empty? ? "observe" : gate
|
|
152
222
|
}.compact
|
|
153
223
|
destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}.machine.json")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Canonical wayfinder quality checker. It validates references and structured
|
|
5
|
+
# coverage only; AI-provided equivalence labels are never trusted.
|
|
6
|
+
require "digest"
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "yaml"
|
|
10
|
+
require_relative "requirement_graph"
|
|
11
|
+
|
|
12
|
+
module RequirementCoverage
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate(root:, feature:)
|
|
16
|
+
directory = RequirementGraph.feature_dir(root, feature)
|
|
17
|
+
errors = []
|
|
18
|
+
source = load(File.join(directory, "source_units.yaml"), errors, "SOURCE_UNITS_MISSING")
|
|
19
|
+
coverage = load(File.join(directory, "coverage.yaml"), errors, "COVERAGE_ARTIFACT_MISSING")
|
|
20
|
+
return result(errors, {}, {}) unless source && coverage
|
|
21
|
+
validate_source(source, errors)
|
|
22
|
+
validate_coverage(coverage, errors)
|
|
23
|
+
return result(errors, {}, {}) unless errors.empty?
|
|
24
|
+
|
|
25
|
+
units = Array(source["units"])
|
|
26
|
+
unit_by_id = units.to_h { |unit| [unit["stable_unit_id"].to_s, unit] }
|
|
27
|
+
assertions = units.flat_map { |unit| Array(unit["required_assertions"]) }
|
|
28
|
+
assertion_by_id = assertions.to_h { |assertion| [assertion["assertion_id"].to_s, assertion] }
|
|
29
|
+
claims = Array(coverage["claims"])
|
|
30
|
+
claim_ids = {}
|
|
31
|
+
covered = {}
|
|
32
|
+
uncertain = {}
|
|
33
|
+
approval_required = {}
|
|
34
|
+
|
|
35
|
+
claims.each do |claim|
|
|
36
|
+
id = claim["claim_id"].to_s
|
|
37
|
+
duplicate(errors, claim_ids, id, "DUPLICATE_CLAIM_ID")
|
|
38
|
+
unit_id = claim["source_unit_id"].to_s
|
|
39
|
+
unless unit_by_id.key?(unit_id)
|
|
40
|
+
add(errors, "CLAIM_WITHOUT_UNIT", "claim references an unknown source unit", claim_id: id)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
Array(claim["assertion_ids"]).each do |assertion_id|
|
|
44
|
+
assertion = assertion_by_id[assertion_id.to_s]
|
|
45
|
+
unless assertion && assertion["source_unit_id"].to_s == unit_id
|
|
46
|
+
add(errors, "ASSERTION_REF_NOT_FOUND", "claim references an unknown assertion", claim_id: id, assertion_id: assertion_id)
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
relation = compare(assertion, claim["inherited_constraints"])
|
|
50
|
+
case relation
|
|
51
|
+
when "equivalent" then covered[assertion_id.to_s] = true
|
|
52
|
+
when "uncertain" then uncertain[assertion_id.to_s] = true
|
|
53
|
+
when "scope_widened", "constraint_tightened"
|
|
54
|
+
approval = claim["approval"]
|
|
55
|
+
expected_content_fingerprint = sha(canonical(claim["inherited_constraints"]))
|
|
56
|
+
if approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["claim_id"].to_s == id && approval["assertion_id"].to_s == assertion_id.to_s && approval["relation"].to_s == relation && approval["content_fingerprint"].to_s == expected_content_fingerprint && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
57
|
+
covered[assertion_id.to_s] = true
|
|
58
|
+
else
|
|
59
|
+
approval_required[assertion_id.to_s] = true
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
add(errors, relation == "under_coverage" ? "ASSERTION_UNCOVERED" : "CONTRADICTORY_CLAIM", "claim is not an equivalent coverage", claim_id: id, assertion_id: assertion_id, relation: relation)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
disposition_by_id = {}
|
|
68
|
+
Array(coverage["units"]).each do |entry|
|
|
69
|
+
unit_id = entry["unit_id"].to_s
|
|
70
|
+
duplicate(errors, disposition_by_id, unit_id, "DUPLICATE_COVERAGE")
|
|
71
|
+
add(errors, "UNIT_REF_NOT_FOUND", "coverage references an unknown unit", unit_id: unit_id) unless unit_by_id.key?(unit_id)
|
|
72
|
+
disposition = entry["disposition"]
|
|
73
|
+
unless disposition.is_a?(Hash) && %w[active deferred out_of_scope merged].include?(disposition["kind"].to_s)
|
|
74
|
+
add(errors, "DISPOSITION_SCHEMA_INVALID", "invalid unit disposition", unit_id: unit_id)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
if %w[deferred out_of_scope].include?(disposition["kind"].to_s)
|
|
78
|
+
approval = disposition["approval"]
|
|
79
|
+
unless approval.is_a?(Hash) && approval["approval_id"].to_s != "" && approval["unit_id"].to_s == unit_id && approval["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
80
|
+
add(errors, "DISPOSITION_APPROVAL_MISSING", "deferred/out_of_scope requires a current unit approval", unit_id: unit_id)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
unit_by_id.each_key { |id| add(errors, "UNIT_DISPOSITION_MISSING", "source unit has no disposition", unit_id: id) unless disposition_by_id.key?(id) }
|
|
85
|
+
active = disposition_by_id.select { |_id, entry| entry.dig("disposition", "kind").to_s == "active" }.keys
|
|
86
|
+
active.each do |unit_id|
|
|
87
|
+
Array(unit_by_id[unit_id]["required_assertions"]).each do |assertion|
|
|
88
|
+
id = assertion["assertion_id"].to_s
|
|
89
|
+
next if covered[id] || uncertain[id] || approval_required[id]
|
|
90
|
+
add(errors, "ASSERTION_UNCOVERED", "active assertion has no valid claim", unit_id: unit_id, assertion_id: id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
counts = { "unit_count" => units.length, "assertion_count" => assertions.length, "claim_count" => claims.length, "covered_assertion_count" => covered.length, "uncertain_assertion_count" => uncertain.length, "approval_required_assertion_count" => approval_required.length }
|
|
94
|
+
result(errors, counts, (uncertain.any? || approval_required.any?) ? "await_user" : nil)
|
|
95
|
+
rescue JSON::ParserError, Psych::Exception, ArgumentError => e
|
|
96
|
+
result([{ "code" => "COVERAGE_SCHEMA_INVALID", "message" => e.message }], {}, {})
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load(path, errors, code)
|
|
100
|
+
unless File.file?(path)
|
|
101
|
+
add(errors, code, "required coverage artifact is missing", path: path)
|
|
102
|
+
return nil
|
|
103
|
+
end
|
|
104
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
105
|
+
raise ArgumentError, "coverage artifact must be a mapping" unless value.is_a?(Hash)
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_source(source, errors)
|
|
110
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_units schema_version must be 1") unless source["schema_version"].to_i == 1
|
|
111
|
+
add(errors, "SOURCE_UNITS_NOT_CORE_GENERATED", "source_units must be produced by Core") unless source["produced_by"].to_s == "core.source_unitizer/v1"
|
|
112
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "source_document_fingerprint is required") if source["source_document_fingerprint"].to_s.empty?
|
|
113
|
+
add(errors, "SOURCE_UNITS_SCHEMA_INVALID", "units must be an array") unless source["units"].is_a?(Array)
|
|
114
|
+
Array(source["units"]).each do |unit|
|
|
115
|
+
add(errors, "UNIT_SCHEMA_INVALID", "stable_unit_id is required") if unit["stable_unit_id"].to_s.empty?
|
|
116
|
+
add(errors, "SOURCE_UNIT_REUSED_STALE", "unit source fingerprint mismatch", unit_id: unit["stable_unit_id"]) unless unit["source_document_fingerprint"].to_s == source["source_document_fingerprint"].to_s
|
|
117
|
+
add(errors, "EXCERPT_MODIFIED", "excerpt fingerprint mismatch", unit_id: unit["stable_unit_id"]) if unit["excerpt_fingerprint"] && sha(unit["excerpt"]) != unit["excerpt_fingerprint"].to_s
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_coverage(coverage, errors)
|
|
122
|
+
add(errors, "COVERAGE_SCHEMA_INVALID", "coverage schema_version must be 1") unless coverage["schema_version"].to_i == 1
|
|
123
|
+
%w[claims units].each { |key| add(errors, "COVERAGE_SCHEMA_INVALID", "#{key} must be an array") unless coverage[key].is_a?(Array) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def compare(assertion, inherited)
|
|
127
|
+
return "uncertain" unless inherited.is_a?(Hash)
|
|
128
|
+
expected = assertion.select { |key, _| %w[type polarity modality predicate condition role_scope order_constraint data_constraint exception].include?(key) }
|
|
129
|
+
actual = inherited.select { |key, _| expected.key?(key) }
|
|
130
|
+
return "equivalent" if canonical(expected) == canonical(actual)
|
|
131
|
+
return "under_coverage" if expected["polarity"] && actual["polarity"] && expected["polarity"] != actual["polarity"]
|
|
132
|
+
return "under_coverage" if expected["modality"] == "must" && actual["modality"] == "should"
|
|
133
|
+
if expected["role_scope"].is_a?(Hash) && actual["role_scope"].is_a?(Hash)
|
|
134
|
+
expected_include = Array(expected["role_scope"]["include"]); actual_include = Array(actual["role_scope"]["include"])
|
|
135
|
+
return "scope_widened" if (actual_include - expected_include).any?
|
|
136
|
+
return "under_coverage" if (expected_include - actual_include).any?
|
|
137
|
+
end
|
|
138
|
+
"uncertain"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def canonical(value)
|
|
142
|
+
JSON.generate(value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value)
|
|
143
|
+
end
|
|
144
|
+
def canonical_value(value)
|
|
145
|
+
value.is_a?(Hash) ? value.keys.sort.to_h { |key| [key, canonical_value(value[key])] } : value.is_a?(Array) ? value.map { |item| canonical_value(item) } : value
|
|
146
|
+
end
|
|
147
|
+
def sha(value)
|
|
148
|
+
Digest::SHA256.hexdigest(value.to_s)
|
|
149
|
+
end
|
|
150
|
+
def duplicate(errors, seen, id, code)
|
|
151
|
+
add(errors, code, "duplicate identifier", id: id) if seen.key?(id)
|
|
152
|
+
seen[id] = true
|
|
153
|
+
end
|
|
154
|
+
def add(errors, code, message, **extra)
|
|
155
|
+
errors << { "code" => code, "message" => message }.merge(extra)
|
|
156
|
+
end
|
|
157
|
+
def result(errors, counts, forced_verdict = nil)
|
|
158
|
+
verdict = if errors.any? then "block_unsafe" elsif forced_verdict then forced_verdict else "allow" end
|
|
159
|
+
{ "ok" => verdict == "allow", "verdict" => verdict, "errors" => errors, "warnings" => [], "counts" => counts, "evidence" => { "checker" => "requirement-coverage-check" } }
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if $PROGRAM_NAME == __FILE__
|
|
164
|
+
options = {}
|
|
165
|
+
OptionParser.new do |opts|
|
|
166
|
+
opts.on("--root PATH") { |value| options[:root] = value }
|
|
167
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
168
|
+
end.parse!
|
|
169
|
+
abort("--root and --feature are required") unless options[:root] && options[:feature]
|
|
170
|
+
payload = RequirementCoverage.validate(root: options[:root], feature: options[:feature])
|
|
171
|
+
puts JSON.pretty_generate(payload)
|
|
172
|
+
exit(payload["verdict"] == "allow" ? 0 : 1)
|
|
173
|
+
end
|
|
@@ -63,6 +63,10 @@ module RequirementGraph
|
|
|
63
63
|
JSON.generate(value)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
def deep_clone(value)
|
|
67
|
+
JSON.parse(JSON.generate(value))
|
|
68
|
+
end
|
|
69
|
+
|
|
66
70
|
def fingerprint(value)
|
|
67
71
|
Digest::SHA256.hexdigest(canonical_json(value))
|
|
68
72
|
end
|
|
@@ -1000,20 +1004,22 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
1000
1004
|
{ "ok" => true, "path" => relative, "context" => ctx }
|
|
1001
1005
|
end
|
|
1002
1006
|
when "propose"
|
|
1003
|
-
raise ArgumentError, "--write is required" unless options[:write]
|
|
1004
1007
|
raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
|
|
1005
1008
|
if graph["revision"] != options[:expected_revision]
|
|
1006
1009
|
{ "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
|
|
1007
1010
|
else
|
|
1008
1011
|
delta = JSON.parse(options[:delta_json].to_s)
|
|
1009
1012
|
allowed_kinds = options[:allowed_kinds]&.split(",")&.map(&:strip)
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1013
|
+
candidate = RequirementGraph.deep_clone(graph)
|
|
1014
|
+
applied = RequirementGraph.proposal(candidate, delta, allowed_kinds)
|
|
1015
|
+
candidate["revision"] += 1
|
|
1016
|
+
stale = Array(candidate["stale"]).any?
|
|
1017
|
+
if options[:write]
|
|
1018
|
+
RequirementGraph.write_views!(root, candidate, stale: stale)
|
|
1019
|
+
RequirementGraph.append_audit!(root, candidate, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
|
|
1020
|
+
DevflowControlIO.atomic_write(path, JSON.pretty_generate(candidate) + "\n")
|
|
1021
|
+
end
|
|
1022
|
+
{ "ok" => true, "path" => relative, "written" => options[:write], "revision" => options[:write] ? candidate["revision"] : graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(candidate), "checkers" => RequirementGraph.checker_results(candidate), "views" => candidate["views"] }
|
|
1017
1023
|
end
|
|
1018
1024
|
when "import-test-cases"
|
|
1019
1025
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
@@ -9,6 +9,9 @@ require_relative "control_io"
|
|
|
9
9
|
require_relative "phase_ownership"
|
|
10
10
|
require_relative "requirement_graph"
|
|
11
11
|
require_relative "architecture_feasibility"
|
|
12
|
+
require_relative "checker_registry"
|
|
13
|
+
require_relative "requirement_coverage"
|
|
14
|
+
require_relative "workflow_gate_contract"
|
|
12
15
|
|
|
13
16
|
module WorkflowStateValidator
|
|
14
17
|
REQUIRED_STATE_FIELDS = %w[
|
|
@@ -68,6 +71,8 @@ module WorkflowStateValidator
|
|
|
68
71
|
)
|
|
69
72
|
|
|
70
73
|
steps = Array(workflow["steps"])
|
|
74
|
+
validate_checker_registry(issues, workflow)
|
|
75
|
+
validate_workflow_gate_contract(issues, workflow)
|
|
71
76
|
step_by_id = steps.to_h { |step| [step.fetch("id"), step] }
|
|
72
77
|
step_ids = steps.map { |step| step.fetch("id") }
|
|
73
78
|
completed_ids = Array(state["completed_steps"])
|
|
@@ -77,7 +82,7 @@ module WorkflowStateValidator
|
|
|
77
82
|
validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
|
|
78
83
|
validate_track_scope(issues, state, completed_ids)
|
|
79
84
|
validate_dependencies(issues, step_by_id, completed_ids)
|
|
80
|
-
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
|
|
85
|
+
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
81
86
|
validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
|
|
82
87
|
validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
83
88
|
graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
@@ -264,13 +269,43 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
264
269
|
end
|
|
265
270
|
end
|
|
266
271
|
|
|
267
|
-
def
|
|
272
|
+
def validate_checker_registry(issues, workflow)
|
|
273
|
+
declared = workflow.dig("governance", "step_checkers")
|
|
274
|
+
return unless declared.is_a?(Hash)
|
|
275
|
+
|
|
276
|
+
declared.each do |step_id, checker_ids|
|
|
277
|
+
Array(checker_ids).each do |checker_id|
|
|
278
|
+
next if CheckerRegistry.known?(checker_id)
|
|
279
|
+
|
|
280
|
+
issues << issue(
|
|
281
|
+
"error",
|
|
282
|
+
"unknown_checker_id",
|
|
283
|
+
"Workflow references a checker that is not in the canonical checker registry",
|
|
284
|
+
step: step_id,
|
|
285
|
+
checker_id: checker_id
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate_workflow_gate_contract(issues, workflow)
|
|
292
|
+
# GateContract validation is opt-in. Older/custom workflows intentionally
|
|
293
|
+
# use a smaller checkpoint shape and must keep the legacy control-plane
|
|
294
|
+
# behavior until they declare the contract version.
|
|
295
|
+
return unless workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
296
|
+
|
|
297
|
+
WorkflowGateContract.validate(workflow).each do |entry|
|
|
298
|
+
issues << issue("error", entry["code"], entry["message"], entry.reject { |key, _| %w[code message].include?(key) })
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
268
303
|
completed_ids.each do |step_id|
|
|
269
304
|
step = step_by_id[step_id]
|
|
270
305
|
next unless step
|
|
271
306
|
|
|
272
307
|
validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint_step?(state, step_id))
|
|
273
|
-
validate_meta_output(issues, root, state, step)
|
|
308
|
+
validate_meta_output(issues, root, state, step, workflow)
|
|
274
309
|
end
|
|
275
310
|
end
|
|
276
311
|
|
|
@@ -390,7 +425,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
390
425
|
end
|
|
391
426
|
end
|
|
392
427
|
|
|
393
|
-
def validate_meta_output(issues, root, state, step)
|
|
428
|
+
def validate_meta_output(issues, root, state, step, workflow = nil)
|
|
394
429
|
meta = step["meta_output"]
|
|
395
430
|
return unless meta
|
|
396
431
|
|
|
@@ -411,6 +446,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
411
446
|
|
|
412
447
|
validate_requirement_triage_gate(issues, step, data)
|
|
413
448
|
validate_requirement_wayfinder_gate(issues, step, data)
|
|
449
|
+
validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
414
450
|
validate_domain_modeling_gate(issues, step, data)
|
|
415
451
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
416
452
|
validate_requirement_baseline_gate(issues, step, data)
|
|
@@ -427,6 +463,21 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
427
463
|
issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
|
|
428
464
|
end
|
|
429
465
|
|
|
466
|
+
def validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
467
|
+
return unless step["id"] == "requirement-wayfinder"
|
|
468
|
+
return unless workflow.nil? || workflow.dig("governance", "requirement_coverage_gate").to_s == "enforced"
|
|
469
|
+
|
|
470
|
+
result = RequirementCoverage.validate(root: root, feature: state.fetch("current_feature"))
|
|
471
|
+
return if result["verdict"] == "allow"
|
|
472
|
+
|
|
473
|
+
Array(result["errors"]).each do |entry|
|
|
474
|
+
issues << issue("error", entry["code"], entry["message"], step: step["id"], coverage: entry)
|
|
475
|
+
end
|
|
476
|
+
if result["verdict"] == "await_user"
|
|
477
|
+
issues << issue("error", "wayfinder_coverage_requires_decision", "Wayfinder coverage requires user review before completion", step: step["id"], counts: result["counts"])
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
430
481
|
def validate_requirement_triage_gate(issues, step, data)
|
|
431
482
|
return unless step["id"] == "requirement-triage"
|
|
432
483
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Validates the existing workflow topology as a GateContract source. This is
|
|
5
|
+
# intentionally declarative and read-only: it does not advance STATE or alter
|
|
6
|
+
# failure-branch behavior.
|
|
7
|
+
module WorkflowGateContract
|
|
8
|
+
CHECKPOINT_FIELDS = %w[gate_after producing_step required_outputs required_meta_output required_meta_fields prompt confirm_options skip_policy resume_behavior].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate(workflow)
|
|
13
|
+
errors = []
|
|
14
|
+
checkpoints = workflow["checkpoints"]
|
|
15
|
+
unless checkpoints.is_a?(Hash)
|
|
16
|
+
return [error("CHECKPOINTS_SCHEMA_INVALID", "workflow checkpoints must be a mapping")]
|
|
17
|
+
end
|
|
18
|
+
checkpoints.each do |id, checkpoint|
|
|
19
|
+
unless checkpoint.is_a?(Hash)
|
|
20
|
+
errors << error("CHECKPOINT_SCHEMA_INVALID", "checkpoint must be a mapping", checkpoint: id)
|
|
21
|
+
next
|
|
22
|
+
end
|
|
23
|
+
CHECKPOINT_FIELDS.each do |field|
|
|
24
|
+
errors << error("CHECKPOINT_FIELD_MISSING", "checkpoint field is required", checkpoint: id, field: field) unless checkpoint.key?(field)
|
|
25
|
+
end
|
|
26
|
+
errors << error("CHECKPOINT_PRODUCER_MISMATCH", "gate_after must equal producing_step", checkpoint: id) if checkpoint["gate_after"].to_s != checkpoint["producing_step"].to_s
|
|
27
|
+
errors << error("CHECKPOINT_OUTPUTS_INVALID", "required_outputs must be an array", checkpoint: id) unless checkpoint["required_outputs"].is_a?(Array)
|
|
28
|
+
errors << error("CHECKPOINT_META_FIELDS_INVALID", "required_meta_fields must be an array", checkpoint: id) unless checkpoint["required_meta_fields"].is_a?(Array)
|
|
29
|
+
errors << error("CHECKPOINT_OPTIONS_INVALID", "confirm_options must include approve and needs_fix", checkpoint: id) unless Array(checkpoint["confirm_options"]).sort == %w[approve needs_fix]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Array(workflow.dig("governance", "enforced_checkpoints")).each do |id|
|
|
33
|
+
errors << error("CHECKPOINT_REF_NOT_FOUND", "enforced checkpoint is not defined", checkpoint: id) unless checkpoints.key?(id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Array(workflow["parallel_windows"]).each do |window|
|
|
37
|
+
errors << error("PARALLEL_WINDOW_SCHEMA_INVALID", "parallel window must define join_step and branches", window: window["id"]) unless window.is_a?(Hash) && window["join_step"] && window["branches"].is_a?(Hash)
|
|
38
|
+
next unless window.is_a?(Hash)
|
|
39
|
+
errors << error("PARALLEL_WINDOW_CHECKPOINT_INVALID", "parallel join checkpoint is not defined", window: window["id"], checkpoint: window["checkpoint"]) if window["checkpoint"] && !checkpoints.key?(window["checkpoint"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
(workflow["failure_branches"] || {}).each do |source_step, branch|
|
|
43
|
+
resume = branch.is_a?(Hash) && branch["resume_after_success"]
|
|
44
|
+
unless resume.is_a?(Hash) && %w[flow step status checkpoint].all? { |field| resume.key?(field) }
|
|
45
|
+
errors << error("FAILURE_RETURN_TO_INVALID", "failure branch must declare flow, step, status and checkpoint", source_step: source_step)
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
errors << error("FAILURE_RETURN_CHECKPOINT_INVALID", "failure branch return checkpoint is not defined", source_step: source_step, checkpoint: resume["checkpoint"]) unless checkpoints.key?(resume["checkpoint"])
|
|
49
|
+
end
|
|
50
|
+
errors
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error(code, message, **extra)
|
|
54
|
+
{ "code" => code, "message" => message }.merge(extra)
|
|
55
|
+
end
|
|
56
|
+
end
|