@guanlin4924/devflow-pi 0.1.19 → 0.1.21

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.
Files changed (36) hide show
  1. package/README.md +19 -0
  2. package/package.json +3 -3
  3. package/plugins/devflow-core/extensions/devflow-core.ts +152 -3
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +394 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +86 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +192 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +829 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +7 -5
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +11 -1
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +5 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +39 -3
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +309 -23
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tdd_evidence.py +311 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +53 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_task_binding_check.py +254 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +109 -4
  17. package/plugins/devflow-core/skills/workflow-orchestrator/templates/uat-remediation.yaml +21 -1
  18. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -0
  19. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +38 -0
  20. package/plugins/devflow-implementation/agents/implementation-worker.md +3 -0
  21. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +7 -0
  22. package/plugins/devflow-implementation/skills/workflow-change-plan/references/output-template.md +2 -0
  23. package/plugins/devflow-implementation/skills/workflow-execute-phase/SKILL.md +3 -3
  24. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +63 -0
  25. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/agents/openai.yaml +4 -0
  26. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +7 -6
  27. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +3 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +49 -3
  29. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +48 -0
  30. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/agents/openai.yaml +4 -0
  31. package/plugins/devflow-quality/skills/workflow-verify-work/SKILL.md +9 -3
  32. package/src/constants.ts +1 -1
  33. package/src/doctor.ts +6 -2
  34. package/src/project-context.ts +11 -1
  35. package/src/runtime.ts +7 -0
  36. package/src/usage-stats.ts +3 -3
@@ -12,12 +12,14 @@ require_relative "requirement_graph"
12
12
 
13
13
  POLICY = {
14
14
  "requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
15
- "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
15
+ "api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
16
16
  "architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
17
17
  "integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
18
- "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
19
- "execute" => { "checkers" => %w[change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
20
- "verify" => { "checkers" => %w[acceptance-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[acceptance] }
18
+ "acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
19
+ "change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
20
+ "execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
21
+ "run-acceptance-tests" => { "checkers" => %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => [] },
22
+ "verify" => { "checkers" => %w[acceptance-coverage-check acceptance-test-results-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[acceptance] }
21
23
  }.freeze
22
24
 
23
25
  options = { feature: nil, step: nil, expected_state_revision: nil, expected_graph_revision: nil, write: false, json: false }
@@ -67,7 +69,7 @@ begin
67
69
  "state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
68
70
  "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
69
71
  "allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
70
- "allowed_tools" => %w[read grep find glob lsp search write edit apply_patch bash shell exec_command task],
72
+ "allowed_tools" => %w[read grep find glob lsp search write edit apply_patch bash shell exec_command task devflow_api_test_matrix devflow_tdd_evidence devflow_run_acceptance_tests],
71
73
  "tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
72
74
  "allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
73
75
  "on_unknown" => "block_and_escalate"
@@ -87,7 +87,17 @@ fallback_pending_steps = [
87
87
  ]
88
88
  pending_steps = if File.exist?(workflow_path)
89
89
  workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
90
- Array(workflow["steps"]).map { |step| step.fetch("id") }
90
+ # The template is authored in readable sections, so physical
91
+ # YAML position is not a scheduling contract. A step with an
92
+ # explicit order must initialize ahead of later ordered
93
+ # steps even when it was inserted lower in the document.
94
+ Array(workflow["steps"])
95
+ .each_with_index
96
+ .sort_by do |(step, index)|
97
+ order = step["order"]
98
+ [order.is_a?(Integer) ? 0 : 1, order.is_a?(Integer) ? order : index, index]
99
+ end
100
+ .map { |(step, _index)| step.fetch("id") }
91
101
  else
92
102
  fallback_pending_steps
93
103
  end
@@ -148,7 +148,11 @@ end
148
148
 
149
149
  def next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
150
150
  track = active_track(state)
151
- return pending_ids.first unless track
151
+ unless track
152
+ return pending_ids.find do |step_id|
153
+ step_by_id[step_id] && dependencies_satisfied?(step_by_id[step_id], completed_ids)
154
+ end
155
+ end
152
156
 
153
157
  allowed = track_allowed_steps(track)
154
158
  pending_ids.find do |step_id|
@@ -5,6 +5,7 @@
5
5
  # and canonical graph. Agent text is never used to decide completion.
6
6
  require "digest"
7
7
  require "json"
8
+ require "open3"
8
9
  require "optparse"
9
10
  require "yaml"
10
11
  require_relative "control_io"
@@ -37,14 +38,46 @@ def configured_checkers(workflow, step_id)
37
38
  return Array(explicit) if explicit
38
39
  case step_id
39
40
  when "requirement-baseline" then %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check]
40
- when "api-contract" then %w[requirement-graph-check contract-coverage-check change-impact-check]
41
+ when "api-contract" then %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check]
41
42
  when "architecture-feasibility" then %w[requirement-graph-check change-impact-check]
42
- when "integration-verify", "change-plan" then %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check]
43
- when "verify" then %w[acceptance-coverage-check change-impact-check]
43
+ when "integration-verify" then %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check]
44
+ when "change-plan" then %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check]
45
+ when "acceptance-test-design" then %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check]
46
+ when "run-acceptance-tests" then %w[api-test-matrix-check test-coverage-check acceptance-test-results-check change-impact-check]
47
+ when "verify" then %w[acceptance-coverage-check acceptance-test-results-check change-impact-check]
48
+ when "execute" then %w[tdd-evidence-check change-impact-check]
44
49
  else []
