@guanlin4924/devflow-pi 0.1.39 → 0.1.47
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 +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +61 -24
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Deterministically resolves business-model provenance against requirement
|
|
5
|
+
# governance, source assertions, code-index snapshots, domain documents, and
|
|
6
|
+
# Core-owned user decisions. AI-provided source labels are never trusted.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "pathname"
|
|
12
|
+
require "yaml"
|
|
13
|
+
require_relative "requirement_graph"
|
|
14
|
+
|
|
15
|
+
module BusinessSourceResolver
|
|
16
|
+
SOURCE_TYPES = %w[requirement code domain_rule user_decision].freeze
|
|
17
|
+
REGISTRY_SCHEMA = File.expand_path("../contracts/business-source-registry.schema.yaml", __dir__)
|
|
18
|
+
REFERENCE_ID = /\A[A-Za-z][A-Za-z0-9_-]*\z/.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def add(errors, code, message, source_id: nil, path: nil)
|
|
23
|
+
entry = { "code" => code, "message" => message }
|
|
24
|
+
entry["source_id"] = source_id if source_id
|
|
25
|
+
entry["path"] = path if path
|
|
26
|
+
errors << entry
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load_yaml(path)
|
|
30
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
31
|
+
raise ArgumentError, "YAML root must be an object" unless value.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
value
|
|
34
|
+
rescue Psych::Exception => e
|
|
35
|
+
raise ArgumentError, "invalid YAML: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def deep_sort(value)
|
|
39
|
+
case value
|
|
40
|
+
when Hash then value.keys.sort.to_h { |key| [key, deep_sort(value[key])] }
|
|
41
|
+
when Array then value.map { |item| deep_sort(item) }
|
|
42
|
+
else value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fingerprint(value)
|
|
47
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(deep_sort(value)))}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def file_fingerprint(path)
|
|
51
|
+
"sha256:#{Digest::SHA256.file(path).hexdigest}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_feature?(feature)
|
|
55
|
+
RequirementGraph.valid_feature?(feature)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def safe_path(root, raw, kind, must_exist: true)
|
|
59
|
+
root_real = File.realpath(root)
|
|
60
|
+
value = raw.to_s
|
|
61
|
+
raise ArgumentError, "#{kind} path is required" if value.empty?
|
|
62
|
+
raise ArgumentError, "#{kind} path must be project-relative" if Pathname.new(value).absolute?
|
|
63
|
+
raise ArgumentError, "#{kind} path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
|
|
64
|
+
|
|
65
|
+
normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
|
|
66
|
+
raise ArgumentError, "#{kind} path traverses parent directories" if normalized == ".." || normalized.start_with?("../")
|
|
67
|
+
absolute = File.expand_path(normalized, root_real)
|
|
68
|
+
raise ArgumentError, "#{kind} path escapes project root" unless absolute.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
69
|
+
raise ArgumentError, "#{kind} path is missing" if must_exist && !File.file?(absolute)
|
|
70
|
+
|
|
71
|
+
cursor = root_real
|
|
72
|
+
normalized.split("/").each do |segment|
|
|
73
|
+
cursor = File.join(cursor, segment)
|
|
74
|
+
next unless File.symlink?(cursor)
|
|
75
|
+
|
|
76
|
+
resolved = File.realpath(cursor)
|
|
77
|
+
raise ArgumentError, "#{kind} path escapes through symlink" unless resolved == root_real || resolved.start_with?("#{root_real}#{File::SEPARATOR}")
|
|
78
|
+
end
|
|
79
|
+
[absolute, normalized]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def command(root, *args)
|
|
83
|
+
stdout, _stderr, status = Open3.capture3(*args, chdir: root)
|
|
84
|
+
status.success? ? stdout.strip : ""
|
|
85
|
+
rescue Errno::ENOENT
|
|
86
|
+
""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def command_success?(root, *args)
|
|
90
|
+
_stdout, _stderr, status = Open3.capture3(*args, chdir: root)
|
|
91
|
+
status.success?
|
|
92
|
+
rescue Errno::ENOENT
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def repository_revision(root)
|
|
97
|
+
command(root, "git", "rev-parse", "HEAD")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def worktree_signature(root)
|
|
101
|
+
output = command(root, "git", "status", "--porcelain=v1", "--untracked-files=all")
|
|
102
|
+
lines = output.lines.map(&:rstrip).reject do |line|
|
|
103
|
+
path = line.length > 3 ? line[3..].to_s.strip : ""
|
|
104
|
+
path.start_with?(".planning/", ".context/")
|
|
105
|
+
end
|
|
106
|
+
"sha256:#{Digest::SHA256.hexdigest(lines.join("\n"))}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_shape(registry, schema, feature, errors)
|
|
110
|
+
top = schema.fetch("top_level")
|
|
111
|
+
allowed = top.fetch("properties").keys
|
|
112
|
+
registry.keys.each { |key| add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "unknown registry field #{key}", path: key) unless allowed.include?(key) }
|
|
113
|
+
top.fetch("required").each { |key| add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required registry field #{key} is missing", path: key) unless registry.key?(key) }
|
|
114
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "schema_version must be 1", path: "schema_version") unless registry["schema_version"] == 1
|
|
115
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "feature does not match", path: "feature") unless registry["feature"] == feature
|
|
116
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "registry_revision must be a non-negative integer", path: "registry_revision") unless registry["registry_revision"].is_a?(Integer) && registry["registry_revision"] >= 0
|
|
117
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "sources must be an array", path: "sources") unless registry["sources"].is_a?(Array)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_source_shape(source, schema, errors, seen)
|
|
121
|
+
unless source.is_a?(Hash)
|
|
122
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "source must be an object")
|
|
123
|
+
return false
|
|
124
|
+
end
|
|
125
|
+
id = source["id"].to_s
|
|
126
|
+
pattern = Regexp.new(schema.fetch("id_pattern"))
|
|
127
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "source ID is invalid", source_id: id) unless id.match?(pattern)
|
|
128
|
+
if seen.include?(id)
|
|
129
|
+
add(errors, "BUSINESS_SOURCE_DUPLICATE_ID", "duplicate source ID", source_id: id)
|
|
130
|
+
else
|
|
131
|
+
seen << id
|
|
132
|
+
end
|
|
133
|
+
type = source["type"].to_s
|
|
134
|
+
variant = schema.fetch("source_variants", {})[type]
|
|
135
|
+
unless SOURCE_TYPES.include?(type) && variant.is_a?(Hash)
|
|
136
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "unsupported source type #{type}", source_id: id)
|
|
137
|
+
return false
|
|
138
|
+
end
|
|
139
|
+
allowed = variant.fetch("properties").keys
|
|
140
|
+
source.keys.each do |key|
|
|
141
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "field #{key} is not valid for #{type}", source_id: id, path: key) unless allowed.include?(key)
|
|
142
|
+
end
|
|
143
|
+
variant.fetch("required").each do |key|
|
|
144
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required field #{key} is missing", source_id: id, path: key) unless source.key?(key)
|
|
145
|
+
end
|
|
146
|
+
variant.fetch("properties").each do |key, property|
|
|
147
|
+
validate_contract_value(source[key], property, errors, id, key) if source.key?(key)
|
|
148
|
+
end
|
|
149
|
+
source["locator"].is_a?(Hash) || add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator must be an object", source_id: id, path: "locator")
|
|
150
|
+
locator = source["locator"]
|
|
151
|
+
locator_variant = schema.fetch("locator_variants", {})[type]
|
|
152
|
+
if locator.is_a?(Hash) && locator_variant.is_a?(Hash)
|
|
153
|
+
locator_allowed = locator_variant.fetch("properties").keys
|
|
154
|
+
locator.keys.each do |key|
|
|
155
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator field #{key} is not valid for #{type}", source_id: id, path: "locator.#{key}") unless locator_allowed.include?(key)
|
|
156
|
+
end
|
|
157
|
+
locator_variant.fetch("required", []).each do |key|
|
|
158
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "required locator field #{key} is missing", source_id: id, path: "locator.#{key}") unless locator.key?(key)
|
|
159
|
+
end
|
|
160
|
+
locator_variant.fetch("properties").each do |key, property|
|
|
161
|
+
validate_contract_value(locator[key], property, errors, id, "locator.#{key}") if locator.key?(key)
|
|
162
|
+
end
|
|
163
|
+
alternatives = locator_variant.fetch("at_least_one", [])
|
|
164
|
+
if alternatives.any? && alternatives.none? { |key| locator.key?(key) }
|
|
165
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "locator requires at least one of #{alternatives.join(', ')}", source_id: id, path: "locator")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_contract_value(value, property, errors, source_id, path)
|
|
172
|
+
return unless property.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
if property.key?("const") && value != property["const"]
|
|
175
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "value must equal #{property['const']}", source_id: source_id, path: path)
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
valid = case property["type"]
|
|
179
|
+
when "string" then value.is_a?(String) && (!property["non_empty"] || !value.strip.empty?)
|
|
180
|
+
when "integer" then value.is_a?(Integer) && (!property.key?("minimum") || value >= property["minimum"])
|
|
181
|
+
when "id" then value.is_a?(String) && value.match?(REFERENCE_ID)
|
|
182
|
+
when "requirement_locator", "code_locator", "domain_locator", "decision_locator" then value.is_a?(Hash)
|
|
183
|
+
else true
|
|
184
|
+
end
|
|
185
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", "value does not satisfy #{property['type'] || 'contract'}", source_id: source_id, path: path) unless valid
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def verify_file_fingerprint(root, source, field, errors)
|
|
189
|
+
path, relative = safe_path(root, source[field], field)
|
|
190
|
+
actual = file_fingerprint(path)
|
|
191
|
+
if source["source_fingerprint"].to_s != actual
|
|
192
|
+
add(errors, "BUSINESS_SOURCE_FINGERPRINT_MISMATCH", "registered fingerprint does not match current file", source_id: source["id"], path: relative)
|
|
193
|
+
end
|
|
194
|
+
[path, relative, actual]
|
|
195
|
+
rescue ArgumentError => e
|
|
196
|
+
add(errors, "BUSINESS_SOURCE_NOT_FOUND", e.message, source_id: source["id"], path: source[field].to_s)
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def verify_requirement(root, feature, source, errors)
|
|
201
|
+
file_info = verify_file_fingerprint(root, source, "document", errors)
|
|
202
|
+
locator = source["locator"]
|
|
203
|
+
requirement_ref = locator.is_a?(Hash) ? locator["requirement_ref"].to_s : ""
|
|
204
|
+
assertion_id = locator.is_a?(Hash) ? locator["assertion_id"].to_s : ""
|
|
205
|
+
begin
|
|
206
|
+
graph, = RequirementGraph.load_graph(root, feature)
|
|
207
|
+
requirement = Array(graph["nodes"]).find { |node| node.is_a?(Hash) && node["id"] == requirement_ref && node["kind"] == "requirement" }
|
|
208
|
+
unless requirement && %w[confirmed approved].include?(requirement["status"].to_s)
|
|
209
|
+
add(errors, "BUSINESS_REQUIREMENT_REF_INVALID", "requirement_ref must resolve to a confirmed/approved requirement", source_id: source["id"], path: requirement_ref)
|
|
210
|
+
end
|
|
211
|
+
rescue ArgumentError => e
|
|
212
|
+
add(errors, "BUSINESS_REQUIREMENT_REF_INVALID", e.message, source_id: source["id"], path: requirement_ref)
|
|
213
|
+
end
|
|
214
|
+
units_path = File.join(RequirementGraph.feature_dir(root, feature), "source_units.yaml")
|
|
215
|
+
unless File.file?(units_path)
|
|
216
|
+
add(errors, "BUSINESS_ASSERTION_NOT_FOUND", "source_units.yaml is missing", source_id: source["id"], path: units_path)
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
units = load_yaml(units_path)
|
|
220
|
+
assertions = Array(units["units"]).flat_map { |unit| Array(unit["required_assertions"]) }
|
|
221
|
+
add(errors, "BUSINESS_ASSERTION_NOT_FOUND", "assertion locator is not present in source_units", source_id: source["id"], path: assertion_id) unless assertions.any? { |item| item.is_a?(Hash) && item["assertion_id"].to_s == assertion_id }
|
|
222
|
+
if file_info && units["source_document_fingerprint"].to_s != file_info[2]
|
|
223
|
+
add(errors, "BUSINESS_SOURCE_FINGERPRINT_MISMATCH", "source_units fingerprint does not match the requirement document", source_id: source["id"], path: units_path)
|
|
224
|
+
end
|
|
225
|
+
rescue ArgumentError => e
|
|
226
|
+
add(errors, "BUSINESS_SOURCE_SCHEMA_INVALID", e.message, source_id: source["id"])
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def verify_code(root, source, errors)
|
|
230
|
+
id = source["id"]
|
|
231
|
+
path_info = safe_path(root, source["path"], "code")
|
|
232
|
+
path, relative = path_info
|
|
233
|
+
actual_hash = file_fingerprint(path)
|
|
234
|
+
add(errors, "BUSINESS_CODE_CONTENT_MISMATCH", "code content_hash is stale", source_id: id, path: relative) unless source["content_hash"].to_s == actual_hash
|
|
235
|
+
current_revision = repository_revision(root)
|
|
236
|
+
add(errors, "BUSINESS_CODE_REVISION_MISMATCH", "repository revision is stale", source_id: id) unless !current_revision.empty? && source["repository_revision"].to_s == current_revision
|
|
237
|
+
current_signature = worktree_signature(root)
|
|
238
|
+
add(errors, "BUSINESS_WORKTREE_SIGNATURE_MISMATCH", "worktree signature is stale", source_id: id) unless source["worktree_signature"].to_s == current_signature
|
|
239
|
+
|
|
240
|
+
snapshot_path = File.join(root, ".planning", "code-index", "SNAPSHOT.json")
|
|
241
|
+
unless File.file?(snapshot_path)
|
|
242
|
+
add(errors, "BUSINESS_CODE_INDEX_STALE", "code index snapshot is missing", source_id: id, path: snapshot_path)
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
snapshot = JSON.parse(File.read(snapshot_path))
|
|
246
|
+
if snapshot["generation"].to_s != source["index_generation"].to_s || snapshot.dig("workspace", "repositoryRevision").to_s != current_revision
|
|
247
|
+
add(errors, "BUSINESS_CODE_INDEX_STALE", "code index generation or repository revision is stale", source_id: id, path: snapshot_path)
|
|
248
|
+
end
|
|
249
|
+
symbol = source.dig("locator", "symbol").to_s
|
|
250
|
+
line_start = source.dig("locator", "line_start")
|
|
251
|
+
if symbol.empty? && !line_start.is_a?(Integer)
|
|
252
|
+
add(errors, "BUSINESS_CODE_LOCATOR_INVALID", "code locator requires symbol or line_start", source_id: id)
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
if line_start.is_a?(Integer)
|
|
256
|
+
line_count = File.foreach(path).count
|
|
257
|
+
add(errors, "BUSINESS_CODE_LOCATOR_INVALID", "line_start is outside the current file", source_id: id, path: relative) unless line_start.positive? && line_start <= line_count
|
|
258
|
+
end
|
|
259
|
+
return if symbol.empty?
|
|
260
|
+
|
|
261
|
+
symbols_path = File.join(root, ".planning", "code-index", "symbols.jsonl")
|
|
262
|
+
unless File.file?(symbols_path)
|
|
263
|
+
add(errors, "BUSINESS_CODE_SYMBOL_NOT_FOUND", "symbols index is missing", source_id: id, path: symbols_path)
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
symbols = File.readlines(symbols_path, chomp: true).map do |line|
|
|
267
|
+
JSON.parse(line)
|
|
268
|
+
rescue JSON::ParserError
|
|
269
|
+
nil
|
|
270
|
+
end.compact
|
|
271
|
+
match = symbols.find do |entry|
|
|
272
|
+
entry["path"].to_s == relative && [entry["name"].to_s, entry["qualified_name"].to_s].include?(symbol) &&
|
|
273
|
+
(!line_start.is_a?(Integer) || (entry["line_start"].to_i <= line_start && entry["line_end"].to_i >= line_start))
|
|
274
|
+
end
|
|
275
|
+
add(errors, "BUSINESS_CODE_SYMBOL_NOT_FOUND", "symbol/line is not present in the current index", source_id: id, path: relative) unless match
|
|
276
|
+
|
|
277
|
+
return unless source["commit"]
|
|
278
|
+
|
|
279
|
+
unless command_success?(root, "git", "cat-file", "-e", "#{source['commit']}:#{relative}")
|
|
280
|
+
add(errors, "BUSINESS_CODE_COMMIT_INVALID", "commit does not contain the registered code path", source_id: id, path: relative)
|
|
281
|
+
end
|
|
282
|
+
rescue ArgumentError => e
|
|
283
|
+
add(errors, "BUSINESS_CODE_PATH_INVALID", e.message, source_id: source["id"], path: source["path"].to_s)
|
|
284
|
+
rescue JSON::ParserError => e
|
|
285
|
+
add(errors, "BUSINESS_CODE_INDEX_STALE", "invalid code index snapshot: #{e.message}", source_id: source["id"])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def verify_domain_rule(root, source, errors)
|
|
289
|
+
file_info = verify_file_fingerprint(root, source, "document", errors)
|
|
290
|
+
return unless file_info
|
|
291
|
+
|
|
292
|
+
clause = source.dig("locator", "clause_id").to_s
|
|
293
|
+
if clause.empty? || !File.readlines(file_info[0], chomp: true).any? { |line| line.include?(clause) }
|
|
294
|
+
add(errors, "BUSINESS_DOMAIN_RULE_INVALID", "domain clause locator is not present in the document", source_id: source["id"], path: clause)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def verify_user_decision(root, feature, source, errors)
|
|
299
|
+
path = File.join(RequirementGraph.feature_dir(root, feature), "business-decisions.json")
|
|
300
|
+
unless File.file?(path)
|
|
301
|
+
add(errors, "BUSINESS_DECISION_INVALID", "Core decision registry is missing", source_id: source["id"], path: path)
|
|
302
|
+
return
|
|
303
|
+
end
|
|
304
|
+
registry = JSON.parse(File.read(path))
|
|
305
|
+
stored_fingerprint = registry["fingerprint"].to_s
|
|
306
|
+
payload = registry.reject { |key, _| key == "fingerprint" }
|
|
307
|
+
actual_fingerprint = fingerprint(payload)
|
|
308
|
+
decision_id = source.dig("locator", "decision_id").to_s
|
|
309
|
+
valid = registry["feature"] == feature && registry["revision"] == source["decision_revision"] &&
|
|
310
|
+
stored_fingerprint == actual_fingerprint && source["decision_fingerprint"].to_s == actual_fingerprint &&
|
|
311
|
+
Array(registry["decisions"]).any? { |entry| entry.is_a?(Hash) && entry["id"].to_s == decision_id }
|
|
312
|
+
add(errors, "BUSINESS_DECISION_INVALID", "decision revision/fingerprint/locator is not current", source_id: source["id"], path: decision_id) unless valid
|
|
313
|
+
rescue JSON::ParserError => e
|
|
314
|
+
add(errors, "BUSINESS_DECISION_INVALID", "invalid decision registry: #{e.message}", source_id: source["id"], path: path)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def validate(root:, feature:, registry_path: nil)
|
|
318
|
+
raise ArgumentError, "invalid feature" unless valid_feature?(feature)
|
|
319
|
+
root = File.realpath(root)
|
|
320
|
+
schema = load_yaml(REGISTRY_SCHEMA)
|
|
321
|
+
raw_registry = registry_path || File.join(".planning", "business-model", feature, "source-registry.yaml")
|
|
322
|
+
registry_absolute, registry_relative = safe_path(root, raw_registry, "source registry")
|
|
323
|
+
registry = load_yaml(registry_absolute)
|
|
324
|
+
errors = []
|
|
325
|
+
validate_shape(registry, schema, feature, errors)
|
|
326
|
+
sources = registry["sources"].is_a?(Array) ? registry["sources"] : []
|
|
327
|
+
seen = []
|
|
328
|
+
sources.each do |source|
|
|
329
|
+
next unless validate_source_shape(source, schema, errors, seen)
|
|
330
|
+
|
|
331
|
+
case source["type"]
|
|
332
|
+
when "requirement" then verify_requirement(root, feature, source, errors)
|
|
333
|
+
when "code" then verify_code(root, source, errors)
|
|
334
|
+
when "domain_rule" then verify_domain_rule(root, source, errors)
|
|
335
|
+
when "user_decision" then verify_user_decision(root, feature, source, errors)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
registry_fingerprint = fingerprint(registry)
|
|
339
|
+
{
|
|
340
|
+
"ok" => errors.empty?,
|
|
341
|
+
"verdict" => errors.empty? ? "allow" : "block_unsafe",
|
|
342
|
+
"errors" => errors,
|
|
343
|
+
"warnings" => [],
|
|
344
|
+
"source_registry_path" => registry_relative,
|
|
345
|
+
"source_registry_fingerprint" => registry_fingerprint,
|
|
346
|
+
"resolved_source_ids" => errors.empty? ? sources.map { |source| source["id"] } : []
|
|
347
|
+
}
|
|
348
|
+
rescue ArgumentError, Errno::ENOENT => e
|
|
349
|
+
{ "ok" => false, "verdict" => "block_unsafe", "errors" => [{ "code" => "BUSINESS_SOURCE_SCHEMA_INVALID", "message" => e.message }], "warnings" => [] }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
if $PROGRAM_NAME == __FILE__
|
|
354
|
+
options = {}
|
|
355
|
+
OptionParser.new do |opts|
|
|
356
|
+
opts.on("--root PATH") { |value| options[:root] = value }
|
|
357
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
358
|
+
opts.on("--registry PATH") { |value| options[:registry_path] = value }
|
|
359
|
+
opts.on("--json") { options[:json] = true }
|
|
360
|
+
end.parse!
|
|
361
|
+
abort("--root and --feature are required") unless options[:root] && options[:feature]
|
|
362
|
+
payload = BusinessSourceResolver.validate(root: options[:root], feature: options[:feature], registry_path: options[:registry_path])
|
|
363
|
+
puts JSON.pretty_generate(payload)
|
|
364
|
+
exit(payload["ok"] ? 0 : 1)
|
|
365
|
+
end
|
|
@@ -7,6 +7,8 @@ module CheckerRegistry
|
|
|
7
7
|
api-test-schema-check contract-coverage-check api-test-matrix-check
|
|
8
8
|
test-coverage-check test-obligation-check acceptance-test-results-check
|
|
9
9
|
change-impact-check tdd-evidence-check test-task-binding-check
|
|
10
|
+
cross-track-consistency-check business-closure-check business-claim-reference-check
|
|
11
|
+
business-cross-model-check business-invalidation-check
|
|
10
12
|
].freeze
|
|
11
13
|
EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
|
|
12
14
|
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
options = { feature: nil, json: false }
|
|
9
|
+
OptionParser.new do |opts|
|
|
10
|
+
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
11
|
+
opts.on("--json") { options[:json] = true }
|
|
12
|
+
end.parse!
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
15
|
+
feature = options[:feature].to_s
|
|
16
|
+
raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
|
|
17
|
+
|
|
18
|
+
root = Dir.pwd
|
|
19
|
+
base = File.join(root, ".planning", "features", feature)
|
|
20
|
+
files = {
|
|
21
|
+
"api_contract" => File.join(base, "api-contract.md"),
|
|
22
|
+
"api_call_guide" => File.join(base, "api-call-guide.md"),
|
|
23
|
+
"frontend_spec" => File.join(base, "前端技术方案.md"),
|
|
24
|
+
"backend_spec" => File.join(base, "后端技术方案.md"),
|
|
25
|
+
"api_supplement" => File.join(base, "api-supplement-requests.md"),
|
|
26
|
+
}
|
|
27
|
+
errors = []
|
|
28
|
+
missing = files.select { |_, path| !File.file?(path) }.keys
|
|
29
|
+
errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
|
|
30
|
+
|
|
31
|
+
read = lambda do |key|
|
|
32
|
+
path = files.fetch(key)
|
|
33
|
+
File.file?(path) ? File.read(path) : ""
|
|
34
|
+
end
|
|
35
|
+
contract = read.call("api_contract")
|
|
36
|
+
guide = read.call("api_call_guide")
|
|
37
|
+
frontend = read.call("frontend_spec")
|
|
38
|
+
|
|
39
|
+
# Endpoint references are deliberately extracted from the authoritative API
|
|
40
|
+
# contract and checked for textual closure in both consumers. This is a
|
|
41
|
+
# deterministic reference-closure check; an AI integration summary is not used.
|
|
42
|
+
endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
|
|
43
|
+
endpoints = contract.scan(endpoint_pattern).flatten.uniq
|
|
44
|
+
unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
|
|
45
|
+
unless unreferenced.empty?
|
|
46
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
|
|
50
|
+
{
|
|
51
|
+
"api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
|
|
52
|
+
"frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
|
|
53
|
+
}.each do |consumer, references|
|
|
54
|
+
ghosts = references.reject { |endpoint| contract_set[endpoint] }
|
|
55
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
supplement = read.call("api_supplement")
|
|
59
|
+
supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
|
|
60
|
+
supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
|
|
61
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
|
|
62
|
+
supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
|
|
63
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
|
|
64
|
+
|
|
65
|
+
meta_path = File.join(base, "api-call-guide.meta.json")
|
|
66
|
+
if File.file?(meta_path)
|
|
67
|
+
begin
|
|
68
|
+
meta = JSON.parse(File.read(meta_path))
|
|
69
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
|
|
70
|
+
pending = meta["pending_items"]
|
|
71
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
|
|
72
|
+
rescue JSON::ParserError => error
|
|
73
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
supplement_meta = File.join(base, "api-supplement-requests.meta.json")
|
|
78
|
+
if File.file?(supplement_meta)
|
|
79
|
+
begin
|
|
80
|
+
meta = JSON.parse(File.read(supplement_meta))
|
|
81
|
+
unresolved = meta["unresolved_supplement_count"]
|
|
82
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
|
|
83
|
+
rescue JSON::ParserError => error
|
|
84
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result = {
|
|
89
|
+
"ok" => errors.empty?,
|
|
90
|
+
"checker_id" => "cross-track-consistency-check",
|
|
91
|
+
"errors" => errors,
|
|
92
|
+
"evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
|
|
93
|
+
}
|
|
94
|
+
puts(options[:json] ? JSON.generate(result) : result.to_yaml)
|
|
@@ -11,10 +11,10 @@ require_relative "control_io"
|
|
|
11
11
|
require_relative "requirement_graph"
|
|
12
12
|
|
|
13
13
|
POLICY = {
|
|
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] },
|
|
14
|
+
"requirement-baseline" => { "checkers" => %w[requirement-graph-check business-closure-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
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 metric read_model] },
|
|
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] },
|
|
17
|
+
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
18
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
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
20
|
"execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|
|
@@ -69,7 +69,7 @@ begin
|
|
|
69
69
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_path" => workflow_relative,
|
|
70
70
|
"workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw), "manifest_id" => "devflow-core-execution-policy-v2",
|
|
71
71
|
"allowed_paths" => paths, "allowed_agents" => policy["agents"], "checker_ids" => policy["checkers"],
|
|
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],
|
|
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 devflow_mainflow],
|
|
73
73
|
"tool_budget" => policy["budget"], "acceptance" => Array(graph["nodes"]).select { |node| node["kind"] == "acceptance" }.map { |node| node["id"] },
|
|
74
74
|
"allowed_graph_delta" => Array(policy["allowed_graph_delta"]),
|
|
75
75
|
"on_unknown" => "block_and_escalate"
|
|
@@ -24,6 +24,9 @@ module GateContract
|
|
|
24
24
|
%w[allowed_tracks allowed_predecessors].each do |field|
|
|
25
25
|
raise ArgumentError, "#{field} must be an array" unless topology[field].is_a?(Array)
|
|
26
26
|
end
|
|
27
|
+
if topology.key?("allowed_intervening_predecessors") && !topology["allowed_intervening_predecessors"].is_a?(Array)
|
|
28
|
+
raise ArgumentError, "allowed_intervening_predecessors must be an array"
|
|
29
|
+
end
|
|
27
30
|
raise ArgumentError, "allowed_reentry must be an array" unless topology["allowed_reentry"].is_a?(Array)
|
|
28
31
|
validate_reentries!(topology["allowed_reentry"])
|
|
29
32
|
checkpoint = topology["checkpoint"]
|