@guanlin4924/devflow-pi 0.1.16 → 0.1.17
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 +247 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +187 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +83 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +54 -2
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
- package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
- package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Static Runtime Context P1. Unlike runtime_context_collector.rb (P0), this
|
|
5
|
+
# produces a separate v2 evidence projection. It must never turn a repository
|
|
6
|
+
# declaration or code reference into a statement about a running environment.
|
|
7
|
+
require "digest"
|
|
8
|
+
require "json"
|
|
9
|
+
require "open3"
|
|
10
|
+
require "optparse"
|
|
11
|
+
require "rexml/document"
|
|
12
|
+
require "rexml/xpath"
|
|
13
|
+
require "time"
|
|
14
|
+
require "yaml"
|
|
15
|
+
require_relative "control_io"
|
|
16
|
+
|
|
17
|
+
module StaticRuntimeContextCollector
|
|
18
|
+
SCHEMA_VERSION = 2
|
|
19
|
+
COLLECTOR_VERSION = "static-runtime-context-v2.1"
|
|
20
|
+
ROOT_DIR = File.join(".context", "static-runtime-context")
|
|
21
|
+
HISTORY_DIR = File.join(ROOT_DIR, "revisions")
|
|
22
|
+
POINTER = ".context/static-runtime-context.json"
|
|
23
|
+
AUDIT_PATH = File.join(ROOT_DIR, "audit.jsonl")
|
|
24
|
+
MAX_COMPOSE_BYTES = 1_048_576
|
|
25
|
+
EXCLUDED_ROOTS = %w[node_modules vendor dist build .git].freeze
|
|
26
|
+
MANIFEST_EXTRACTORS = {
|
|
27
|
+
"package.json" => :extract_package_json,
|
|
28
|
+
"composer.json" => :extract_composer_json,
|
|
29
|
+
"requirements.txt" => :extract_requirements_txt,
|
|
30
|
+
"pom.xml" => :extract_pom_xml,
|
|
31
|
+
"go.mod" => :extract_go_mod
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def canonical_json(value)
|
|
37
|
+
case value
|
|
38
|
+
when Hash
|
|
39
|
+
"{" + value.keys.map(&:to_s).sort.map { |key| JSON.generate(key) + ":" + canonical_json(value[key]) }.join(",") + "}"
|
|
40
|
+
when Array
|
|
41
|
+
"[" + value.map { |item| canonical_json(item) }.join(",") + "]"
|
|
42
|
+
when String
|
|
43
|
+
JSON.generate(value)
|
|
44
|
+
when Numeric
|
|
45
|
+
value.to_s
|
|
46
|
+
when true, false
|
|
47
|
+
value.to_s
|
|
48
|
+
when nil
|
|
49
|
+
"null"
|
|
50
|
+
else
|
|
51
|
+
JSON.generate(value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sha256(value)
|
|
56
|
+
Digest::SHA256.hexdigest(value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def normalize_subject(value)
|
|
60
|
+
value.to_s.strip.downcase
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def safe_version(value)
|
|
64
|
+
text = value.to_s.strip
|
|
65
|
+
return nil if text.empty? || text.match?(%r{[:/@]}) || text.match?(/(?:token|secret|password)=/i)
|
|
66
|
+
|
|
67
|
+
text
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_package_json(content)
|
|
71
|
+
parsed = JSON.parse(content)
|
|
72
|
+
dependencies = parsed["dependencies"].is_a?(Hash) ? parsed["dependencies"] : {}
|
|
73
|
+
dependencies.map { |name, version| { "subject" => normalize_subject(name), "version" => safe_version(version) } }
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
raise "parse_error"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract_composer_json(content)
|
|
79
|
+
parsed = JSON.parse(content)
|
|
80
|
+
dependencies = parsed["require"].is_a?(Hash) ? parsed["require"] : {}
|
|
81
|
+
dependencies.map { |name, version| { "subject" => normalize_subject(name), "version" => safe_version(version) } }
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
raise "parse_error"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extract_requirements_txt(content)
|
|
87
|
+
content.lines.map(&:strip).reject { |line| line.empty? || line.start_with?("#", "-e", "--", "-r", "-f") }.map do |line|
|
|
88
|
+
name = line.split(/[<>=!\[\s]/, 2).first.to_s
|
|
89
|
+
version = line.match(/\A[^<>=!\[\s]+\s*(?:==|>=|<=|~=|>|<)\s*([^\s;]+)\z/)&.[](1)
|
|
90
|
+
{ "subject" => normalize_subject(name), "version" => safe_version(version) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_pom_xml(content)
|
|
95
|
+
document = REXML::Document.new(content)
|
|
96
|
+
REXML::XPath.match(document, "//dependency").map do |dependency|
|
|
97
|
+
group = dependency.elements["groupId"]&.text.to_s.strip
|
|
98
|
+
artifact = dependency.elements["artifactId"]&.text.to_s.strip
|
|
99
|
+
next if group.empty? || artifact.empty?
|
|
100
|
+
|
|
101
|
+
{ "subject" => normalize_subject("#{group}:#{artifact}"), "version" => safe_version(dependency.elements["version"]&.text) }
|
|
102
|
+
end.compact
|
|
103
|
+
rescue REXML::ParseException
|
|
104
|
+
raise "parse_error"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The grammar used here is deliberately small: direct and parenthesized
|
|
108
|
+
# require directives. Unsupported Go syntax becomes a parse failure instead
|
|
109
|
+
# of an incomplete-looking positive inventory.
|
|
110
|
+
def extract_go_mod(content)
|
|
111
|
+
entries = []
|
|
112
|
+
in_block = false
|
|
113
|
+
content.each_line do |line|
|
|
114
|
+
stripped = line.strip
|
|
115
|
+
next if stripped.empty? || stripped.start_with?("//")
|
|
116
|
+
|
|
117
|
+
if stripped == "require ("
|
|
118
|
+
in_block = true
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
if in_block && stripped == ")"
|
|
122
|
+
in_block = false
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
candidate = in_block ? stripped : stripped.sub(/\Arequire\s+/, "")
|
|
126
|
+
next if !in_block && candidate == stripped
|
|
127
|
+
|
|
128
|
+
match = candidate.match(/\A([^\s]+)\s+([^\s]+)(?:\s+\/\/.*)?\z/)
|
|
129
|
+
raise "parse_error" unless match
|
|
130
|
+
|
|
131
|
+
entries << { "subject" => normalize_subject(match[1]), "version" => safe_version(match[2]) }
|
|
132
|
+
end
|
|
133
|
+
raise "parse_error" if in_block
|
|
134
|
+
|
|
135
|
+
entries
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def git_capture(root, *args)
|
|
139
|
+
stdout, stderr, status = Open3.capture3("git", "-C", root, *args)
|
|
140
|
+
[stdout, stderr, status.success?]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def git_revision(root)
|
|
144
|
+
output, _error, success = git_capture(root, "rev-parse", "HEAD")
|
|
145
|
+
success ? output.strip : ""
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def git_paths(root, revision)
|
|
149
|
+
output, error, success = git_capture(root, "ls-tree", "-r", "--name-only", revision)
|
|
150
|
+
raise "git_tree_unavailable: #{error.strip}" unless success
|
|
151
|
+
|
|
152
|
+
output.lines.map(&:strip).reject(&:empty?).sort
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def git_content(root, revision, path)
|
|
156
|
+
output, error, success = git_capture(root, "show", "#{revision}:#{path}")
|
|
157
|
+
raise "git_show_failed: #{error.strip}" unless success
|
|
158
|
+
|
|
159
|
+
output
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def excluded_path?(path)
|
|
163
|
+
path.split("/").any? { |segment| EXCLUDED_ROOTS.include?(segment) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dependency_discovery(root, revision)
|
|
167
|
+
all_paths = git_paths(root, revision)
|
|
168
|
+
matching = all_paths.select { |path| MANIFEST_EXTRACTORS.key?(File.basename(path)) }
|
|
169
|
+
included, excluded = matching.partition { |path| !excluded_path?(path) }
|
|
170
|
+
files = excluded.map { |path| { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" } }
|
|
171
|
+
entries = []
|
|
172
|
+
included.each do |path|
|
|
173
|
+
extractor = MANIFEST_EXTRACTORS.fetch(File.basename(path))
|
|
174
|
+
begin
|
|
175
|
+
content = git_content(root, revision, path)
|
|
176
|
+
extract = send(extractor, content)
|
|
177
|
+
digest = sha256(content)
|
|
178
|
+
extract.each do |entry|
|
|
179
|
+
subject = normalize_subject(entry["subject"])
|
|
180
|
+
next if subject.empty?
|
|
181
|
+
|
|
182
|
+
entries << {
|
|
183
|
+
"subject" => subject,
|
|
184
|
+
"version" => entry["version"],
|
|
185
|
+
"evidence" => {
|
|
186
|
+
"source_type" => "manifest",
|
|
187
|
+
"path" => path,
|
|
188
|
+
"git_revision" => revision,
|
|
189
|
+
"sha256" => digest,
|
|
190
|
+
"extractor" => extractor.to_s
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
files << { "path" => path, "status" => "scanned", "extractor" => extractor.to_s, "sha256" => digest }
|
|
195
|
+
rescue StandardError => error
|
|
196
|
+
files << { "path" => path, "status" => "failed", "extractor" => extractor.to_s, "error" => error.message }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
[files.sort_by { |file| file["path"] }, entries]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A mini-program claim is emitted only when app.json is valid JSON and has
|
|
203
|
+
# the defining pages array. We deliberately do not infer web by default.
|
|
204
|
+
def platform_discovery(root, revision)
|
|
205
|
+
candidates = git_paths(root, revision).select { |path| File.basename(path) == "app.json" }
|
|
206
|
+
files = []
|
|
207
|
+
entries = []
|
|
208
|
+
candidates.each do |path|
|
|
209
|
+
if excluded_path?(path)
|
|
210
|
+
files << { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" }
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
begin
|
|
214
|
+
content = git_content(root, revision, path)
|
|
215
|
+
parsed = JSON.parse(content)
|
|
216
|
+
digest = sha256(content)
|
|
217
|
+
if parsed["pages"].is_a?(Array)
|
|
218
|
+
entries << {
|
|
219
|
+
"subject" => "miniprogram",
|
|
220
|
+
"evidence" => {
|
|
221
|
+
"source_type" => "manifest",
|
|
222
|
+
"path" => path,
|
|
223
|
+
"git_revision" => revision,
|
|
224
|
+
"sha256" => digest,
|
|
225
|
+
"extractor" => "miniprogram_app_json_v1"
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
files << { "path" => path, "status" => "scanned", "extractor" => "miniprogram_app_json_v1", "sha256" => digest }
|
|
229
|
+
else
|
|
230
|
+
files << { "path" => path, "status" => "ignored", "reason" => "not_a_miniprogram_manifest", "sha256" => digest }
|
|
231
|
+
end
|
|
232
|
+
rescue JSON::ParserError
|
|
233
|
+
files << { "path" => path, "status" => "failed", "extractor" => "miniprogram_app_json_v1", "error" => "parse_error" }
|
|
234
|
+
rescue StandardError => error
|
|
235
|
+
files << { "path" => path, "status" => "failed", "extractor" => "miniprogram_app_json_v1", "error" => error.message }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
[files.sort_by { |file| file["path"] }, entries]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def compose_path?(path)
|
|
242
|
+
File.basename(path).match?(%r{\A(?:docker-)?compose(?:[._-][A-Za-z0-9._-]+)?\.ya?ml\z}i)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Docker Compose is intentionally the first and only configuration adapter.
|
|
246
|
+
# It proves only that a committed descriptor declares a service name; image,
|
|
247
|
+
# environment, endpoint, and deployment state are never copied or inferred.
|
|
248
|
+
def compose_discovery(root, revision)
|
|
249
|
+
candidates = git_paths(root, revision).select { |path| compose_path?(path) }
|
|
250
|
+
files = []
|
|
251
|
+
entries = []
|
|
252
|
+
candidates.each do |path|
|
|
253
|
+
if excluded_path?(path)
|
|
254
|
+
files << { "path" => path, "status" => "excluded", "reason" => "third_party_or_build_output" }
|
|
255
|
+
next
|
|
256
|
+
end
|
|
257
|
+
begin
|
|
258
|
+
content = git_content(root, revision, path)
|
|
259
|
+
raise "file_too_large" if content.bytesize > MAX_COMPOSE_BYTES
|
|
260
|
+
# Compose files commonly use x-* anchors. Allow aliases only after a
|
|
261
|
+
# bounded read and with Psych safe loading; no values are projected.
|
|
262
|
+
parsed = YAML.safe_load(content, permitted_classes: [], aliases: true)
|
|
263
|
+
digest = sha256(content)
|
|
264
|
+
services = parsed.is_a?(Hash) ? parsed["services"] : nil
|
|
265
|
+
unless services.is_a?(Hash)
|
|
266
|
+
files << { "path" => path, "status" => "ignored", "reason" => "not_a_compose_services_descriptor", "sha256" => digest }
|
|
267
|
+
next
|
|
268
|
+
end
|
|
269
|
+
services.keys.map(&:to_s).sort.each do |name|
|
|
270
|
+
next if name.strip.empty?
|
|
271
|
+
|
|
272
|
+
entries << {
|
|
273
|
+
"subject" => "compose_service:#{normalize_subject(name)}",
|
|
274
|
+
"scope" => { "repository" => "repo-default", "deployment_descriptor" => path },
|
|
275
|
+
"evidence" => {
|
|
276
|
+
"source_type" => "config_identifier",
|
|
277
|
+
"path" => path,
|
|
278
|
+
"git_revision" => revision,
|
|
279
|
+
"sha256" => digest,
|
|
280
|
+
"locator" => "services.#{name}",
|
|
281
|
+
"extractor" => "docker_compose_services_v1"
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
files << { "path" => path, "status" => "scanned", "extractor" => "docker_compose_services_v1", "sha256" => digest }
|
|
286
|
+
rescue Psych::Exception
|
|
287
|
+
files << { "path" => path, "status" => "failed", "extractor" => "docker_compose_services_v1", "error" => "parse_error" }
|
|
288
|
+
rescue StandardError => error
|
|
289
|
+
files << { "path" => path, "status" => "failed", "extractor" => "docker_compose_services_v1", "error" => error.message }
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
[files.sort_by { |file| file["path"] }, entries]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def stable_evidence(entries)
|
|
296
|
+
entries.sort_by { |entry| [entry["path"], entry["extractor"], entry["sha256"]] }.uniq
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def claim_id(kind, assertion, subject, scope)
|
|
300
|
+
"CTX-#{sha256(canonical_json({ "kind" => kind, "assertion" => assertion, "subject" => subject, "scope" => scope }))[0, 16]}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def grouped_claims(kind, assertion, default_scope, entries)
|
|
304
|
+
entries.group_by { |entry| [entry.fetch("subject"), canonical_json(entry.fetch("scope", default_scope))] }.map do |(subject, scope_json), group|
|
|
305
|
+
scope = JSON.parse(scope_json)
|
|
306
|
+
evidence = stable_evidence(group.map do |entry|
|
|
307
|
+
item = entry.fetch("evidence").dup
|
|
308
|
+
item["declared_version"] = entry["version"] if entry.key?("version") && !entry["version"].nil?
|
|
309
|
+
item
|
|
310
|
+
end)
|
|
311
|
+
{
|
|
312
|
+
"id" => claim_id(kind, assertion, subject, scope),
|
|
313
|
+
"kind" => kind,
|
|
314
|
+
"subject" => subject,
|
|
315
|
+
"assertion" => assertion,
|
|
316
|
+
"scope" => scope,
|
|
317
|
+
"evidence" => evidence,
|
|
318
|
+
"collection_state" => "proven"
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def coverage(subject_class, scope, rule, status, files: [], reason: nil)
|
|
324
|
+
result = {
|
|
325
|
+
"subject_class" => subject_class,
|
|
326
|
+
"scope" => scope,
|
|
327
|
+
"rule" => rule,
|
|
328
|
+
"status" => status,
|
|
329
|
+
"files" => files
|
|
330
|
+
}
|
|
331
|
+
result["reason"] = reason if reason
|
|
332
|
+
result
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def snapshot_for(root, repository_revision)
|
|
336
|
+
scope = { "repository" => "repo-default" }
|
|
337
|
+
if repository_revision.empty?
|
|
338
|
+
failure = { "path" => "<repository>", "status" => "failed", "error" => "git_revision_unavailable" }
|
|
339
|
+
return [
|
|
340
|
+
[],
|
|
341
|
+
[
|
|
342
|
+
coverage("dependency_declaration", scope, "manifests-v2", "incomplete", files: [failure]),
|
|
343
|
+
coverage("platform_declaration", scope, "miniprogram-app-json-v1", "unknown", reason: "git_revision_unavailable"),
|
|
344
|
+
coverage("resource_binding", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
|
|
345
|
+
coverage("integration_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
|
|
346
|
+
coverage("job_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled")
|
|
347
|
+
],
|
|
348
|
+
{ "dependency_declaration" => [failure], "platform_declaration" => [] }
|
|
349
|
+
]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
begin
|
|
353
|
+
dependency_files, dependency_entries = dependency_discovery(root, repository_revision)
|
|
354
|
+
rescue StandardError => error
|
|
355
|
+
dependency_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
|
|
356
|
+
dependency_entries = []
|
|
357
|
+
end
|
|
358
|
+
begin
|
|
359
|
+
platform_files, platform_entries = platform_discovery(root, repository_revision)
|
|
360
|
+
rescue StandardError => error
|
|
361
|
+
platform_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
|
|
362
|
+
platform_entries = []
|
|
363
|
+
end
|
|
364
|
+
begin
|
|
365
|
+
resource_files, resource_entries = compose_discovery(root, repository_revision)
|
|
366
|
+
rescue StandardError => error
|
|
367
|
+
resource_files = [{ "path" => "<repository>", "status" => "failed", "error" => error.message }]
|
|
368
|
+
resource_entries = []
|
|
369
|
+
end
|
|
370
|
+
dependency_status = dependency_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "complete"
|
|
371
|
+
platform_status = platform_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "partial"
|
|
372
|
+
resource_status = resource_files.any? { |file| file["status"] == "failed" } ? "incomplete" : "partial"
|
|
373
|
+
claims = grouped_claims("dependency_declaration", "declared", scope, dependency_entries) +
|
|
374
|
+
grouped_claims("platform_declaration", "declared", scope, platform_entries) +
|
|
375
|
+
grouped_claims("resource_binding", "declared", scope, resource_entries)
|
|
376
|
+
coverages = [
|
|
377
|
+
coverage("dependency_declaration", scope, "manifests-v2", dependency_status, files: dependency_files),
|
|
378
|
+
coverage("platform_declaration", scope, "miniprogram-app-json-v1", platform_status, files: platform_files, reason: "only_miniprogram_app_json_is_supported"),
|
|
379
|
+
coverage("resource_binding", scope, "docker-compose-services-v1", resource_status, files: resource_files, reason: "only_compose_service_names_are_supported"),
|
|
380
|
+
coverage("integration_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled"),
|
|
381
|
+
coverage("job_use", scope, "not_enabled", "unknown", reason: "code_index_projection_not_enabled")
|
|
382
|
+
]
|
|
383
|
+
[claims, coverages, {
|
|
384
|
+
"dependency_declaration" => dependency_files,
|
|
385
|
+
"platform_declaration" => platform_files,
|
|
386
|
+
"resource_binding" => resource_files
|
|
387
|
+
}]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def snapshot_paths(root)
|
|
391
|
+
Dir.glob(File.join(root, HISTORY_DIR, "*.json")).sort_by { |path| File.basename(path, ".json").to_i }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def next_snapshot_revision(root)
|
|
395
|
+
latest = snapshot_paths(root).map { |path| File.basename(path, ".json").to_i }.max || 0
|
|
396
|
+
latest + 1
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def matching_snapshot(root, repository_revision, input_fingerprint)
|
|
400
|
+
snapshot_paths(root).each do |path|
|
|
401
|
+
snapshot = JSON.parse(File.read(path))
|
|
402
|
+
next unless snapshot["schema_version"] == SCHEMA_VERSION
|
|
403
|
+
next unless snapshot["collector_version"] == COLLECTOR_VERSION
|
|
404
|
+
next unless snapshot["repository_revision"] == repository_revision
|
|
405
|
+
next unless snapshot["input_fingerprint"] == input_fingerprint
|
|
406
|
+
|
|
407
|
+
return [snapshot, path]
|
|
408
|
+
rescue JSON::ParserError
|
|
409
|
+
raise ArgumentError, "static runtime-context history is invalid JSON: #{path}"
|
|
410
|
+
end
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def relative_path(root, path)
|
|
415
|
+
path.sub("#{root}/", "")
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def collect(root)
|
|
419
|
+
repository_revision = git_revision(root)
|
|
420
|
+
claims, coverages, discovery = snapshot_for(root, repository_revision)
|
|
421
|
+
claims = claims.sort_by { |claim| [claim["kind"], claim["subject"], claim["id"]] }
|
|
422
|
+
input_fingerprint = sha256(canonical_json({
|
|
423
|
+
"collector_version" => COLLECTOR_VERSION,
|
|
424
|
+
"repository_revision" => repository_revision,
|
|
425
|
+
"coverage" => coverages,
|
|
426
|
+
"claims" => claims,
|
|
427
|
+
"discovery" => discovery
|
|
428
|
+
}))
|
|
429
|
+
if (existing = matching_snapshot(root, repository_revision, input_fingerprint))
|
|
430
|
+
snapshot, path = existing
|
|
431
|
+
DevflowControlIO.atomic_write(File.join(root, POINTER), JSON.pretty_generate({ "current_revision" => snapshot["snapshot_revision"], "path" => relative_path(root, path) }) + "\n")
|
|
432
|
+
DevflowControlIO.append_audit(File.join(root, AUDIT_PATH), {
|
|
433
|
+
"event" => "snapshot_reused", "snapshot_revision" => snapshot["snapshot_revision"], "repository_revision" => repository_revision,
|
|
434
|
+
"input_fingerprint" => input_fingerprint, "at" => Time.now.utc.iso8601
|
|
435
|
+
})
|
|
436
|
+
return [snapshot, true]
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
revision = next_snapshot_revision(root)
|
|
440
|
+
snapshot = {
|
|
441
|
+
"schema_version" => SCHEMA_VERSION,
|
|
442
|
+
"context_id" => "repository-static",
|
|
443
|
+
"snapshot_revision" => revision,
|
|
444
|
+
"repository_revision" => repository_revision,
|
|
445
|
+
"collected_at" => Time.now.utc.iso8601,
|
|
446
|
+
"collector_version" => COLLECTOR_VERSION,
|
|
447
|
+
"input_fingerprint" => input_fingerprint,
|
|
448
|
+
"claims" => claims,
|
|
449
|
+
"coverage" => coverages,
|
|
450
|
+
"discovery" => discovery
|
|
451
|
+
}
|
|
452
|
+
history = File.join(root, HISTORY_DIR, "#{revision}.json")
|
|
453
|
+
DevflowControlIO.atomic_create(history, JSON.pretty_generate(snapshot) + "\n")
|
|
454
|
+
DevflowControlIO.atomic_write(File.join(root, POINTER), JSON.pretty_generate({ "current_revision" => revision, "path" => relative_path(root, history) }) + "\n")
|
|
455
|
+
DevflowControlIO.append_audit(File.join(root, AUDIT_PATH), {
|
|
456
|
+
"event" => "snapshot_collected", "snapshot_revision" => revision, "repository_revision" => repository_revision,
|
|
457
|
+
"input_fingerprint" => input_fingerprint, "claims_count" => claims.length, "at" => Time.now.utc.iso8601
|
|
458
|
+
})
|
|
459
|
+
DevflowControlIO.register_control_artifact(root, {
|
|
460
|
+
"path" => relative_path(root, history), "type" => "static_runtime_context_snapshot",
|
|
461
|
+
"produced_by" => "core", "snapshot_revision" => revision, "repository_revision" => repository_revision,
|
|
462
|
+
"input_fingerprint" => input_fingerprint
|
|
463
|
+
})
|
|
464
|
+
[snapshot, false]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def inspect(root)
|
|
468
|
+
pointer_path = File.join(root, POINTER)
|
|
469
|
+
return { "ok" => true, "pointer_exists" => false, "current_revision" => 0 } unless File.exist?(pointer_path)
|
|
470
|
+
|
|
471
|
+
pointer = JSON.parse(File.read(pointer_path))
|
|
472
|
+
history = File.join(root, pointer.fetch("path"))
|
|
473
|
+
snapshot = JSON.parse(File.read(history))
|
|
474
|
+
{
|
|
475
|
+
"ok" => true,
|
|
476
|
+
"pointer_exists" => true,
|
|
477
|
+
"current_revision" => snapshot["snapshot_revision"],
|
|
478
|
+
"repository_revision" => snapshot["repository_revision"],
|
|
479
|
+
"claims_count" => Array(snapshot["claims"]).length,
|
|
480
|
+
"path" => pointer["path"]
|
|
481
|
+
}
|
|
482
|
+
rescue JSON::ParserError, KeyError => error
|
|
483
|
+
raise ArgumentError, "static runtime-context pointer is invalid: #{error.message}"
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if __FILE__ == $PROGRAM_NAME
|
|
488
|
+
options = { json: false }
|
|
489
|
+
command = ARGV.shift
|
|
490
|
+
OptionParser.new do |o|
|
|
491
|
+
o.on("--json") { options[:json] = true }
|
|
492
|
+
end.parse!
|
|
493
|
+
root = Dir.pwd
|
|
494
|
+
lock = command == "collect" ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
495
|
+
begin
|
|
496
|
+
result =
|
|
497
|
+
case command
|
|
498
|
+
when "collect"
|
|
499
|
+
snapshot, reused = StaticRuntimeContextCollector.collect(root)
|
|
500
|
+
{
|
|
501
|
+
"ok" => true,
|
|
502
|
+
"snapshot_revision" => snapshot["snapshot_revision"],
|
|
503
|
+
"repository_revision" => snapshot["repository_revision"],
|
|
504
|
+
"claims_count" => snapshot["claims"].length,
|
|
505
|
+
"reused" => reused
|
|
506
|
+
}
|
|
507
|
+
when "inspect"
|
|
508
|
+
StaticRuntimeContextCollector.inspect(root)
|
|
509
|
+
else
|
|
510
|
+
raise ArgumentError, "command must be collect or inspect"
|
|
511
|
+
end
|
|
512
|
+
rescue StandardError => error
|
|
513
|
+
result = { "ok" => false, "reason" => "static_runtime_context_collector_failed", "message" => error.message }
|
|
514
|
+
ensure
|
|
515
|
+
DevflowControlIO.release_lock(lock)
|
|
516
|
+
end
|
|
517
|
+
puts JSON.pretty_generate(result)
|
|
518
|
+
end
|
|
@@ -7,6 +7,7 @@ require "yaml"
|
|
|
7
7
|
require_relative "control_io"
|
|
8
8
|
require_relative "phase_ownership"
|
|
9
9
|
require_relative "requirement_graph"
|
|
10
|
+
require_relative "architecture_feasibility"
|
|
10
11
|
|
|
11
12
|
module WorkflowStateValidator
|
|
12
13
|
REQUIRED_STATE_FIELDS = %w[
|
|
@@ -78,7 +79,7 @@ module WorkflowStateValidator
|
|
|
78
79
|
validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
|
|
79
80
|
validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
80
81
|
graph_gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
81
|
-
governed_steps = %w[requirement-baseline api-contract integration-verify change-plan execute verify]
|
|
82
|
+
governed_steps = %w[requirement-baseline api-contract architecture-feasibility integration-verify change-plan execute verify]
|
|
82
83
|
# A historical completed state is never rolled back solely because it has
|
|
83
84
|
# no v2 graph/evidence. Its first new governed step, however, must satisfy
|
|
84
85
|
# the new contract before it can advance.
|
|
@@ -378,6 +379,8 @@ module WorkflowStateValidator
|
|
|
378
379
|
validate_requirement_feasibility_review_gate(issues, step, data)
|
|
379
380
|
validate_requirement_baseline_gate(issues, step, data)
|
|
380
381
|
validate_api_contract_gate(issues, step, data)
|
|
382
|
+
validate_architecture_feasibility_gate(issues, step, data)
|
|
383
|
+
validate_architecture_consumption_gate(issues, root, state, step, data)
|
|
381
384
|
validate_api_call_guide_gate(issues, step, data)
|
|
382
385
|
validate_frontend_tech_spec_gate(issues, step, data)
|
|
383
386
|
validate_api_supplement_gate(issues, step, data)
|
|
@@ -512,6 +515,85 @@ module WorkflowStateValidator
|
|
|
512
515
|
end
|
|
513
516
|
end
|
|
514
517
|
|
|
518
|
+
# P2 is intentionally graduated: only a structurally-derived full
|
|
519
|
+
# assessment fail-closes. Lightweight incomplete work remains visible to its
|
|
520
|
+
# technical-spec consumers rather than forcing every small feature through
|
|
521
|
+
# an invented NFR exercise.
|
|
522
|
+
def validate_architecture_feasibility_gate(issues, step, data)
|
|
523
|
+
return unless step["id"] == "architecture-feasibility"
|
|
524
|
+
|
|
525
|
+
level = data["assessment_level"]
|
|
526
|
+
status = data["assessment_status"]
|
|
527
|
+
comparison = data["comparison_status"]
|
|
528
|
+
unless %w[not_triggered lightweight full].include?(level)
|
|
529
|
+
issues << issue("error", "architecture_assessment_level_invalid", "Architecture assessment level is invalid", step: step["id"], level: level)
|
|
530
|
+
return
|
|
531
|
+
end
|
|
532
|
+
unless %w[ready incomplete].include?(status)
|
|
533
|
+
issues << issue("error", "architecture_assessment_status_invalid", "Architecture assessment status is invalid", step: step["id"], status: status)
|
|
534
|
+
return
|
|
535
|
+
end
|
|
536
|
+
unless %w[not_required missing awaiting_user_confirmation missing_risk awaiting_risk_confirmation confirmed].include?(comparison)
|
|
537
|
+
issues << issue("error", "architecture_comparison_status_invalid", "Architecture comparison status is invalid", step: step["id"], comparison_status: comparison)
|
|
538
|
+
return
|
|
539
|
+
end
|
|
540
|
+
return unless level == "full"
|
|
541
|
+
return if status == "ready" && comparison == "confirmed"
|
|
542
|
+
|
|
543
|
+
issues << issue(
|
|
544
|
+
"error",
|
|
545
|
+
"architecture_full_assessment_incomplete",
|
|
546
|
+
"A full architecture assessment requires ready inputs plus confirmed decision and risk treatment before technical specifications.",
|
|
547
|
+
step: step["id"],
|
|
548
|
+
assessment_status: status,
|
|
549
|
+
comparison_status: comparison
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# A technical spec does not merely claim that it considered architecture. It
|
|
554
|
+
# pins the exact Core-derived brief it consumed. Compare the pin both to the
|
|
555
|
+
# brief and to the current graph/context so an upstream edit cannot leave a
|
|
556
|
+
# convincing but obsolete spec looking fresh.
|
|
557
|
+
def validate_architecture_consumption_gate(issues, root, state, step, data)
|
|
558
|
+
return unless %w[frontend-tech-spec backend-tech-spec].include?(step["id"])
|
|
559
|
+
|
|
560
|
+
feature = state["current_feature"]
|
|
561
|
+
brief_path = File.join(root, ".planning", "features", feature, "architecture-brief.meta.json")
|
|
562
|
+
unless File.file?(brief_path)
|
|
563
|
+
issues << issue("error", "architecture_brief_meta_missing", "Technical spec cannot be validated without the architecture brief meta", step: step["id"], path: brief_path)
|
|
564
|
+
return
|
|
565
|
+
end
|
|
566
|
+
brief = JSON.parse(File.read(brief_path))
|
|
567
|
+
expected = {
|
|
568
|
+
"architecture_brief_revision" => brief["brief_revision"],
|
|
569
|
+
"architecture_graph_revision" => brief["graph_revision"],
|
|
570
|
+
"architecture_static_context_snapshot_revision" => brief["static_context_snapshot_revision"],
|
|
571
|
+
"architecture_comparison_status" => brief["comparison_status"]
|
|
572
|
+
}
|
|
573
|
+
expected.each do |field, value|
|
|
574
|
+
next if data[field] == value
|
|
575
|
+
|
|
576
|
+
issues << issue("error", "architecture_consumption_pin_mismatch", "Technical spec architecture pin differs from the Core-derived brief", step: step["id"], field: field, spec_value: data[field], brief_value: value)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
graph, = RequirementGraph.load_graph(root, feature)
|
|
580
|
+
if brief["graph_revision"] != graph["revision"]
|
|
581
|
+
issues << issue("error", "architecture_brief_graph_stale", "Architecture brief was derived from a stale graph revision", step: step["id"], brief_revision: brief["graph_revision"], graph_revision: graph["revision"])
|
|
582
|
+
end
|
|
583
|
+
current_context = ArchitectureFeasibility.safe_static_context(root)
|
|
584
|
+
current_context_revision = current_context["available"] ? current_context["snapshot_revision"] : nil
|
|
585
|
+
if brief["static_context_snapshot_revision"] != current_context_revision
|
|
586
|
+
issues << issue("error", "architecture_brief_context_stale", "Architecture brief was derived from a stale static-context snapshot", step: step["id"], brief_revision: brief["static_context_snapshot_revision"], context_revision: current_context_revision)
|
|
587
|
+
end
|
|
588
|
+
if brief["assessment_level"] == "full" && brief["comparison_status"] != "confirmed"
|
|
589
|
+
issues << issue("error", "architecture_brief_full_unconfirmed", "Technical spec cannot consume an unconfirmed full architecture assessment", step: step["id"], comparison_status: brief["comparison_status"])
|
|
590
|
+
end
|
|
591
|
+
rescue JSON::ParserError => error
|
|
592
|
+
issues << issue("error", "architecture_brief_meta_invalid", error.message, step: step["id"], path: brief_path)
|
|
593
|
+
rescue ArgumentError => error
|
|
594
|
+
issues << issue("error", "architecture_brief_graph_invalid", error.message, step: step["id"])
|
|
595
|
+
end
|
|
596
|
+
|
|
515
597
|
def validate_api_call_guide_gate(issues, step, data)
|
|
516
598
|
return unless step["id"] == "api-call-guide"
|
|
517
599
|
|