45
50
  end
46
51
  end
47
52
 
53
+ def supplemental_checker(root, feature, id, step_id)
54
+ command = case id
55
+ when "test-task-binding-check"
56
+ stage = step_id == "change-plan" ? "candidate" : "frozen"
57
+ [File.join(__dir__, "test_task_binding_check.py"), "--feature", feature, "--stage", stage, "--json"]
58
+ when "tdd-evidence-check"
59
+ [File.join(__dir__, "tdd_evidence.py"), "validate", "--feature", feature, "--json"]
60
+ when "acceptance-test-results-check"
61
+ [File.join(__dir__, "acceptance_test_runner.py"), "validate-results", "--feature", feature, "--json"]
62
+ when "api-test-matrix-check"
63
+ [File.join(__dir__, "api_test_matrix.py"), "validate", "--feature", feature, "--json"]
64
+ when "api-test-schema-check"
65
+ [File.join(__dir__, "api_test_matrix.py"), "check", "--feature", feature, "--json"]
66
+ end
67
+ return nil unless command
68
+
69
+ stdout, stderr, status = Open3.capture3("python3", *command, chdir: root)
70
+ payload = JSON.parse(stdout)
71
+ issues = payload.is_a?(Hash) && payload["errors"].is_a?(Array) ? payload["errors"].map(&:to_s) : ["test-task-binding checker returned invalid JSON"]
72
+ passed = status.success? && payload.is_a?(Hash) && payload["ok"] == true
73
+ { "id" => id, "origin" => "core_fixed_checker", "exit_code" => passed ? 0 : 1, "passed" => passed,
74
+ "issues" => passed ? [] : (issues.empty? ? [stderr.strip.empty? ? "#{id} failed" : stderr.strip] : issues),
75
+ "counters" => { "issue_count" => passed ? 0 : issues.length } }
76
+ rescue JSON::ParserError => error
77
+ { "id" => id, "origin" => "core_fixed_checker", "exit_code" => 1, "passed" => false,
78
+ "issues" => ["#{id} returned invalid JSON: #{error.message}"], "counters" => { "issue_count" => 1 } }
79
+ end
80
+
48
81
  root = Dir.pwd
49
82
  lock = DevflowControlIO.acquire_project_lock(root)
50
83
  begin
@@ -78,6 +111,9 @@ begin
78
111
  gate = workflow.dig("governance", "requirement_graph_gate").to_s
79
112
  enforced = gate == "enforced"
80
113
  checker_results = selected.map do |id|
114
+ supplemental = supplemental_checker(root, options[:feature], id, options[:step])
115
+ next supplemental if supplemental
116
+
81
117
  issues = checkers.fetch(id, ["unknown fixed checker: #{id}"])
82
118
  { "id" => id, "origin" => "core_fixed_checker", "exit_code" => issues.empty? ? 0 : 1, "passed" => issues.empty?, "issues" => issues,
83
119
  "counters" => { "issue_count" => issues.length } }
@@ -13,19 +13,24 @@ require_relative "control_io"
13
13
  require_relative "architecture_constraints"
14
14
 
15
15
  module RequirementGraph
16
- # v3 adds the two narrowly-scoped architecture inputs. Existing v1/v2
16
+ # v4 adds derived TC verification nodes. Existing v1-v3
17
17
  # graphs are migrated in memory and re-written only by this Core writer.
18
- SCHEMA_VERSION = 3
19
- SUPPORTED_SCHEMA_VERSIONS = [1, 2, SCHEMA_VERSION].freeze
18
+ SCHEMA_VERSION = 4
19
+ SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, SCHEMA_VERSION].freeze
20
20
  ROOT = ".planning/requirements"
21
21
  KINDS = {
22
22
  "source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
23
23
  "behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
24
24
  "slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
25
- "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC"
25
+ "api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "test_case" => "TC"
26
26
  }.freeze
