@guanlin4924/devflow-pi 0.1.13 → 0.1.15
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 +119 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +4 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +47 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +42 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +21 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/phase_ownership.rb +154 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +35 -26
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +18 -7
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/traceability_check.py +160 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +60 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +25 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/traceability.json +23 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -1
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +10 -0
- package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +8 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +9 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The control plane has one STATE.md, so phases must be claimed serially. This
|
|
5
|
+
# registry deliberately lives beside STATE.md instead of inside a phase folder:
|
|
6
|
+
# it remains visible even when a caller has selected the wrong phase directory.
|
|
7
|
+
require "date"
|
|
8
|
+
require "json"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "yaml"
|
|
11
|
+
require_relative "control_io"
|
|
12
|
+
|
|
13
|
+
module PhaseOwnership
|
|
14
|
+
REGISTRY_PATH = ".planning/phase-ownership.yaml"
|
|
15
|
+
PHASE_SLUG = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def valid_slug?(value)
|
|
19
|
+
value.is_a?(String) && value.match?(PHASE_SLUG)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Feature labels are user-facing names and may be Unicode. They are never
|
|
23
|
+
# used as a registry filename here, but reject separators/control characters
|
|
24
|
+
# so a later caller cannot reinterpret them as a path.
|
|
25
|
+
def valid_feature?(value)
|
|
26
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(/[\\\/\x00-\x1F\x7F]/)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def registry_path(root)
|
|
30
|
+
File.join(root, REGISTRY_PATH)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load_registry(root)
|
|
34
|
+
path = registry_path(root)
|
|
35
|
+
return [nil, nil] unless File.exist?(path)
|
|
36
|
+
|
|
37
|
+
registry = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
38
|
+
return [nil, "invalid_phase_ownership_registry"] unless registry.is_a?(Hash) && registry["schema_version"] == 1 && registry["owners"].is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
[registry, nil]
|
|
41
|
+
rescue Psych::Exception
|
|
42
|
+
[nil, "invalid_phase_ownership_registry"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_owner?(owner, phase: nil)
|
|
46
|
+
owner.is_a?(Hash) && valid_slug?(owner["phase"]) && (phase.nil? || owner["phase"] == phase) &&
|
|
47
|
+
valid_feature?(owner["feature"]) && owner["flow"].is_a?(String) && !owner["flow"].empty? &&
|
|
48
|
+
owner["state_path"] == ".planning/STATE.md" && %w[active completed].include?(owner["status"])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def nonempty_phase_dirs(root)
|
|
52
|
+
Dir.glob(File.join(root, ".planning", "phases", "*")).select do |path|
|
|
53
|
+
File.directory?(path) && !Dir.children(path).empty?
|
|
54
|
+
end.map { |path| File.basename(path) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def claim(root:, feature:, phase:, flow:, write:)
|
|
58
|
+
return failure("invalid_phase", "phase must be a safe non-empty slug") unless valid_slug?(phase)
|
|
59
|
+
return failure("invalid_feature", "feature must be non-empty and cannot contain path separators or control characters") unless valid_feature?(feature)
|
|
60
|
+
return failure("invalid_flow", "flow is required") unless flow.is_a?(String) && !flow.empty?
|
|
61
|
+
|
|
62
|
+
registry, error = load_registry(root)
|
|
63
|
+
return failure(error, "phase ownership registry is malformed") if error
|
|
64
|
+
registry ||= { "schema_version" => 1, "owners" => {} }
|
|
65
|
+
owners = registry["owners"]
|
|
66
|
+
|
|
67
|
+
nonempty_phase_dirs(root).each do |phase_dir|
|
|
68
|
+
owner = owners[phase_dir]
|
|
69
|
+
next if valid_owner?(owner, phase: phase_dir)
|
|
70
|
+
|
|
71
|
+
return failure("legacy_phase_directory_without_owner", "non-empty phase directory has no valid ownership record", phase: phase_dir)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
owners.each do |owner_phase, owner|
|
|
75
|
+
return failure("invalid_phase_owner", "phase ownership record is invalid", phase: owner_phase) unless valid_owner?(owner, phase: owner_phase)
|
|
76
|
+
next unless owner["status"] == "active"
|
|
77
|
+
next if owner["phase"] == phase && owner["feature"] == feature
|
|
78
|
+
|
|
79
|
+
return failure("active_phase_owned_by_another_feature", "an active phase already owns the global state", owner: owner)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
current = owners[phase]
|
|
83
|
+
if current && current["feature"] != feature
|
|
84
|
+
return failure("phase_owned_by_another_feature", "requested phase is already owned by another feature", owner: current)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
owner = current || { "phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active" }
|
|
88
|
+
owner = owner.merge("phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active")
|
|
89
|
+
registry["owners"][phase] = owner
|
|
90
|
+
registry["updated_at"] = Date.today.to_s
|
|
91
|
+
DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
|
|
92
|
+
{ "ok" => true, "written" => write, "action" => current ? "idempotent_claim" : "claim", "owner" => owner, "path" => REGISTRY_PATH }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def complete(root:, feature:, phase:, flow:, write:)
|
|
96
|
+
registry, error = load_registry(root)
|
|
97
|
+
return failure(error || "missing_phase_owner", "phase ownership record is required") unless registry
|
|
98
|
+
owner = registry["owners"][phase]
|
|
99
|
+
unless valid_owner?(owner, phase: phase) && owner["feature"] == feature && owner["flow"] == flow
|
|
100
|
+
return failure("phase_owner_mismatch", "cannot complete a phase owned by another feature")
|
|
101
|
+
end
|
|
102
|
+
owner["status"] = "completed"
|
|
103
|
+
registry["updated_at"] = Date.today.to_s
|
|
104
|
+
DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
|
|
105
|
+
{ "ok" => true, "written" => write, "action" => "complete", "owner" => owner, "path" => REGISTRY_PATH }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_state(root:, state:, owner_override: nil)
|
|
109
|
+
registry, error = load_registry(root)
|
|
110
|
+
return failure(error || "missing_phase_owner", "STATE.md has no phase ownership record") unless registry
|
|
111
|
+
phase = state["current_phase"]
|
|
112
|
+
feature = state["current_feature"]
|
|
113
|
+
flow = state["current_flow"]
|
|
114
|
+
return failure("invalid_phase", "STATE.md current_phase must be a safe non-empty slug") unless valid_slug?(phase)
|
|
115
|
+
owner = owner_override || registry["owners"][phase]
|
|
116
|
+
return failure("missing_phase_owner", "STATE.md phase has no ownership record", phase: phase) unless valid_owner?(owner, phase: phase)
|
|
117
|
+
unless owner["feature"] == feature && owner["flow"] == flow && owner["state_path"] == ".planning/STATE.md"
|
|
118
|
+
return failure("phase_owner_mismatch", "STATE.md does not match phase ownership record", owner: owner)
|
|
119
|
+
end
|
|
120
|
+
conflicting = registry["owners"].values.find { |entry| valid_owner?(entry) && entry["status"] == "active" && entry["phase"] != phase }
|
|
121
|
+
return failure("active_phase_ownership_conflict", "another active phase owns the global state", owner: conflicting) if conflicting
|
|
122
|
+
{ "ok" => true, "owner" => owner, "path" => REGISTRY_PATH }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def failure(reason, message, extra = {})
|
|
126
|
+
{ "ok" => false, "reason" => reason, "message" => message }.merge(extra)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if __FILE__ == $PROGRAM_NAME
|
|
131
|
+
options = { feature: nil, phase: nil, flow: "feature-development", write: false, json: false }
|
|
132
|
+
command = ARGV.shift
|
|
133
|
+
OptionParser.new do |opts|
|
|
134
|
+
opts.banner = "Usage: phase_ownership.rb claim|inspect|complete --feature NAME --phase ID --flow ID [--write] [--json]"
|
|
135
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
136
|
+
opts.on("--phase ID") { |value| options[:phase] = value }
|
|
137
|
+
opts.on("--flow ID") { |value| options[:flow] = value }
|
|
138
|
+
opts.on("--write") { options[:write] = true }
|
|
139
|
+
opts.on("--json") { options[:json] = true }
|
|
140
|
+
end.parse!
|
|
141
|
+
root = Dir.pwd
|
|
142
|
+
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
143
|
+
begin
|
|
144
|
+
result = case command
|
|
145
|
+
when "claim" then PhaseOwnership.claim(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
|
|
146
|
+
when "complete" then PhaseOwnership.complete(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
|
|
147
|
+
when "inspect" then PhaseOwnership.validate_state(root: root, state: { "current_feature" => options[:feature], "current_phase" => options[:phase], "current_flow" => options[:flow] })
|
|
148
|
+
else PhaseOwnership.failure("invalid_command", "command must be claim, inspect, or complete")
|
|
149
|
+
end
|
|
150
|
+
ensure
|
|
151
|
+
DevflowControlIO.release_lock(lock)
|
|
152
|
+
end
|
|
153
|
+
puts JSON.pretty_generate(result)
|
|
154
|
+
end
|
|
@@ -7,6 +7,7 @@ require "optparse"
|
|
|
7
7
|
require "yaml"
|
|
8
8
|
require_relative "control_io"
|
|
9
9
|
require_relative "validate_state"
|
|
10
|
+
require_relative "phase_ownership"
|
|
10
11
|
|
|
11
12
|
options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
|
|
12
13
|
OptionParser.new do |opts|
|
|
@@ -31,7 +32,7 @@ def build_registry(root, workflow, state)
|
|
|
31
32
|
artifacts = Array(workflow["steps"]).flat_map do |step|
|
|
32
33
|
Array(step["outputs"]).map do |output|
|
|
33
34
|
path = interpolate_path(output.fetch("path"), state)
|
|
34
|
-
abs =
|
|
35
|
+
abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
35
36
|
exists = File.exist?(abs)
|
|
36
37
|
issues = []
|
|
37
38
|
issues << "file_missing" unless exists
|
|
@@ -53,43 +54,51 @@ def build_registry(root, workflow, state)
|
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
root = Dir.pwd
|
|
56
|
-
state_path = File.expand_path(options[:state], root)
|
|
57
57
|
result = nil
|
|
58
58
|
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
59
59
|
begin
|
|
60
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
60
61
|
unless File.exist?(state_path)
|
|
61
62
|
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
|
|
62
63
|
else
|
|
63
64
|
state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if !registry_source.is_a?(String) || registry_source.empty?
|
|
68
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
65
|
+
ownership = PhaseOwnership.validate_state(root: root, state: state)
|
|
66
|
+
unless ownership["ok"]
|
|
67
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => ownership["reason"], "issues" => [ownership], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
69
68
|
else
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path = interpolate_path(output.fetch("path"), state)
|
|
76
|
-
path unless File.exist?(File.join(root, path))
|
|
77
|
-
end.compact
|
|
78
|
-
if missing_current_outputs.any?
|
|
79
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
80
|
-
elsif !validation["ok"]
|
|
81
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
82
|
-
elsif !options[:confirm] || !options[:write]
|
|
83
|
-
result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
|
|
84
|
-
elsif options[:expected_revision] != state.fetch("revision", 0)
|
|
85
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
|
|
69
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
70
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
71
|
+
registry_source = workflow.dig("artifact_registry", "source")
|
|
72
|
+
if !registry_source.is_a?(String) || registry_source.empty?
|
|
73
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
86
74
|
else
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
75
|
+
candidate = build_registry(root, workflow, state)
|
|
76
|
+
validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
|
|
77
|
+
current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
|
|
78
|
+
missing_current_outputs = Array(current && current["outputs"]).map do |output|
|
|
79
|
+
next unless output["required"]
|
|
80
|
+
path = interpolate_path(output.fetch("path"), state)
|
|
81
|
+
output_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
82
|
+
path unless File.exist?(output_path)
|
|
83
|
+
end.compact
|
|
84
|
+
if missing_current_outputs.any?
|
|
85
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
86
|
+
elsif !validation["ok"]
|
|
87
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
88
|
+
elsif !options[:confirm] || !options[:write]
|
|
89
|
+
result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
|
|
90
|
+
elsif options[:expected_revision] != state.fetch("revision", 0)
|
|
91
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
|
|
92
|
+
else
|
|
93
|
+
registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
|
|
94
|
+
DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
|
|
95
|
+
result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
|
|
96
|
+
end
|
|
90
97
|
end
|
|
91
98
|
end
|
|
92
99
|
end
|
|
100
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
101
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "invalid_control_path", "message" => error.message, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
93
102
|
ensure
|
|
94
103
|
DevflowControlIO.release_lock(lock)
|
|
95
104
|
end
|
|
@@ -7,6 +7,7 @@ require "json"
|
|
|
7
7
|
require "optparse"
|
|
8
8
|
require "yaml"
|
|
9
9
|
require_relative "control_io"
|
|
10
|
+
require_relative "phase_ownership"
|
|
10
11
|
|
|
11
12
|
options = {
|
|
12
13
|
state: ".planning/STATE.md",
|
|
@@ -63,11 +64,18 @@ end
|
|
|
63
64
|
project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
|
|
64
65
|
|
|
65
66
|
begin
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
68
|
+
state = load_state(state_path)
|
|
69
|
+
ownership = PhaseOwnership.validate_state(root: root, state: state)
|
|
70
|
+
unless ownership["ok"]
|
|
71
|
+
puts JSON.pretty_generate(ownership.merge("written" => false))
|
|
72
|
+
exit 0
|
|
73
|
+
end
|
|
74
|
+
workflow_source = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
|
|
75
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, workflow_source, :workflow)
|
|
76
|
+
workflow = load_yaml_file(workflow_path)
|
|
77
|
+
registry_source = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
|
|
78
|
+
existing_registry_path, registry_path = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
|
|
71
79
|
existing_by_path = {}
|
|
72
80
|
if options[:preserve] && File.exist?(existing_registry_path)
|
|
73
81
|
existing = load_yaml_file(existing_registry_path)
|
|
@@ -80,7 +88,7 @@ content_checks = workflow.dig("artifact_registry", "content_checks") || {}
|
|
|
80
88
|
preservable_statuses = %w[approved completed passed pending_rerun stale failed]
|
|
81
89
|
|
|
82
90
|
def content_validity(root, path, checks)
|
|
83
|
-
abs =
|
|
91
|
+
abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
84
92
|
return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
|
|
85
93
|
|
|
86
94
|
issues = []
|
|
@@ -114,7 +122,8 @@ artifacts = collect_outputs(workflow).map do |step, output|
|
|
|
114
122
|
end
|
|
115
123
|
|
|
116
124
|
validity = content_validity(root, path, content_checks)
|
|
117
|
-
|
|
125
|
+
artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
126
|
+
file_exists = File.exist?(artifact_path)
|
|
118
127
|
status = if options[:preserve] && preservable_statuses.include?(existing["status"])
|
|
119
128
|
existing["status"]
|
|
120
129
|
else
|
|
@@ -161,6 +170,8 @@ result = {
|
|
|
161
170
|
"missing_count" => artifacts.count { |artifact| !artifact["exists"] },
|
|
162
171
|
"registry" => registry
|
|
163
172
|
}
|
|
173
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
174
|
+
result = { "ok" => false, "written" => false, "reason" => "invalid_control_path", "message" => error.message }
|
|
164
175
|
ensure
|
|
165
176
|
DevflowControlIO.release_lock(project_lock)
|
|
166
177
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed requirement-to-delivery traceability validation."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
STAGES = ("baseline", "design", "change-plan", "verify")
|
|
11
|
+
DIMENSIONS = ("input", "output", "behavior", "orchestration", "failure", "verification")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fail(errors: list[str], message: str) -> None:
|
|
15
|
+
errors.append(message)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def references(items: object) -> set[str]:
|
|
19
|
+
if not isinstance(items, list):
|
|
20
|
+
return set()
|
|
21
|
+
result = set()
|
|
22
|
+
for item in items:
|
|
23
|
+
if isinstance(item, str):
|
|
24
|
+
result.add(item)
|
|
25
|
+
elif isinstance(item, dict) and isinstance(item.get("id"), str):
|
|
26
|
+
result.add(item["id"])
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check(data: dict, stage: str) -> list[str]:
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
requirements = data.get("requirements")
|
|
33
|
+
if data.get("schema_version") != 1:
|
|
34
|
+
fail(errors, "schema_version must be 1")
|
|
35
|
+
if not isinstance(data.get("feature"), str) or not data["feature"].strip():
|
|
36
|
+
fail(errors, "feature is required")
|
|
37
|
+
if not isinstance(requirements, list) or not requirements:
|
|
38
|
+
return errors + ["requirements must be a non-empty array"]
|
|
39
|
+
|
|
40
|
+
active: set[str] = set()
|
|
41
|
+
known: set[str] = set()
|
|
42
|
+
for req in requirements:
|
|
43
|
+
if not isinstance(req, dict):
|
|
44
|
+
fail(errors, "every requirement must be an object")
|
|
45
|
+
continue
|
|
46
|
+
rid = req.get("id")
|
|
47
|
+
if not isinstance(rid, str) or not rid.startswith("REQ-"):
|
|
48
|
+
fail(errors, f"invalid requirement id: {rid!r}")
|
|
49
|
+
continue
|
|
50
|
+
known.add(rid)
|
|
51
|
+
if req.get("status", "confirmed") in {"confirmed", "approved"} and req.get("scope", "in") == "in":
|
|
52
|
+
active.add(rid)
|
|
53
|
+
dimensions = req.get("dimensions")
|
|
54
|
+
if not isinstance(dimensions, dict):
|
|
55
|
+
fail(errors, f"{rid}: dimensions is required")
|
|
56
|
+
else:
|
|
57
|
+
missing = [d for d in DIMENSIONS if not isinstance(dimensions.get(d), str) or not dimensions[d].strip()]
|
|
58
|
+
if missing:
|
|
59
|
+
fail(errors, f"{rid}: missing dimensions: {', '.join(missing)}")
|
|
60
|
+
|
|
61
|
+
if stage == "baseline":
|
|
62
|
+
return errors
|
|
63
|
+
|
|
64
|
+
designs = data.get("designs")
|
|
65
|
+
if not isinstance(designs, list) or not designs:
|
|
66
|
+
fail(errors, "designs must be a non-empty array after baseline")
|
|
67
|
+
covered_by_design = set()
|
|
68
|
+
for item in designs or []:
|
|
69
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
70
|
+
fail(errors, "every design needs an id")
|
|
71
|
+
continue
|
|
72
|
+
refs = references(item.get("requirements"))
|
|
73
|
+
unknown = refs - known
|
|
74
|
+
if unknown:
|
|
75
|
+
fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
76
|
+
covered_by_design |= refs
|
|
77
|
+
if item.get("kind") == "api":
|
|
78
|
+
contract = item.get("contract")
|
|
79
|
+
if not isinstance(contract, dict) or any(not contract.get(k) for k in ("request", "response", "errors", "behavior")):
|
|
80
|
+
fail(errors, f"{item['id']}: API design must include request, response, errors and behavior")
|
|
81
|
+
missing = active - covered_by_design
|
|
82
|
+
if missing:
|
|
83
|
+
fail(errors, "requirements missing design coverage: " + ", ".join(sorted(missing)))
|
|
84
|
+
if stage == "design":
|
|
85
|
+
return errors
|
|
86
|
+
|
|
87
|
+
tasks = data.get("tasks")
|
|
88
|
+
if not isinstance(tasks, list) or not tasks:
|
|
89
|
+
fail(errors, "tasks must be a non-empty array after design")
|
|
90
|
+
covered_by_task = set()
|
|
91
|
+
for task in tasks or []:
|
|
92
|
+
if not isinstance(task, dict) or not isinstance(task.get("id"), str):
|
|
93
|
+
fail(errors, "every task needs an id")
|
|
94
|
+
continue
|
|
95
|
+
refs = task.get("requirements")
|
|
96
|
+
if not isinstance(refs, list) or not refs:
|
|
97
|
+
fail(errors, f"{task['id']}: requirements is required")
|
|
98
|
+
else:
|
|
99
|
+
covered_by_task.update(refs)
|
|
100
|
+
unknown = set(refs) - known
|
|
101
|
+
if unknown:
|
|
102
|
+
fail(errors, f"{task['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
103
|
+
criteria = task.get("acceptance")
|
|
104
|
+
if not isinstance(criteria, dict):
|
|
105
|
+
fail(errors, f"{task['id']}: acceptance is required")
|
|
106
|
+
else:
|
|
107
|
+
missing = [d for d in DIMENSIONS if not isinstance(criteria.get(d), str) or not criteria[d].strip()]
|
|
108
|
+
if missing:
|
|
109
|
+
fail(errors, f"{task['id']}: missing acceptance dimensions: {', '.join(missing)}")
|
|
110
|
+
missing = active - covered_by_task
|
|
111
|
+
if missing:
|
|
112
|
+
fail(errors, "requirements missing task coverage: " + ", ".join(sorted(missing)))
|
|
113
|
+
if stage == "change-plan":
|
|
114
|
+
return errors
|
|
115
|
+
|
|
116
|
+
verification = data.get("verification")
|
|
117
|
+
if not isinstance(verification, list) or not verification:
|
|
118
|
+
fail(errors, "verification must be a non-empty array after change-plan")
|
|
119
|
+
covered_by_verification = set()
|
|
120
|
+
for item in verification or []:
|
|
121
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), str):
|
|
122
|
+
fail(errors, "every verification item needs an id")
|
|
123
|
+
continue
|
|
124
|
+
refs = item.get("requirements")
|
|
125
|
+
if not isinstance(refs, list) or not refs:
|
|
126
|
+
fail(errors, f"{item['id']}: requirements is required")
|
|
127
|
+
else:
|
|
128
|
+
covered_by_verification.update(refs)
|
|
129
|
+
unknown = set(refs) - known
|
|
130
|
+
if unknown:
|
|
131
|
+
fail(errors, f"{item['id']}: references unknown requirements: {', '.join(sorted(unknown))}")
|
|
132
|
+
if not isinstance(item.get("evidence"), str) or not item["evidence"].strip():
|
|
133
|
+
fail(errors, f"{item['id']}: evidence is required")
|
|
134
|
+
missing = active - covered_by_verification
|
|
135
|
+
if missing:
|
|
136
|
+
fail(errors, "requirements missing verification coverage: " + ", ".join(sorted(missing)))
|
|
137
|
+
return errors
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main() -> int:
|
|
141
|
+
parser = argparse.ArgumentParser()
|
|
142
|
+
parser.add_argument("--file", required=True)
|
|
143
|
+
parser.add_argument("--stage", choices=STAGES, required=True)
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
try:
|
|
146
|
+
data = json.loads(Path(args.file).read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
148
|
+
print(f"TRACEABILITY_FAIL: cannot read manifest: {exc}", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
print("TRACEABILITY_FAIL: manifest root must be an object", file=sys.stderr)
|
|
152
|
+
return 2
|
|
153
|
+
errors = check(data, args.stage)
|
|
154
|
+
result = {"status": "passed" if not errors else "failed", "stage": args.stage, "errors": errors}
|
|
155
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
156
|
+
return 0 if not errors else 1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
raise SystemExit(main())
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "optparse"
|
|
6
6
|
require "yaml"
|
|
7
|
+
require_relative "control_io"
|
|
8
|
+
require_relative "phase_ownership"
|
|
7
9
|
|
|
8
10
|
module WorkflowStateValidator
|
|
9
11
|
REQUIRED_STATE_FIELDS = %w[
|
|
@@ -18,12 +20,18 @@ module WorkflowStateValidator
|
|
|
18
20
|
|
|
19
21
|
TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed].freeze
|
|
20
22
|
NON_TERMINAL_ARTIFACT_STATUSES = %w[planned generated pending_rerun stale failed].freeze
|
|
23
|
+
TERMINAL_WORKFLOW_STATUSES = %w[completed failed cancelled].freeze
|
|
21
24
|
|
|
22
25
|
module_function
|
|
23
26
|
|
|
24
|
-
def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true)
|
|
27
|
+
def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true, phase_owner_override: nil)
|
|
25
28
|
root = File.expand_path(root)
|
|
26
|
-
|
|
29
|
+
canonical_state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
|
|
30
|
+
unless File.expand_path(state_path, root) == canonical_state_path
|
|
31
|
+
issues = [issue("error", "invalid_control_path", "STATE.md path must be .planning/STATE.md")]
|
|
32
|
+
return result(false, state_path, nil, nil, issues)
|
|
33
|
+
end
|
|
34
|
+
state_path = canonical_state_path
|
|
27
35
|
issues = []
|
|
28
36
|
|
|
29
37
|
state = state_override || load_state(state_path, issues)
|
|
@@ -34,8 +42,14 @@ module WorkflowStateValidator
|
|
|
34
42
|
end
|
|
35
43
|
return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
|
|
36
44
|
|
|
45
|
+
ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
|
|
46
|
+
unless ownership["ok"]
|
|
47
|
+
issues << issue("error", ownership["reason"], ownership["message"], ownership.reject { |key, _| %w[ok reason message].include?(key) })
|
|
48
|
+
return result(false, state_path, nil, nil, issues)
|
|
49
|
+
end
|
|
50
|
+
|
|
37
51
|
workflow_path = state["workflow_config"] || File.join(".planning", "workflows", "#{state.fetch("current_flow")}.yaml")
|
|
38
|
-
abs_workflow_path =
|
|
52
|
+
abs_workflow_path, workflow_path = DevflowControlIO.resolve_control_path(root, workflow_path, :workflow)
|
|
39
53
|
workflow = workflow_override || load_yaml(abs_workflow_path, issues, "workflow_config")
|
|
40
54
|
return result(false, state_path, workflow_path, nil, issues) unless workflow
|
|
41
55
|
|
|
@@ -103,7 +117,7 @@ module WorkflowStateValidator
|
|
|
103
117
|
|
|
104
118
|
registry = registry_override
|
|
105
119
|
unless registry
|
|
106
|
-
abs_registry_path =
|
|
120
|
+
abs_registry_path, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
|
|
107
121
|
unless File.exist?(abs_registry_path)
|
|
108
122
|
issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
|
|
109
123
|
return {}
|
|
@@ -121,7 +135,7 @@ module WorkflowStateValidator
|
|
|
121
135
|
)
|
|
122
136
|
end
|
|
123
137
|
Array(registry["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
|
|
124
|
-
rescue ArgumentError, TypeError, Psych::SyntaxError => e
|
|
138
|
+
rescue ArgumentError, TypeError, Psych::SyntaxError, DevflowControlIO::InvalidControlPath => e
|
|
125
139
|
issues << issue("error", "invalid_artifact_registry", e.message, path: registry_path)
|
|
126
140
|
{}
|
|
127
141
|
end
|
|
@@ -145,8 +159,10 @@ module WorkflowStateValidator
|
|
|
145
159
|
unknown.each do |step_id|
|
|
146
160
|
issues << issue("error", "unknown_tracked_step", "Tracked step is not defined in the active workflow", step: step_id)
|
|
147
161
|
end
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
unless %w[failed cancelled].include?(state["status"])
|
|
163
|
+
missing.each do |step_id|
|
|
164
|
+
issues << issue("error", "missing_tracked_step", "Workflow step is missing from both completed_steps and pending_steps", step: step_id)
|
|
165
|
+
end
|
|
150
166
|
end
|
|
151
167
|
|
|
152
168
|
current_step = state["current_step"]
|
|
@@ -162,6 +178,10 @@ module WorkflowStateValidator
|
|
|
162
178
|
missing_completed.each do |step_id|
|
|
163
179
|
issues << issue("error", "completed_status_missing_step", "Workflow status is completed but a workflow step is not completed", step: step_id)
|
|
164
180
|
end
|
|
181
|
+
elsif %w[failed cancelled].include?(state["status"])
|
|
182
|
+
if pending_ids.any?
|
|
183
|
+
issues << issue("error", "terminal_with_pending_steps", "Terminal workflow status must not retain pending steps", status: state["status"])
|
|
184
|
+
end
|
|
165
185
|
elsif pending_ids.empty?
|
|
166
186
|
issues << issue("error", "non_completed_without_pending_steps", "Workflow has no pending steps but status is not completed")
|
|
167
187
|
end
|
|
@@ -304,6 +324,7 @@ module WorkflowStateValidator
|
|
|
304
324
|
validate_frontend_tech_spec_gate(issues, step, data)
|
|
305
325
|
validate_api_supplement_gate(issues, step, data)
|
|
306
326
|
validate_integration_merge_gate(issues, step, data)
|
|
327
|
+
validate_change_plan_gate(issues, step, data)
|
|
307
328
|
validate_verification_gate(issues, step, data)
|
|
308
329
|
rescue JSON::ParserError => e
|
|
309
330
|
issues << issue("error", "invalid_meta_json", e.message, step: step["id"], path: path)
|
|
@@ -403,6 +424,10 @@ module WorkflowStateValidator
|
|
|
403
424
|
|
|
404
425
|
issues << issue("error", "baseline_blocking_counter_nonzero", "Requirement baseline has a non-zero blocking counter", step: step["id"], field: field, count: data[field])
|
|
405
426
|
end
|
|
427
|
+
|
|
428
|
+
if data["traceability_status"] != "passed"
|
|
429
|
+
issues << issue("error", "baseline_traceability_not_passed", "Requirement traceability must pass before technical design", step: step["id"], status: data["traceability_status"])
|
|
430
|
+
end
|
|
406
431
|
end
|
|
407
432
|
|
|
408
433
|
def validate_api_contract_gate(issues, step, data)
|
|
@@ -416,6 +441,10 @@ module WorkflowStateValidator
|
|
|
416
441
|
issues << issue("error", "api_contract_pending_items", "API contract has unresolved pending contract items", step: step["id"], count: data["pending_contract_count"])
|
|
417
442
|
end
|
|
418
443
|
|
|
444
|
+
if data["traceability_status"] != "passed"
|
|
445
|
+
issues << issue("error", "api_contract_traceability_not_passed", "API contract must retain complete requirement traceability", step: step["id"], status: data["traceability_status"])
|
|
446
|
+
end
|
|
447
|
+
|
|
419
448
|
api_required = data["api_required"]
|
|
420
449
|
endpoint_count = data["endpoint_count"].to_i
|
|
421
450
|
if api_required == false && endpoint_count.positive?
|
|
@@ -495,6 +524,10 @@ module WorkflowStateValidator
|
|
|
495
524
|
issues << issue("error", "merge_gate_not_passed", "Integration merge gate status is not passed", step: step["id"], status: data["merge_gate_status"])
|
|
496
525
|
end
|
|
497
526
|
|
|
527
|
+
if data["traceability_status"] != "passed"
|
|
528
|
+
issues << issue("error", "integration_traceability_not_passed", "Integration gate requires complete requirement-to-design traceability", step: step["id"], status: data["traceability_status"])
|
|
529
|
+
end
|
|
530
|
+
|
|
498
531
|
%w[
|
|
499
532
|
contract_backend_mismatch_count
|
|
500
533
|
contract_frontend_mismatch_count
|
|
@@ -507,6 +540,20 @@ module WorkflowStateValidator
|
|
|
507
540
|
end
|
|
508
541
|
end
|
|
509
542
|
|
|
543
|
+
def validate_change_plan_gate(issues, step, data)
|
|
544
|
+
return unless step["id"] == "change-plan"
|
|
545
|
+
|
|
546
|
+
if data["traceability_status"] != "passed"
|
|
547
|
+
issues << issue("error", "change_plan_traceability_not_passed", "Change plan requires complete requirement-to-task traceability", step: step["id"], status: data["traceability_status"])
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
%w[uncovered_requirement_count orphan_task_count].each do |field|
|
|
551
|
+
next if data[field].to_i.zero?
|
|
552
|
+
|
|
553
|
+
issues << issue("error", "change_plan_traceability_counter_nonzero", "Change plan has unresolved traceability coverage", step: step["id"], field: field, count: data[field])
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
510
557
|
def validate_verification_gate(issues, step, data)
|
|
511
558
|
return unless step["id"] == "verify"
|
|
512
559
|
|
|
@@ -589,7 +636,12 @@ if __FILE__ == $PROGRAM_NAME
|
|
|
589
636
|
end.parse!
|
|
590
637
|
|
|
591
638
|
root = Dir.pwd
|
|
592
|
-
|
|
639
|
+
begin
|
|
640
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
641
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
642
|
+
puts JSON.pretty_generate({ "ok" => false, "errors" => [{ "code" => "invalid_control_path", "message" => error.message }] })
|
|
643
|
+
exit 1
|
|
644
|
+
end
|
|
593
645
|
validation = WorkflowStateValidator.validate(
|
|
594
646
|
root: root,
|
|
595
647
|
state_path: state_path,
|