@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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +425 -54
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +3 -2
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_assessment.rb +458 -0
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +256 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +155 -0
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +176 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +46 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +94 -0
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +2 -1
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +646 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/runtime_context_collector.rb +210 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/stale_check.rb +56 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/static_runtime_context_collector.rb +518 -0
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +140 -0
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workload_constraint.rb +57 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +88 -2
  19. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -2
  20. package/plugins/devflow-core/skills/workflow-project-context/SKILL.md +17 -1
  21. package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +37 -0
  22. package/plugins/devflow-design/skills/workflow-architecture-feasibility/agents/openai.yaml +4 -0
  23. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +7 -2
  24. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +6 -1
  25. package/plugins/devflow-requirements/skills/requirement-feasibility-review/SKILL.md +4 -0
  26. package/plugins/devflow-requirements/skills/workflow-domain-modeling/SKILL.md +4 -0
  27. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +4 -0
  28. package/plugins/devflow-requirements/skills/workflow-requirement-triage/SKILL.md +4 -0
  29. package/src/cli.ts +2 -1
  30. 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