27
- VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance traceability].freeze
27
+ VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance tests traceability].freeze
28
28
  FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
29
+ # Delta data never writes a node's top-level id/status/revision: Core builds
30
+ # that wrapper itself. Reserving these words recursively made ordinary API
31
+ # bodies such as {"id": ...} or {"status": ...} impossible to describe;
32
+ # reject only root data keys, while nested contract/request payloads remain
33
+ # factual business data.
29
34
  RESERVED_KEYS = %w[id status revision passed confirmed graph_revision stale_fingerprint severity risk_level priority p0 high_risk].freeze
30
35
 
31
36
  module_function
@@ -85,12 +90,7 @@ module RequirementGraph
85
90
  end
86
91
 
87
92
  def contains_reserved_key?(value)
88
- case value
89
- when Hash
90
- value.any? { |key, child| RESERVED_KEYS.include?(key.to_s) || contains_reserved_key?(child) }
91
- when Array then value.any? { |child| contains_reserved_key?(child) }
92
- else false
93
- end
93
+ value.is_a?(Hash) && value.keys.any? { |key| RESERVED_KEYS.include?(key.to_s) }
94
94
  end
95
95
 
96
96
  def normalize_data(value)
@@ -111,6 +111,24 @@ module RequirementGraph
111
111
  graph["nodes"].map { |node| node["id"] if node.is_a?(Hash) && node["id"].is_a?(String) }.compact.to_set
112
112
  end
113
113
 
114
+ # The API test compiler deliberately fingerprints only the API contracts.
115
+ # Importing its derived TC nodes advances the graph revision, but must not
116
+ # make the compiler believe the source contract itself changed.
117
+ def api_contract_fingerprint(graph)
118
+ source = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "api" }.map do |node|
119
+ { "id" => node["id"], "data" => node["data"] }
120
+ end
121
+ Digest::SHA256.hexdigest(JSON.generate(deep_sort(source)))
122
+ end
123
+
124
+ def deep_sort(value)
125
+ case value
126
+ when Hash then value.keys.sort.to_h { |key| [key, deep_sort(value[key])] }
127
+ when Array then value.map { |item| deep_sort(item) }
128
+ else value
129
+ end
130
+ end
131
+
114
132
  def set_node_fingerprint!(node)
115
133
  node["fingerprint"] = fingerprint(node.reject { |key, _| key == "fingerprint" })
116
134
  end
@@ -158,7 +176,7 @@ module RequirementGraph
158
176
  raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
159
177
  data = normalize_data(delta["data"])
160
178
  node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
161
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
179
+ candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
162
180
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
163
181
  set_node_fingerprint!(node)
164
182
  graph["nodes"] << node
@@ -175,12 +193,12 @@ module RequirementGraph
175
193
  id = delta["id"]
176
194
  node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
177
195
  raise ArgumentError, "update requires a known node id" unless node
178
- if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
179
- raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; add a new candidate and relink consumers"
196
+ if (%w[workload_shape workload_constraint test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
197
+ raise ArgumentError, "confirmed workload nodes and architecture decisions are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
180
198
  end
181
199
  data = normalize_data(delta["data"])
182
200
  node["data"] = node.fetch("data", {}).merge(data)
183
- candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node)
201
+ candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
184
202
  raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
185
203
  set_node_fingerprint!(node)
186
204
  clear_stale_for!(graph, id)
@@ -199,6 +217,7 @@ module RequirementGraph
199
217
  node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == node_id }
200
218
  raise ArgumentError, "confirm requires a known node id" unless node
201
219
  raise ArgumentError, "node is already confirmed" if node["status"] == "confirmed"
220
+ raise ArgumentError, "test cases must be frozen through acceptance_test_suite.rb" if node["kind"] == "test_case"
202
221
  unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
203
222
  raise ArgumentError, "confirm requires cp_record or evidence_ref"
204
223
  end
@@ -252,6 +271,215 @@ module RequirementGraph
252
271
  end
253
272
  end
254
273
 
