@guanlin4924/devflow-pi 0.1.38 → 0.1.40
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 +178 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- 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 +157 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
- 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 +131 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +8 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require "time"
|
|
11
|
+
require "yaml"
|
|
12
|
+
require_relative "control_io"
|
|
13
|
+
|
|
14
|
+
# Durable scheduler state for one workflow parallel window. This file owns
|
|
15
|
+
# dispatch state only; artifacts/index.yaml owns artifact content state and
|
|
16
|
+
# STATE.md owns the workflow snapshot/current pointer.
|
|
17
|
+
module ParallelRun
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
TERMINAL_BRANCH_STATUSES = %w[succeeded failed aborted].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def safe_feature(feature)
|
|
24
|
+
value = feature.to_s
|
|
25
|
+
raise ArgumentError, "feature is required" if value.empty?
|
|
26
|
+
raise ArgumentError, "feature contains path separators" if value.include?("/") || value.include?("\\") || value == "." || value == ".."
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def path(root, feature)
|
|
32
|
+
File.join(root, ".planning", "parallel", safe_feature(feature), "branches.yaml")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load(root, feature)
|
|
36
|
+
target = path(root, feature)
|
|
37
|
+
return nil unless File.file?(target)
|
|
38
|
+
|
|
39
|
+
value = YAML.safe_load(File.read(target), permitted_classes: [Date, Time], aliases: false)
|
|
40
|
+
raise ArgumentError, "parallel run record must be a mapping" unless value.is_a?(Hash)
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(root, feature, record)
|
|
45
|
+
raise ArgumentError, "parallel run record must be a mapping" unless record.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
DevflowControlIO.atomic_write(path(root, feature), record.to_yaml)
|
|
48
|
+
record
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def state_front_matter(markdown)
|
|
52
|
+
match = markdown.match(/\A---\n(.*?)\n---\n/m)
|
|
53
|
+
match && match[1]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attach_state_ref(root, state_path, run_id, expected_revision)
|
|
57
|
+
absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
|
|
58
|
+
original = File.read(absolute)
|
|
59
|
+
front = state_front_matter(original)
|
|
60
|
+
raise ArgumentError, "STATE.md has no YAML front matter" unless front
|
|
61
|
+
|
|
62
|
+
state = YAML.safe_load(front, permitted_classes: [], aliases: false)
|
|
63
|
+
raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
|
|
64
|
+
actual = Integer(state.fetch("revision", 0))
|
|
65
|
+
raise ArgumentError, "STATE revision conflict" unless actual == Integer(expected_revision)
|
|
66
|
+
raise ArgumentError, "STATE already references a parallel run" if state["parallel_run_ref"]
|
|
67
|
+
|
|
68
|
+
state["parallel_run_ref"] = run_id
|
|
69
|
+
state["revision"] = actual + 1
|
|
70
|
+
body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
|
|
71
|
+
DevflowControlIO.atomic_write(absolute, body)
|
|
72
|
+
state
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def prepare(root:, feature:, workflow_window:, state_revision:, branches:, task_ids: {}, state_path: ".planning/STATE.md")
|
|
76
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
77
|
+
begin
|
|
78
|
+
raise ArgumentError, "parallel run already exists" if load(root, feature)
|
|
79
|
+
|
|
80
|
+
run_id = "PR-#{SecureRandom.uuid}"
|
|
81
|
+
branch_records = Array(branches).map do |branch|
|
|
82
|
+
value = branch.is_a?(Hash) ? branch : { "branch_id" => branch }
|
|
83
|
+
workflow_step = value["workflow_step"].to_s
|
|
84
|
+
track = value["track"].to_s
|
|
85
|
+
raise ArgumentError, "workflow_step is required" if workflow_step.empty?
|
|
86
|
+
raise ArgumentError, "track is required" if track.empty?
|
|
87
|
+
branch_id = value["branch_id"].to_s
|
|
88
|
+
branch_id = "#{run_id}-#{track.sub(/\Atrack_/, "")}-#{workflow_step}" if branch_id.empty?
|
|
89
|
+
|
|
90
|
+
value.merge(
|
|
91
|
+
"branch_id" => branch_id,
|
|
92
|
+
"workflow_step" => workflow_step,
|
|
93
|
+
"track" => track,
|
|
94
|
+
"attempt" => 0,
|
|
95
|
+
"branch_revision" => 0,
|
|
96
|
+
"status" => "pending",
|
|
97
|
+
"task_id" => task_ids[branch_id],
|
|
98
|
+
"previous_branch_id" => nil,
|
|
99
|
+
"history" => [],
|
|
100
|
+
).compact
|
|
101
|
+
end
|
|
102
|
+
record = {
|
|
103
|
+
"schema_version" => SCHEMA_VERSION,
|
|
104
|
+
"run_id" => run_id,
|
|
105
|
+
"workflow_window" => workflow_window,
|
|
106
|
+
"bound_state_revision" => Integer(state_revision),
|
|
107
|
+
"run_revision" => 0,
|
|
108
|
+
"status" => "pending",
|
|
109
|
+
"created_at" => Time.now.utc.iso8601,
|
|
110
|
+
"branches" => branch_records,
|
|
111
|
+
}
|
|
112
|
+
write(root, feature, record)
|
|
113
|
+
updated_state = attach_state_ref(root, state_path, run_id, state_revision)
|
|
114
|
+
record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
|
|
115
|
+
record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
|
|
116
|
+
write(root, feature, record)
|
|
117
|
+
{ "ok" => true, "run" => record }
|
|
118
|
+
ensure
|
|
119
|
+
DevflowControlIO.release_lock(lock)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def update_branch(root:, feature:, branch_id:, expected_revision:, status:, task_id: nil, evidence_path: nil, output_fingerprint: nil, error: nil, lock: nil)
|
|
124
|
+
owned_lock = lock.nil?
|
|
125
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
126
|
+
begin
|
|
127
|
+
record = load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
128
|
+
actual = Integer(record.fetch("run_revision", 0))
|
|
129
|
+
raise ArgumentError, "parallel run revision conflict" unless actual == Integer(expected_revision)
|
|
130
|
+
|
|
131
|
+
branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
132
|
+
raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
|
|
133
|
+
raise ArgumentError, "invalid branch status: #{status}" unless (TERMINAL_BRANCH_STATUSES + %w[pending running]).include?(status)
|
|
134
|
+
raise ArgumentError, "terminal branch must retry before update" if TERMINAL_BRANCH_STATUSES.include?(branch["status"])
|
|
135
|
+
|
|
136
|
+
branch["status"] = status
|
|
137
|
+
branch["branch_revision"] = Integer(branch.fetch("branch_revision", 0)) + 1
|
|
138
|
+
branch["task_id"] = task_id if task_id
|
|
139
|
+
branch["evidence_path"] = evidence_path if evidence_path
|
|
140
|
+
branch["output_fingerprint"] = output_fingerprint if output_fingerprint
|
|
141
|
+
branch["error"] = error if error
|
|
142
|
+
record["run_revision"] = actual + 1
|
|
143
|
+
record["status"] = "failed" if status == "failed"
|
|
144
|
+
record["status"] = "running" if status == "running" && record["status"] == "pending"
|
|
145
|
+
record["status"] = "completed" if Array(record["branches"]).all? { |item| item["status"] == "succeeded" }
|
|
146
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
147
|
+
write(root, feature, record)
|
|
148
|
+
{ "ok" => true, "run" => record }
|
|
149
|
+
ensure
|
|
150
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def retry_branch(root:, feature:, branch_id:, expected_revision:, lock: nil)
|
|
155
|
+
owned_lock = lock.nil?
|
|
156
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
157
|
+
begin
|
|
158
|
+
record = load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
159
|
+
raise ArgumentError, "parallel run revision conflict" unless Integer(record.fetch("run_revision", 0)) == Integer(expected_revision)
|
|
160
|
+
branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
161
|
+
raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
|
|
162
|
+
raise ArgumentError, "only failed/aborted branch can retry" unless %w[failed aborted].include?(branch["status"])
|
|
163
|
+
|
|
164
|
+
previous_id = branch["branch_id"]
|
|
165
|
+
branch["history"] ||= []
|
|
166
|
+
branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
|
|
167
|
+
branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
|
|
168
|
+
branch["branch_revision"] = 0
|
|
169
|
+
branch["previous_branch_id"] = previous_id
|
|
170
|
+
branch["status"] = "pending"
|
|
171
|
+
branch.delete("error")
|
|
172
|
+
record["status"] = "running"
|
|
173
|
+
record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
|
|
174
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
175
|
+
write(root, feature, record)
|
|
176
|
+
{ "ok" => true, "run" => record }
|
|
177
|
+
ensure
|
|
178
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if $PROGRAM_NAME == __FILE__
|
|
184
|
+
begin
|
|
185
|
+
options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
|
|
186
|
+
OptionParser.new do |opts|
|
|
187
|
+
opts.on("--action ACTION", %w[inspect prepare update retry]) { |value| options[:action] = value }
|
|
188
|
+
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
189
|
+
opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
|
|
190
|
+
opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
|
|
191
|
+
opts.on("--branch-id ID") { |value| options[:branch_id] = value }
|
|
192
|
+
opts.on("--status STATUS") { |value| options[:status] = value }
|
|
193
|
+
opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
|
|
194
|
+
opts.on("--state PATH") { |value| options[:state_path] = value }
|
|
195
|
+
opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
|
|
196
|
+
workflow_step, track = value.split(":", 2)
|
|
197
|
+
raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
|
|
198
|
+
options[:branches] << { "workflow_step" => workflow_step, "track" => track }
|
|
199
|
+
end
|
|
200
|
+
opts.on("--json") { options[:json] = true }
|
|
201
|
+
end.parse!
|
|
202
|
+
|
|
203
|
+
raise ArgumentError, "--feature is required" unless options[:feature]
|
|
204
|
+
result = case options[:action]
|
|
205
|
+
when "inspect"
|
|
206
|
+
record = ParallelRun.load(Dir.pwd, options[:feature])
|
|
207
|
+
record ? { "ok" => true, "run" => record } : { "ok" => false, "reason" => "parallel_run_not_found" }
|
|
208
|
+
when "prepare"
|
|
209
|
+
ParallelRun.prepare(root: Dir.pwd, feature: options[:feature], workflow_window: options[:workflow_window], state_revision: options[:state_revision], branches: options[:branches], state_path: options[:state_path])
|
|
210
|
+
when "update"
|
|
211
|
+
ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
|
|
212
|
+
when "retry"
|
|
213
|
+
ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
|
|
214
|
+
else
|
|
215
|
+
raise ArgumentError, "--action must be inspect, prepare, update, or retry"
|
|
216
|
+
end
|
|
217
|
+
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
warn e.message
|
|
220
|
+
exit 1
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -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,10 @@ 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"
|
|
15
|
+
require_relative "parallel_run"
|
|
12
16
|
|
|
13
17
|
module WorkflowStateValidator
|
|
14
18
|
REQUIRED_STATE_FIELDS = %w[
|
|
@@ -68,6 +72,8 @@ module WorkflowStateValidator
|
|
|
68
72
|
)
|
|
69
73
|
|
|
70
74
|
steps = Array(workflow["steps"])
|
|
75
|
+
validate_checker_registry(issues, workflow)
|
|
76
|
+
validate_workflow_gate_contract(issues, workflow)
|
|
71
77
|
step_by_id = steps.to_h { |step| [step.fetch("id"), step] }
|
|
72
78
|
step_ids = steps.map { |step| step.fetch("id") }
|
|
73
79
|
completed_ids = Array(state["completed_steps"])
|
|
@@ -75,9 +81,13 @@ module WorkflowStateValidator
|
|
|
75
81
|
tracked_ids = completed_ids + pending_ids
|
|
76
82
|
|
|
77
83
|
validate_step_tracking(issues, state, step_ids, completed_ids, pending_ids, tracked_ids)
|
|
78
|
-
|
|
84
|
+
if state["parallel_run_ref"]
|
|
85
|
+
validate_parallel_run(issues, root, state, workflow, step_by_id)
|
|
86
|
+
else
|
|
87
|
+
validate_track_scope(issues, state, completed_ids)
|
|
88
|
+
end
|
|
79
89
|
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)
|
|
90
|
+
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
81
91
|
validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
|
|
82
92
|
validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
83
93
|
graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
@@ -264,13 +274,113 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
264
274
|
end
|
|
265
275
|
end
|
|
266
276
|
|
|
267
|
-
def
|
|
277
|
+
def validate_parallel_run(issues, root, state, workflow, step_by_id)
|
|
278
|
+
feature = state["current_feature"].to_s
|
|
279
|
+
run = ParallelRun.load(root, feature)
|
|
280
|
+
unless run
|
|
281
|
+
issues << issue("error", "parallel_run_missing", "parallel_run_ref does not resolve to a run record", feature: feature)
|
|
282
|
+
return
|
|
283
|
+
end
|
|
284
|
+
if run["run_id"].to_s != state["parallel_run_ref"].to_s
|
|
285
|
+
issues << issue("error", "parallel_run_ref_mismatch", "parallel_run_ref does not match the run record", reference: state["parallel_run_ref"], run_id: run["run_id"])
|
|
286
|
+
end
|
|
287
|
+
begin
|
|
288
|
+
bound_revision = Integer(run.fetch("bound_state_revision"))
|
|
289
|
+
issues << issue("error", "parallel_state_revision_mismatch", "parallel run is bound to a different STATE revision", state_revision: state["revision"], bound_state_revision: bound_revision) unless bound_revision == Integer(state.fetch("revision"))
|
|
290
|
+
rescue ArgumentError, TypeError, KeyError
|
|
291
|
+
issues << issue("error", "parallel_run_revision_invalid", "parallel run bound_state_revision is invalid")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
window = Array(workflow["parallel_windows"]).find { |candidate| candidate.is_a?(Hash) && candidate["id"].to_s == run["workflow_window"].to_s }
|
|
295
|
+
unless window
|
|
296
|
+
issues << issue("error", "parallel_window_not_found", "parallel run references an unknown workflow window", window: run["workflow_window"])
|
|
297
|
+
return
|
|
298
|
+
end
|
|
299
|
+
join_step = window["join_step"].to_s
|
|
300
|
+
if state["current_step"].to_s != join_step
|
|
301
|
+
issues << issue("error", "parallel_current_step_invalid", "parallel state current_step must remain the window join step", current_step: state["current_step"], join_step: join_step)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
branch_steps = window["branches"].is_a?(Hash) ? window["branches"].each_with_object({}) do |(track, steps), result|
|
|
305
|
+
Array(steps).each { |step| result[step.to_s] = track.to_s }
|
|
306
|
+
end : {}
|
|
307
|
+
seen_ids = {}
|
|
308
|
+
Array(run["branches"]).each do |branch|
|
|
309
|
+
unless branch.is_a?(Hash)
|
|
310
|
+
issues << issue("error", "parallel_branch_invalid", "parallel run branch must be a mapping")
|
|
311
|
+
next
|
|
312
|
+
end
|
|
313
|
+
branch_id = branch["branch_id"].to_s
|
|
314
|
+
step_id = branch["workflow_step"].to_s
|
|
315
|
+
track = branch["track"].to_s
|
|
316
|
+
issues << issue("error", "parallel_branch_id_missing", "parallel branch_id is required") if branch_id.empty?
|
|
317
|
+
issues << issue("error", "parallel_branch_duplicate_id", "parallel branch_id is duplicated", branch_id: branch_id) if seen_ids[branch_id]
|
|
318
|
+
seen_ids[branch_id] = true
|
|
319
|
+
issues << issue("error", "parallel_branch_step_missing", "parallel branch workflow_step is required", branch_id: branch_id) if step_id.empty?
|
|
320
|
+
issues << issue("error", "parallel_branch_step_unknown", "parallel branch step is not in the window", branch_id: branch_id, step: step_id) unless branch_steps.key?(step_id)
|
|
321
|
+
issues << issue("error", "parallel_branch_track_mismatch", "parallel branch track does not match the workflow window", branch_id: branch_id, track: track, expected_track: branch_steps[step_id]) if branch_steps[step_id] && track.sub(/\Atrack_/, "").upcase != branch_steps[step_id].sub(/\Atrack_/, "").upcase
|
|
322
|
+
begin
|
|
323
|
+
attempt = Integer(branch.fetch("attempt"))
|
|
324
|
+
branch_revision = Integer(branch.fetch("branch_revision"))
|
|
325
|
+
issues << issue("error", "parallel_branch_attempt_invalid", "parallel branch attempt must be non-negative", branch_id: branch_id) if attempt.negative?
|
|
326
|
+
issues << issue("error", "parallel_branch_revision_invalid", "parallel branch revision must be non-negative", branch_id: branch_id) if branch_revision.negative?
|
|
327
|
+
rescue ArgumentError, TypeError, KeyError
|
|
328
|
+
issues << issue("error", "parallel_branch_version_invalid", "parallel branch attempt and branch_revision are required integers", branch_id: branch_id)
|
|
329
|
+
end
|
|
330
|
+
expected_state_revision = branch["expected_state_revision"]
|
|
331
|
+
if expected_state_revision && expected_state_revision.to_i != state["revision"].to_i
|
|
332
|
+
issues << issue("error", "parallel_branch_state_revision_mismatch", "parallel branch is bound to a different STATE revision", branch_id: branch_id)
|
|
333
|
+
end
|
|
334
|
+
if step_id && state["completed_steps"].to_a.map(&:to_s).include?(step_id)
|
|
335
|
+
issues << issue("error", "parallel_step_completed_before_join", "parallel branch step must not enter completed_steps before join", branch_id: branch_id, step: step_id)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
declared_steps = branch_steps.keys
|
|
339
|
+
actual_steps = Array(run["branches"]).select { |branch| branch.is_a?(Hash) }.map { |branch| branch["workflow_step"].to_s }
|
|
340
|
+
duplicate_steps = actual_steps.each_with_object(Hash.new(0)) { |step, counts| counts[step] += 1 }.select { |_, count| count > 1 }.keys
|
|
341
|
+
duplicate_steps.each { |step| issues << issue("error", "parallel_branch_step_duplicated", "parallel run contains duplicate workflow_step records", step: step) }
|
|
342
|
+
(declared_steps - actual_steps).each { |step| issues << issue("error", "parallel_branch_record_missing", "workflow branch step has no persisted branch record", step: step) }
|
|
343
|
+
rescue ArgumentError, TypeError, Psych::Exception => error
|
|
344
|
+
issues << issue("error", "parallel_run_invalid", "parallel run record is invalid", detail: error.message)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def validate_checker_registry(issues, workflow)
|
|
348
|
+
declared = workflow.dig("governance", "step_checkers")
|
|
349
|
+
return unless declared.is_a?(Hash)
|
|
350
|
+
|
|
351
|
+
declared.each do |step_id, checker_ids|
|
|
352
|
+
Array(checker_ids).each do |checker_id|
|
|
353
|
+
next if CheckerRegistry.known?(checker_id)
|
|
354
|
+
|
|
355
|
+
issues << issue(
|
|
356
|
+
"error",
|
|
357
|
+
"unknown_checker_id",
|
|
358
|
+
"Workflow references a checker that is not in the canonical checker registry",
|
|
359
|
+
step: step_id,
|
|
360
|
+
checker_id: checker_id
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def validate_workflow_gate_contract(issues, workflow)
|
|
367
|
+
# GateContract validation is opt-in. Older/custom workflows intentionally
|
|
368
|
+
# use a smaller checkpoint shape and must keep the legacy control-plane
|
|
369
|
+
# behavior until they declare the contract version.
|
|
370
|
+
return unless workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
371
|
+
|
|
372
|
+
WorkflowGateContract.validate(workflow).each do |entry|
|
|
373
|
+
issues << issue("error", entry["code"], entry["message"], entry.reject { |key, _| %w[code message].include?(key) })
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status, workflow)
|
|
268
378
|
completed_ids.each do |step_id|
|
|
269
379
|
step = step_by_id[step_id]
|
|
270
380
|
next unless step
|
|
271
381
|
|
|
272
382
|
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)
|
|
383
|
+
validate_meta_output(issues, root, state, step, workflow)
|
|
274
384
|
end
|
|
275
385
|
end
|
|
276
386
|
|
|
@@ -390,7 +500,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
390
500
|
end
|
|
391
501
|
end
|
|
392
502
|
|
|
393
|
-
def validate_meta_output(issues, root, state, step)
|
|
503
|
+
def validate_meta_output(issues, root, state, step, workflow = nil)
|
|
394
504
|
meta = step["meta_output"]
|
|
395
505
|
return unless meta
|
|
396
506
|
|
|
@@ -411,6 +521,7 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
411
521
|
|
|
412
522
|
validate_requirement_triage_gate(issues, step, data)
|
|
413
523
|
validate_requirement_wayfinder_gate(issues, step, data)
|
|
524
|
+
validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
414
525
|
validate_domain_modeling_gate(issues, step, data)
|
|
415
526
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
416
527
|
validate_requirement_baseline_gate(issues, step, data)
|
|
@@ -427,6 +538,21 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
427
538
|
issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
|
|
428
539
|
end
|
|
429
540
|
|
|
541
|
+
def validate_wayfinder_coverage_gate(issues, root, state, step, workflow)
|
|
542
|
+
return unless step["id"] == "requirement-wayfinder"
|
|
543
|
+
return unless workflow.nil? || workflow.dig("governance", "requirement_coverage_gate").to_s == "enforced"
|
|
544
|
+
|
|
545
|
+
result = RequirementCoverage.validate(root: root, feature: state.fetch("current_feature"))
|
|
546
|
+
return if result["verdict"] == "allow"
|
|
547
|
+
|
|
548
|
+
Array(result["errors"]).each do |entry|
|
|
549
|
+
issues << issue("error", entry["code"], entry["message"], step: step["id"], coverage: entry)
|
|
550
|
+
end
|
|
551
|
+
if result["verdict"] == "await_user"
|
|
552
|
+
issues << issue("error", "wayfinder_coverage_requires_decision", "Wayfinder coverage requires user review before completion", step: step["id"], counts: result["counts"])
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
430
556
|
def validate_requirement_triage_gate(issues, step, data)
|
|
431
557
|
return unless step["id"] == "requirement-triage"
|
|
432
558
|
|