@guanlin4924/devflow-pi 0.1.15 → 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 +425 -54
- 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 +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +646 -0
- 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 +140 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +88 -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
- package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
- package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
- package/src/cli.ts +2 -1
- package/src/runtime.ts +2 -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
|