274
+ TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance].freeze
275
+ TEST_AUTOMATION_MODES = %w[automated manual blocked].freeze
276
+
277
+ # TC nodes are derived verification intent. They deliberately store only
278
+ # externally observable setup, operation and oracle; implementation paths,
279
+ # selectors and agent summaries belong to later execution bindings.
280
+ def test_case_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
281
+ return [] unless node["kind"] == "test_case"
282
+
283
+ data = node["data"].is_a?(Hash) ? node["data"] : {}
284
+ errors = []
285
+ %w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode].each do |field|
286
+ errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
287
+ end
288
+ errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
289
+ errors << "#{node["id"]}: requirement_refs must be a non-empty array" unless data["requirement_refs"].is_a?(Array) && !data["requirement_refs"].empty?
290
+ errors << "#{node["id"]}: preconditions must be a non-empty array" unless data["preconditions"].is_a?(Array) && !data["preconditions"].empty?
291
+ errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
292
+ errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
293
+ errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
294
+ if data.key?("test_obligation_refs")
295
+ errors << "#{node["id"]}: test_obligation_refs must be a non-empty string array when present" unless data["test_obligation_refs"].is_a?(Array) && !data["test_obligation_refs"].empty? && data["test_obligation_refs"].all? { |ref| ref.is_a?(String) && ref.match?(/\AOBL-[A-Z0-9-]+\z/) }
296
+ end
297
+ if data.key?("generated_case_key")
298
+ errors << "#{node["id"]}: generated_case_key is invalid" unless data["generated_case_key"].is_a?(String) && data["generated_case_key"].match?(/\AATC-[A-Z0-9-]+\z/)
299
+ errors << "#{node["id"]}: generated API TC requires test_obligation_refs" unless data["test_obligation_refs"].is_a?(Array) && !data["test_obligation_refs"].empty?
300
+ errors << "#{node["id"]}: generated API TC requires a non-empty api_refs" unless data["api_refs"].is_a?(Array) && !data["api_refs"].empty?
301
+ execution = data["execution"]
302
+ unless execution.is_a?(Hash) && execution["protocol"] == "http" && execution["request"].is_a?(Hash) && execution["expect"].is_a?(Hash) && execution.dig("request", "method").is_a?(String) && execution.dig("request", "path").is_a?(String) && execution.dig("expect", "http_status").is_a?(Integer)
303
+ errors << "#{node["id"]}: generated API TC requires an HTTP execution definition"
304
+ end
305
+ end
306
+ if data["automation_mode"] == "manual"
307
+ %w[manual_reason owner].each { |field| errors << "#{node["id"]}: manual tests require #{field}" unless present?(data[field]) }
308
+ elsif data["automation_mode"] == "blocked"
309
+ %w[blocked_by owner].each { |field| errors << "#{node["id"]}: blocked tests require #{field}" unless present?(data[field]) }
310
+ end
311
+
312
+ Array(data["acceptance_refs"]).each do |ref|
313
+ errors << "#{node["id"]}: acceptance_ref #{ref} must reference AC" unless refs_known?(graph, [ref], %w[AC])
314
+ errors << "#{node["id"]}: acceptance_ref #{ref} must link with verified_by" if require_edges && !incoming_edge?(graph, ref, node["id"], "verified_by")
315
+ errors << "#{node["id"]}: acceptance_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
316
+ end
317
+ Array(data["requirement_refs"]).each do |ref|
318
+ errors << "#{node["id"]}: requirement_ref #{ref} must reference REQ/BR/BC" unless refs_known?(graph, [ref], %w[REQ BR BC])
319
+ errors << "#{node["id"]}: requirement_ref #{ref} must link with verified_by" if require_edges && !incoming_edge?(graph, ref, node["id"], "verified_by")
320
+ errors << "#{node["id"]}: requirement_ref #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
321
+ end
322
+ {
323
+ "api_refs" => %w[API],
324
+ "workload_refs" => %w[WSH WLC],
325
+ "risk_refs" => %w[RISK]
326
+ }.each do |field, prefixes|
327
+ next unless data.key?(field)
328
+
329
+ errors << "#{node["id"]}: #{field} must be a non-empty array when present" unless data[field].is_a?(Array) && !data[field].empty?
330
+ Array(data[field]).each do |ref|
331
+ label = field.sub(/s\z/, "")
332
+ errors << "#{node["id"]}: #{label} #{ref} references an invalid graph node" unless refs_known?(graph, [ref], prefixes)
333
+ errors << "#{node["id"]}: #{label} #{ref} must link with verified_by" if require_edges && !incoming_edge?(graph, ref, node["id"], "verified_by")
334
+ errors << "#{node["id"]}: #{label} #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
335
+ end
336
+ end
337
+ errors.uniq
338
+ end
339
+
340
+ def frozen_test_case?(graph, node)
341
+ node["kind"] == "test_case" && node["status"] == "confirmed" && !Array(graph["stale"]).any? { |entry| entry.is_a?(Hash) && entry["node_id"] == node["id"] }
342
+ end
343
+
344
+ # CP-3 is the single freeze point. Keep it here instead of in a Skill so the
345
+ # graph revision, TC statuses, views and audit entry commit atomically under
346
+ # the caller's control-plane lock.
347
+ def freeze_test_cases!(root, graph, cp_record)
348
+ cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
349
+ return { "ok" => false, "reason" => "test_suite_empty", "message" => "cannot freeze an empty TC suite" } if cases.empty?
350
+ return { "ok" => false, "reason" => "test_suite_already_frozen", "message" => "confirmed test cases require a new candidate suite" } if cases.any? { |node| node["status"] == "confirmed" }
351
+ return { "ok" => false, "reason" => "test_suite_graph_stale", "message" => "cannot freeze TC suite while the requirement graph has stale downstream nodes" } if Array(graph["stale"]).any?
352
+
353
+ checkers = checker_results(graph)
354
+ errors = checkers.fetch("test-coverage-check", []).dup
355
+ errors.concat(checkers.fetch("test-obligation-check", []))
356
+ cases.each { |node| errors.concat(test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)) }
357
+ return { "ok" => false, "reason" => "test_suite_incomplete", "message" => errors.uniq.join("; "), "errors" => errors.uniq } if errors.any?
358
+
359
+ cases.each do |node|
360
+ node["status"] = "confirmed"
361
+ node["confirmation"] = { "origin" => "core_acceptance_test_suite", "cp_record" => cp_record }
362
+ set_node_fingerprint!(node)
363
+ clear_stale_for!(graph, node["id"])
364
+ end
365
+ graph["revision"] += 1
366
+ write_views!(root, graph, stale: false)
367
+ path, = graph_path(root, graph["feature"])
368
+ append_audit!(root, graph, { "event" => "test_suite_freeze", "node_ids" => cases.map { |node| node["id"] }, "cp_record" => cp_record })
369
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
370
+ { "ok" => true, "path" => path.sub("#{File.realpath(root)}/", ""), "graph_revision" => graph["revision"],
371
+ "frozen_case_ids" => cases.map { |node| node["id"] }, "tests_view" => graph.dig("views", "tests") }
372
+ end
373
+
374
+ # The matrix compiler is Core-owned and deterministic; this importer is the
375
+ # only path which may turn its complete generated case list into graph TC
376
+ # candidates. Keeping the batch write here prevents a Skill from omitting
377
+ # the 37th boundary case while still preserving the normal graph audit and
378
+ # ID allocation rules.
379
+ def import_api_test_matrix!(root, graph)
380
+ matrix_path = File.join(feature_dir(root, graph["feature"]), "api-test-matrix.json")
381
+ raise ArgumentError, "API test matrix is missing; compile it before import" unless File.file?(matrix_path)
382
+ matrix = JSON.parse(File.read(matrix_path))
383
+ raise ArgumentError, "API test matrix identity is invalid" unless matrix["schema_version"] == 1 && matrix["origin"] == "core_api_test_matrix" && matrix["feature"] == graph["feature"]
384
+ expected_source = { "api_contract_fingerprint" => api_contract_fingerprint(graph), "api_ids" => Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "api" }.map { |node| node["id"] } }
385
+ unless matrix["source"] == expected_source
386
+ raise ArgumentError, "API test matrix is stale for the current API contract (expected #{expected_source["api_contract_fingerprint"]}, got #{matrix.dig("source", "api_contract_fingerprint")})"
387
+ end
388
+ raise ArgumentError, "API test matrix contains unresolved contract gaps" unless Array(matrix["errors"]).empty?
389
+
390
+ generated_cases = matrix["cases"]
391
+ raise ArgumentError, "API test matrix cases must be an array" unless generated_cases.is_a?(Array)
392
+ existing = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" && node.dig("data", "generated_case_key").is_a?(String) }.to_h { |node| [node.dig("data", "generated_case_key"), node] }
393
+ keys = generated_cases.map { |item| item.is_a?(Hash) ? item["key"] : nil }
394
+ raise ArgumentError, "API test matrix case keys must be unique non-empty strings" unless keys.all? { |key| key.is_a?(String) && key.match?(/\AATC-[A-Z0-9-]+\z/) } && keys.uniq.length == keys.length
395
+
396
+ imported = []
397
+ generated_cases.each do |item|
398
+ candidate_data = item["test_case"]
399
+ raise ArgumentError, "#{item["key"]}: test_case must be an object" unless candidate_data.is_a?(Hash)
400
+ raise ArgumentError, "#{item["key"]}: test_case generated_case_key mismatch" unless candidate_data["generated_case_key"] == item["key"]
401
+ raise ArgumentError, "#{item["key"]}: test_case obligation refs mismatch" unless candidate_data["test_obligation_refs"] == item["obligation_refs"]
402
+ node = existing[item["key"]]
403
+ if node
404
+ comparable = node.dig("data", "execution") == candidate_data["execution"] && node.dig("data", "test_obligation_refs") == candidate_data["test_obligation_refs"]
405
+ raise ArgumentError, "#{item["key"]}: existing generated TC does not match matrix" unless comparable
406
+ else
407
+ applied = proposal(graph, { "op" => "add", "kind" => "test_case", "data" => candidate_data }, ["test_case"])
408
+ node = applied.fetch("node")
409
+ refs = Array(candidate_data["acceptance_refs"]) + Array(candidate_data["requirement_refs"]) + Array(candidate_data["api_refs"])
410
+ refs.each do |ref|
411
+ edge = { "from" => ref, "to" => node["id"], "relation" => "verified_by" }
412
+ graph["edges"] << edge unless graph["edges"].include?(edge)
413
+ end
414
+ errors = test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
415
+ raise ArgumentError, errors.join("; ") if errors.any?
416
+ existing[item["key"]] = node
417
+ imported << node["id"]
418
+ end
419
+ item["tc_id"] = node["id"]
420
+ end
421
+
422
+ if imported.any?
423
+ graph["revision"] += 1
424
+ stale = Array(graph["stale"]).any?
425
+ write_views!(root, graph, stale: stale)
426
+ append_audit!(root, graph, { "event" => "api_test_matrix_import", "node_ids" => imported, "case_set_fingerprint" => matrix["case_set_fingerprint"] })
427
+ path, = graph_path(root, graph["feature"])
428
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
429
+ end
430
+ matrix["imported_graph_revision"] = graph["revision"]
431
+ DevflowControlIO.atomic_write(matrix_path, JSON.pretty_generate(matrix) + "\n")
432
+ template_path = File.join(root, ".planning", "features", graph["feature"], "api-acceptance-bindings.template.json")
433
+ template = {
434
+ "schema_version" => 1, "origin" => "core_api_test_matrix_template", "feature" => graph["feature"],
435
+ "case_set_fingerprint" => matrix["case_set_fingerprint"],
436
+ "cases" => generated_cases.map do |item|
437
+ { "tc_id" => item["tc_id"], "mode" => "automated", "adapter" => { "kind" => "http", "base_url_env" => "DEVFLOW_TEST_BASE_URL", "headers_env" => { "Authorization" => "DEVFLOW_TEST_AUTHORIZATION" } } }
438
+ end
439
+ }
440
+ DevflowControlIO.atomic_write(template_path, JSON.pretty_generate(template) + "\n")
441
+ { "ok" => true, "imported_case_ids" => imported, "test_case_ids" => generated_cases.map { |item| item["tc_id"] }, "graph_revision" => graph["revision"], "case_set_fingerprint" => matrix["case_set_fingerprint"] }
442
+ end
443
+
444
+ # CP-API, not the API-contract Skill, turns a complete candidate contract
445
+ # into confirmed graph fact. Downstream TC imports therefore never need to
446
+ # trust an agent-written `cp_record` as proof that the shared contract was
447
+ # reviewed by the user.
448
+ def confirm_api_contracts!(root, graph, cp_record)
449
+ apis = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "api" }
450
+ return { "ok" => true, "confirmed_api_ids" => [], "graph_revision" => graph["revision"], "skipped" => "no_api_nodes" } if apis.empty?
451
+
452
+ errors = checker_results(graph).fetch("contract-coverage-check", []).dup
453
+ apis.each do |node|
454
+ Array(node.dig("data", "requirement_refs")).each do |ref|
455
+ errors << "#{node["id"]}: requirement_ref #{ref} must be confirmed before CP-API" unless confirmed?(graph, ref)
456
+ end
457
+ behavior_ref = node.dig("data", "behavior_ref")
458
+ errors << "#{node["id"]}: behavior_ref #{behavior_ref} must be confirmed before CP-API" unless confirmed?(graph, behavior_ref)
459
+ end
460
+ return { "ok" => false, "reason" => "api_contract_incomplete", "errors" => errors.uniq } if errors.any?
461
+
462
+ confirmed = []
463
+ apis.each do |node|
464
+ next if node["status"] == "confirmed"
465
+
466
+ node["status"] = "confirmed"
467
+ node["confirmation"] = { "origin" => "core_api_contract_checkpoint", "cp_record" => cp_record }
468
+ set_node_fingerprint!(node)
469
+ clear_stale_for!(graph, node["id"])
470
+ confirmed << node["id"]
471
+ end
472
+ if confirmed.any?
473
+ graph["revision"] += 1
474
+ stale = Array(graph["stale"]).any?
475
+ write_views!(root, graph, stale: stale)
476
+ path, = graph_path(root, graph["feature"])
477
+ append_audit!(root, graph, { "event" => "api_contract_confirm", "node_ids" => confirmed, "cp_record" => cp_record })
478
+ DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
479
+ end
480
+ { "ok" => true, "confirmed_api_ids" => confirmed, "graph_revision" => graph["revision"] }
481
+ end
482
+
255
483
  # WSH/WLC are graph-owned facts, not a second unbounded document schema.
