@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.
@@ -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 0
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
- applied = RequirementGraph.proposal(graph, delta, allowed_kinds)
1011
- graph["revision"] += 1
1012
- stale = Array(graph["stale"]).any?
1013
- RequirementGraph.write_views!(root, graph, stale: stale)
1014
- RequirementGraph.append_audit!(root, graph, { "event" => "graph_delta", "operation" => applied["operation"], "node_id" => applied.dig("node", "id"), "stale_count" => Array(applied["stale"]).length })
1015
- DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
1016
- { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
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 validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
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