256
484
  # The candidate validator owns field rules; this layer adds only the facts
257
485
  # it can prove: graph references, traceability edges, and confirmation state.
@@ -400,7 +628,7 @@ module RequirementGraph
400
628
  def checker_results(graph)
401
629
  results = {
402
630
  "requirement-graph-check" => [], "slice-independence-check" => [], "contract-coverage-check" => [],
403
- "change-impact-check" => [], "acceptance-coverage-check" => [], "risk-derivation-check" => []
631
+ "change-impact-check" => [], "acceptance-coverage-check" => [], "test-coverage-check" => [], "test-obligation-check" => [], "risk-derivation-check" => []
404
632
  }
405
633
  Array(graph["nodes"]).each do |node|
406
634
  next unless node.is_a?(Hash)
@@ -446,6 +674,45 @@ module RequirementGraph
446
674
  missing = %w[business contract resilience] - types
447
675
  results["acceptance-coverage-check"] << "#{node["id"]}: high-risk acceptance missing #{missing.join(", ")}" if missing.any?
448
676
  end
677
+ when "test_case"
678
+ results["test-coverage-check"].concat(
679
+ test_case_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
680
+ )
681
+ end
682
+ end
683
+ Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "acceptance" }.each do |acceptance|
684
+ covered = Array(graph["nodes"]).any? do |candidate|
685
+ candidate.is_a?(Hash) && candidate["kind"] == "test_case" && Array(candidate.dig("data", "acceptance_refs")).include?(acceptance["id"])
686
+ end
687
+ results["test-coverage-check"] << "#{acceptance["id"]}: missing TC coverage" unless covered
688
+ end
689
+ test_cases = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "test_case" }
690
+ covered_by = lambda do |test_type, field, ref|
691
+ test_cases.any? do |test_case|
692
+ test_case.dig("data", "test_type") == test_type && Array(test_case.dig("data", field)).include?(ref)
693
+ end
694
+ end
695
+ Array(graph["nodes"]).each do |node|
696
+ next unless node.is_a?(Hash)
697
+
698
+ case node["kind"]
699
+ when "api"
700
+ results["test-obligation-check"] << "#{node["id"]}: API change requires an api TC with api_refs" unless covered_by.call("api", "api_refs", node["id"])
701
+ when "boundary"
702
+ results["test-obligation-check"] << "#{node["id"]}: boundary requires a boundary TC with requirement_refs" unless covered_by.call("boundary", "requirement_refs", node["id"])
703
+ when "workload_shape", "workload_constraint"
704
+ results["test-obligation-check"] << "#{node["id"]}: workload constraint requires a performance TC with workload_refs" unless covered_by.call("performance", "workload_refs", node["id"])
705
+ if node["kind"] == "workload_shape" && node.dig("data", "facts", "data_effect") == "migration"
706
+ results["test-obligation-check"] << "#{node["id"]}: migration workload requires a migration TC with workload_refs" unless covered_by.call("migration", "workload_refs", node["id"])
707
+ end
708
+ when "risk"
709
+ if architecture_option_risk?(node)
710
+ results["test-obligation-check"] << "#{node["id"]}: architecture risk requires a resilience TC with risk_refs" unless covered_by.call("resilience", "risk_refs", node["id"])
711
+ end
712
+ when "requirement"
713
+ if high_risk_by_structure?(node)
714
+ results["test-obligation-check"] << "#{node["id"]}: cross-domain requirement requires a permission TC with requirement_refs" unless covered_by.call("permission", "requirement_refs", node["id"])
715
+ end
449
716
  end
450
717
  end
451
718
  stale = Array(graph["stale"])
@@ -477,7 +744,10 @@ module RequirementGraph
477
744
  Array(graph["edges"]).each do |edge|
478
745
  errors << "invalid edge" unless edge.is_a?(Hash) && known.include?(edge["from"]) && known.include?(edge["to"]) && edge["relation"].is_a?(String)
479
746
  end
480
- checker_results(graph).each_value { |issues| errors.concat(issues) }
747
+ # Test coverage becomes enforced only at acceptance-test-design and later
748
+ # workflow steps. Baseline construction itself must remain valid before
749
+ # any derived TC candidates exist.
750
+ checker_results(graph).reject { |id, _| %w[test-coverage-check test-obligation-check].include?(id) }.each_value { |issues| errors.concat(issues) }
481
751
  Array(graph["nodes"]).each do |node|
482
752
  next unless node.is_a?(Hash) && high_risk_by_structure?(node)
483
753
  errors << "#{node["id"]}: derived high-risk node must be confirmed and provable" unless node["status"] == "confirmed"
@@ -490,14 +760,21 @@ module RequirementGraph
490
760
  when "evidence" then %w[source code]
491
761
  when "definitions" then %w[term object rule boundary]
492
762
  when "behavior-graph" then %w[behavior]
493
- when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint]
763
+ when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint test_case]
494
764
  when "slices" then %w[slice]
495
765
  when "acceptance" then %w[acceptance risk decision]
766
+ when "tests" then %w[test_case]
496
767
  else []
497
768
  end
498
769
  nodes = Array(graph["nodes"]).select { |node| kinds.include?(node["kind"]) }
499
- { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
500
- "view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
770
+ payload = { "schema_version" => SCHEMA_VERSION, "origin" => "core_requirement_graph", "feature" => graph["feature"], "graph_revision" => graph["revision"],
771
+ "view" => name, "nodes" => nodes, "edges" => Array(graph["edges"]).select { |edge| nodes.any? { |node| node["id"] == edge["from"] || node["id"] == edge["to"] } } }
772
+ if name == "tests"
773
+ test_cases = nodes.select { |node| node["kind"] == "test_case" }
774
+ payload["frozen"] = !test_cases.empty? && test_cases.all? { |node| frozen_test_case?(graph, node) }
775
+ payload["frozen_graph_revision"] = graph["revision"] if payload["frozen"]
776
+ end
777
+ payload
501
778
  end
502
779
 
503
780
  def write_views!(root, graph, stale: false)
@@ -559,7 +836,7 @@ if __FILE__ == $PROGRAM_NAME
559
836
  options = { feature: nil, expected_revision: nil, delta_json: nil, slice_id: nil, node_id: nil, evidence_json: nil, allowed_kinds: nil, confirmed_interactively: false, write: false, read: false, json: false }
560
837
  command = ARGV.shift
561
838
  OptionParser.new do |opts|
562
- opts.banner = "Usage: requirement_graph.rb propose|inspect|validate|context --feature NAME [options]"
839
+ opts.banner = "Usage: requirement_graph.rb propose|import-test-cases|inspect|validate|context --feature NAME [options]"
563
840
  opts.on("--feature NAME") { |value| options[:feature] = value }
564
841
  opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
565
842
  opts.on("--delta-json JSON") { |value| options[:delta_json] = value }
@@ -573,7 +850,7 @@ if __FILE__ == $PROGRAM_NAME
573
850
  opts.on("--json") { options[:json] = true }
574
851
  end.parse!
575
852
  root = Dir.pwd
576
- lock = %w[propose context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
853
+ lock = %w[propose import-test-cases context confirm].include?(command) && options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
577
854
  begin
578
855
  graph, path, relative = RequirementGraph.load_graph(root, options[:feature])
579
856
  result = case command
@@ -617,6 +894,15 @@ if __FILE__ == $PROGRAM_NAME
617
894
  DevflowControlIO.atomic_write(path, JSON.pretty_generate(graph) + "\n")
618
895
  { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
619
896
  end
897
+ when "import-test-cases"
898
+ raise ArgumentError, "--write is required" unless options[:write]
899
+ raise ArgumentError, "--expected-revision is required" if options[:expected_revision].nil?
900
+ if graph["revision"] != options[:expected_revision]
901
+ { "ok" => false, "reason" => "stale_graph_revision", "expected_revision" => options[:expected_revision], "actual_revision" => graph["revision"] }
902
+ else
903
+ imported = RequirementGraph.import_api_test_matrix!(root, graph)
904
+ { "ok" => true, "path" => relative, "revision" => graph["revision"], "imported" => imported, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
905
+ end
620
906
  when "confirm"
621
907
  raise ArgumentError, "--write is required" unless options[:write]
622
908
  raise ArgumentError, "--node-id is required" unless options[:node_id]
@@ -635,7 +921,7 @@ if __FILE__ == $PROGRAM_NAME
635
921
  { "ok" => true, "path" => relative, "revision" => graph["revision"], "applied" => applied, "validation_errors" => RequirementGraph.validate(graph), "checkers" => RequirementGraph.checker_results(graph), "views" => graph["views"] }
636
922
  end
637
923
  else
638
- { "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, inspect, validate, context, or confirm" }
924
+ { "ok" => false, "reason" => "invalid_command", "message" => "command must be propose, import-test-cases, inspect, validate, context, or confirm" }
639
925
  end
640
926
  rescue ArgumentError, JSON::ParserError => error
641
927
  result = { "ok" => false, "reason" => "invalid_requirement_graph_input", "message" => error.message }