@guanlin4924/devflow-pi 0.1.40 → 0.1.48
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/bin/apply-opencode-go-session-hotfix.mjs +61 -0
- package/package.json +2 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +609 -42
- 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 +143 -28
- 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 -1
- 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 +13 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
- 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 +10 -1
- 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/templates/feature-development.yaml +106 -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 +53 -22
|
@@ -27,6 +27,7 @@ options = {
|
|
|
27
27
|
expected_revision: nil,
|
|
28
28
|
json: false,
|
|
29
29
|
runtime_confirmed: false,
|
|
30
|
+
confirm_test_prerequisites: false,
|
|
30
31
|
parallel_action: nil,
|
|
31
32
|
parallel_branch_id: nil,
|
|
32
33
|
parallel_attempt: nil,
|
|
@@ -48,6 +49,7 @@ OptionParser.new do |opts|
|
|
|
48
49
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
49
50
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
50
51
|
opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
|
|
52
|
+
opts.on("--confirm-test-prerequisites", "Confirm displayed CP-3 prerequisite candidates in the same atomic freeze") { options[:confirm_test_prerequisites] = true }
|
|
51
53
|
opts.on("--parallel-action ACTION", %w[start complete fail retry join]) { |value| options[:parallel_action] = value }
|
|
52
54
|
opts.on("--parallel-branch-id ID", "Branch id for a parallel transition") { |value| options[:parallel_branch_id] = value }
|
|
53
55
|
opts.on("--parallel-task-id ID", "OMP task id to bind to a branch") { |value| options[:parallel_task_id] = value }
|
|
@@ -94,6 +96,8 @@ def transition_gate_contract(workflow, step)
|
|
|
94
96
|
window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
|
|
95
97
|
end
|
|
96
98
|
allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
|
|
99
|
+
step_order = step["order"].to_i
|
|
100
|
+
immediate_prior = Array(workflow["steps"]).select { |candidate| candidate.is_a?(Hash) && candidate["order"].to_i < step_order }.max_by { |candidate| candidate["order"].to_i }
|
|
97
101
|
reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
|
|
98
102
|
next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
|
|
99
103
|
entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
|
|
@@ -104,6 +108,7 @@ def transition_gate_contract(workflow, step)
|
|
|
104
108
|
"topology" => {
|
|
105
109
|
"allowed_tracks" => allowed_tracks,
|
|
106
110
|
"allowed_predecessors" => Array(step["depends_on"]),
|
|
111
|
+
"allowed_intervening_predecessors" => immediate_prior ? [immediate_prior["id"]] : [],
|
|
107
112
|
"allowed_reentry" => reentries,
|
|
108
113
|
"checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
|
|
109
114
|
},
|
|
@@ -465,18 +470,59 @@ def join_parallel_window(root, state, workflow, current_revision)
|
|
|
465
470
|
branch_steps = branches.map { |branch| branch.fetch("workflow_step").to_s }
|
|
466
471
|
state["completed_steps"] = (Array(state["completed_steps"]) + branch_steps).uniq
|
|
467
472
|
state["pending_steps"] = Array(state["pending_steps"]) - branch_steps
|
|
468
|
-
|
|
473
|
+
window = workflow.fetch("parallel_windows").find { |candidate| candidate["id"].to_s == record["workflow_window"].to_s }
|
|
474
|
+
raise ArgumentError, "parallel workflow window not found" unless window
|
|
475
|
+
join_step_id = window.fetch("join_step")
|
|
476
|
+
join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == join_step_id.to_s }
|
|
477
|
+
join_predecessors = Array(join_step && join_step["depends_on"]).map(&:to_s)
|
|
478
|
+
# The join step is only becoming current here; it has not completed yet.
|
|
479
|
+
# Keep last_completed_step on an actual branch predecessor so the next
|
|
480
|
+
# GateContract evaluation does not reject the join step as its own previous
|
|
481
|
+
# step. Prefer the final declared dependency for deterministic behavior.
|
|
482
|
+
last_predecessor = join_predecessors.reverse.find { |step_id| branch_steps.include?(step_id) }
|
|
483
|
+
state["last_completed_step"] = last_predecessor || branch_steps.last || state["last_completed_step"]
|
|
469
484
|
state.delete("parallel_run_ref")
|
|
470
485
|
record["status"] = "completed"
|
|
471
486
|
record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
|
|
472
487
|
record["updated_at"] = Time.now.utc.iso8601
|
|
473
|
-
state["current_step"] =
|
|
488
|
+
state["current_step"] = join_step_id
|
|
474
489
|
state["status"] = "in_progress"
|
|
475
490
|
state["state_updated_at"] = Date.today.to_s
|
|
476
491
|
state["revision"] = current_revision + 1
|
|
477
492
|
[state, record, "Parallel window joined; branch steps merged and parallel_run_ref cleared."]
|
|
478
493
|
end
|
|
479
494
|
|
|
495
|
+
# Releases states written by the pre-fix join implementation. That version set
|
|
496
|
+
# last_completed_step to the join step while leaving the join step pending,
|
|
497
|
+
# making GateContract reject the very next complete-step call. The signature
|
|
498
|
+
# below is deliberately narrow so unrelated historical state is never changed.
|
|
499
|
+
def repair_legacy_parallel_join_predecessor!(state, workflow)
|
|
500
|
+
return nil if state["parallel_run_ref"]
|
|
501
|
+
|
|
502
|
+
current_step = state["current_step"].to_s
|
|
503
|
+
completed = Array(state["completed_steps"]).map(&:to_s)
|
|
504
|
+
return nil if current_step.empty? || completed.include?(current_step)
|
|
505
|
+
return nil unless state["last_completed_step"].to_s == current_step
|
|
506
|
+
|
|
507
|
+
window = Array(workflow["parallel_windows"]).find { |candidate| candidate["join_step"].to_s == current_step }
|
|
508
|
+
return nil unless window
|
|
509
|
+
|
|
510
|
+
branch_steps = if window["branches"].is_a?(Hash)
|
|
511
|
+
window["branches"].values.flatten.map(&:to_s)
|
|
512
|
+
else
|
|
513
|
+
Array(window["branch_steps"]).map(&:to_s)
|
|
514
|
+
end
|
|
515
|
+
return nil if branch_steps.empty? || branch_steps.any? { |step_id| !completed.include?(step_id) }
|
|
516
|
+
|
|
517
|
+
join_step = Array(workflow["steps"]).find { |step| step["id"].to_s == current_step }
|
|
518
|
+
predecessor = Array(join_step && join_step["depends_on"]).map(&:to_s).reverse.find { |step_id| completed.include?(step_id) }
|
|
519
|
+
predecessor ||= branch_steps.reverse.find { |step_id| completed.include?(step_id) }
|
|
520
|
+
return nil unless predecessor
|
|
521
|
+
|
|
522
|
+
state["last_completed_step"] = predecessor
|
|
523
|
+
predecessor
|
|
524
|
+
end
|
|
525
|
+
|
|
480
526
|
def fail_result(reason, details = {})
|
|
481
527
|
{ "ok" => false, "reason" => reason, "details" => details }
|
|
482
528
|
end
|
|
@@ -540,6 +586,32 @@ rescue JSON::ParserError => error
|
|
|
540
586
|
{ "ok" => false, "errors" => ["cross-track checker returned invalid JSON: #{error.message}"] }
|
|
541
587
|
end
|
|
542
588
|
|
|
589
|
+
def business_cross_model_checker_enabled?(workflow, step_id)
|
|
590
|
+
Array(workflow.dig("governance", "step_checkers", step_id)).map(&:to_s).include?("business-cross-model-check")
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def business_cross_model_check(root, feature)
|
|
594
|
+
script = File.join(__dir__, "business_cross_model_check.py")
|
|
595
|
+
business = ".planning/business-model/#{feature}/canonical.json"
|
|
596
|
+
wireframe = ".planning/features/#{feature}/canonical.json"
|
|
597
|
+
unless File.file?(File.join(root, business))
|
|
598
|
+
return { "valid" => false, "errors" => [{ "code" => "BUSINESS_CANONICAL_MISSING", "path" => business }] }
|
|
599
|
+
end
|
|
600
|
+
command = ["python3", script, "--business-canonical", business]
|
|
601
|
+
command += ["--wireframe", wireframe] if File.file?(File.join(root, wireframe))
|
|
602
|
+
claims = ".planning/features/#{feature}/business-claims.yaml"
|
|
603
|
+
command += ["--claims", claims] if File.file?(File.join(root, claims))
|
|
604
|
+
traceability = ".planning/requirements/#{feature}/traceability.json"
|
|
605
|
+
command += ["--traceability", traceability] if File.file?(File.join(root, traceability))
|
|
606
|
+
stdout, stderr, status = Open3.capture3(*command, "--json", chdir: root)
|
|
607
|
+
payload = JSON.parse(stdout)
|
|
608
|
+
return payload if status.success? && payload.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
payload.is_a?(Hash) ? payload : { "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_FAILED", "message" => stderr.strip }] }
|
|
611
|
+
rescue JSON::ParserError => error
|
|
612
|
+
{ "valid" => false, "errors" => [{ "code" => "BUSINESS_CROSS_MODEL_CHECK_INVALID", "message" => error.message }] }
|
|
613
|
+
end
|
|
614
|
+
|
|
543
615
|
def branch_machine_evidence(root, state, branch)
|
|
544
616
|
script = File.join(__dir__, "machine_evidence.rb")
|
|
545
617
|
step = branch.fetch("workflow_step").to_s
|
|
@@ -668,9 +740,16 @@ result =
|
|
|
668
740
|
actions = []
|
|
669
741
|
action_description = nil
|
|
670
742
|
parallel_run_to_write = nil
|
|
743
|
+
test_suite_freeze_to_write = nil
|
|
671
744
|
next_intent_requested = false
|
|
672
745
|
current_revision = Integer(state.fetch("revision", 0))
|
|
673
746
|
|
|
747
|
+
if (repaired_predecessor = repair_legacy_parallel_join_predecessor!(state, workflow))
|
|
748
|
+
changed = true
|
|
749
|
+
actions << "legacy_parallel_join_predecessor_repaired"
|
|
750
|
+
action_description = "Repaired legacy parallel join predecessor: #{repaired_predecessor}."
|
|
751
|
+
end
|
|
752
|
+
|
|
674
753
|
if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
|
|
675
754
|
result = fail_result(
|
|
676
755
|
"stale_revision",
|
|
@@ -772,6 +851,12 @@ result =
|
|
|
772
851
|
result = fail_result("cross_track_consistency_failed", "checker" => checker)
|
|
773
852
|
end
|
|
774
853
|
end
|
|
854
|
+
if result.nil? && business_cross_model_checker_enabled?(workflow, "integration-verify")
|
|
855
|
+
checker = business_cross_model_check(root, state.fetch("current_feature"))
|
|
856
|
+
unless checker["valid"] == true
|
|
857
|
+
result = fail_result("business_cross_model_check_failed", "checker" => checker)
|
|
858
|
+
end
|
|
859
|
+
end
|
|
775
860
|
if result.nil?
|
|
776
861
|
state, parallel_run_to_write, action_description = join_parallel_window(root, state, workflow, current_revision)
|
|
777
862
|
changed = true
|
|
@@ -972,21 +1057,25 @@ result =
|
|
|
972
1057
|
unless candidate_check["ok"]
|
|
973
1058
|
result = fail_result("acceptance_test_suite_candidate_binding_failed", "test_suite" => candidate_check)
|
|
974
1059
|
else
|
|
975
|
-
frozen = RequirementGraph.freeze_test_cases!(
|
|
1060
|
+
frozen = RequirementGraph.freeze_test_cases!(
|
|
1061
|
+
root,
|
|
1062
|
+
graph,
|
|
1063
|
+
"CP-3: state revision #{current_revision} approved",
|
|
1064
|
+
confirm_prerequisites: options[:confirm_test_prerequisites] && options[:runtime_confirmed],
|
|
1065
|
+
persist: false
|
|
1066
|
+
)
|
|
976
1067
|
unless frozen["ok"]
|
|
977
1068
|
result = fail_result("acceptance_test_suite_freeze_failed", "test_suite" => frozen)
|
|
978
1069
|
else
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
result = fail_result("acceptance_test_suite_freeze_attestation_failed", "message" => error.message)
|
|
989
|
-
end
|
|
1070
|
+
actions << "acceptance_test_prerequisites_confirmed" unless Array(frozen["confirmed_prerequisite_ids"]).empty?
|
|
1071
|
+
test_suite_freeze_to_write = {
|
|
1072
|
+
"feature" => feature,
|
|
1073
|
+
"state_revision" => current_revision,
|
|
1074
|
+
"graph" => graph,
|
|
1075
|
+
"candidate_check" => candidate_check,
|
|
1076
|
+
"frozen" => frozen
|
|
1077
|
+
}
|
|
1078
|
+
actions << "acceptance_test_suite_frozen"
|
|
990
1079
|
end
|
|
991
1080
|
end
|
|
992
1081
|
end
|
|
@@ -1102,23 +1191,49 @@ result =
|
|
|
1102
1191
|
if !ownership["ok"]
|
|
1103
1192
|
result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
|
|
1104
1193
|
else
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
root
|
|
1115
|
-
|
|
1194
|
+
if test_suite_freeze_to_write
|
|
1195
|
+
pending_freeze = test_suite_freeze_to_write
|
|
1196
|
+
RequirementGraph.persist_test_suite_freeze!(
|
|
1197
|
+
root,
|
|
1198
|
+
pending_freeze.fetch("graph"),
|
|
1199
|
+
pending_freeze.fetch("frozen").fetch("audit_event")
|
|
1200
|
+
)
|
|
1201
|
+
pending_freeze.fetch("frozen")["tests_view"] = pending_freeze.fetch("graph").dig("views", "tests")
|
|
1202
|
+
write_test_suite_freeze_attestation(
|
|
1203
|
+
root,
|
|
1204
|
+
pending_freeze.fetch("feature"),
|
|
1205
|
+
pending_freeze.fetch("state_revision"),
|
|
1206
|
+
pending_freeze.fetch("candidate_check"),
|
|
1207
|
+
pending_freeze.fetch("frozen")
|
|
1116
1208
|
)
|
|
1117
|
-
|
|
1118
|
-
|
|
1209
|
+
frozen_check = test_task_binding_check(root, pending_freeze.fetch("feature"), "frozen")
|
|
1210
|
+
unless frozen_check["ok"]
|
|
1211
|
+
result = fail_result("acceptance_test_suite_freeze_attestation_invalid", "test_suite" => frozen_check)
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
unless result
|
|
1215
|
+
sync_artifact_registry(root, workflow, state, registry: candidate_registry)
|
|
1216
|
+
artifact_by_path = load_artifact_registry(root, workflow)
|
|
1217
|
+
write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
|
|
1218
|
+
ParallelRun.write(root, state.fetch("current_feature"), parallel_run_to_write) if parallel_run_to_write
|
|
1219
|
+
ParallelRun.rebase_state_revision(
|
|
1220
|
+
root: root, feature: state.fetch("current_feature"),
|
|
1221
|
+
state_revision: state.fetch("revision"), lock: project_lock
|
|
1222
|
+
)
|
|
1223
|
+
# STATE.md is project-global. Once its top-level workflow is
|
|
1224
|
+
# terminal, retain the historical owner but release its active
|
|
1225
|
+
# reservation so the next feature can claim a distinct phase.
|
|
1226
|
+
if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
|
|
1227
|
+
completed_owner = PhaseOwnership.complete(
|
|
1228
|
+
root: root, feature: state["current_feature"], phase: state["current_phase"],
|
|
1229
|
+
flow: state["current_flow"], write: true
|
|
1230
|
+
)
|
|
1231
|
+
unless completed_owner["ok"]
|
|
1232
|
+
result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
|
|
1233
|
+
end
|
|
1119
1234
|
end
|
|
1235
|
+
next_intent_written = write_next_intent(root, workflow) if next_intent_requested
|
|
1120
1236
|
end
|
|
1121
|
-
next_intent_written = write_next_intent(root, workflow) if next_intent_requested
|
|
1122
1237
|
end
|
|
1123
1238
|
end
|
|
1124
1239
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate business/technical claims against frozen business facts."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_yaml(path: Path) -> Any:
|
|
13
|
+
try:
|
|
14
|
+
import yaml # type: ignore
|
|
15
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
16
|
+
except ImportError:
|
|
17
|
+
result = subprocess.run(["ruby", "-ryaml", "-rjson", "-e", "puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))", str(path)], text=True, capture_output=True, check=False)
|
|
18
|
+
if result.returncode != 0:
|
|
19
|
+
raise ValueError(result.stderr.strip() or "YAML parse failed")
|
|
20
|
+
return json.loads(result.stdout)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
parser = argparse.ArgumentParser()
|
|
25
|
+
parser.add_argument("--claims", type=Path, required=True)
|
|
26
|
+
parser.add_argument("--business-canonical", type=Path, required=True)
|
|
27
|
+
parser.add_argument("--traceability", type=Path)
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
try:
|
|
30
|
+
claims = load_yaml(args.claims)
|
|
31
|
+
business = json.loads(args.business_canonical.read_text(encoding="utf-8"))
|
|
32
|
+
traceability = json.loads(args.traceability.read_text(encoding="utf-8")) if args.traceability else {}
|
|
33
|
+
except Exception as error:
|
|
34
|
+
print(json.dumps({"valid": False, "verdict": "block_unsafe", "errors": [{"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "message": str(error)}]}, ensure_ascii=False))
|
|
35
|
+
return 1
|
|
36
|
+
errors = []
|
|
37
|
+
warnings = []
|
|
38
|
+
actions = {item.get("id") for item in business.get("actions", []) if isinstance(item, dict)}
|
|
39
|
+
requirements = {item.get("id") for item in traceability.get("requirements", []) if isinstance(item, dict) and item.get("status") in {"confirmed", "approved"}} if isinstance(traceability, dict) else set()
|
|
40
|
+
values = claims.get("claims") if isinstance(claims, dict) else claims
|
|
41
|
+
if not isinstance(values, list):
|
|
42
|
+
errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": "claims", "message": "claims must be an array"})
|
|
43
|
+
values = []
|
|
44
|
+
for index, claim in enumerate(values):
|
|
45
|
+
path = f"claims[{index}]"
|
|
46
|
+
if not isinstance(claim, dict):
|
|
47
|
+
errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "claim must be an object"})
|
|
48
|
+
continue
|
|
49
|
+
allowed = {"id", "text", "kind", "business_action_ref", "requirement_ref"}
|
|
50
|
+
for key in claim:
|
|
51
|
+
if key not in allowed:
|
|
52
|
+
errors.append({"code": "BUSINESS_CLAIM_UNKNOWN_FIELD", "path": f"{path}.{key}", "message": "unknown claim field"})
|
|
53
|
+
if not isinstance(claim.get("id"), str) or not claim["id"].strip() or not isinstance(claim.get("text"), str) or not claim["text"].strip():
|
|
54
|
+
errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": path, "message": "id and text are required"})
|
|
55
|
+
if isinstance(claim.get("id"), str) and sum(1 for item in values[:index] if isinstance(item, dict) and item.get("id") == claim.get("id")):
|
|
56
|
+
errors.append({"code": "BUSINESS_CLAIM_DUPLICATE_ID", "path": f"{path}.id", "message": f"duplicate claim id {claim['id']}"})
|
|
57
|
+
kind = claim.get("kind")
|
|
58
|
+
if kind not in {"business", "technical"}:
|
|
59
|
+
errors.append({"code": "BUSINESS_CLAIM_SCHEMA_INVALID", "path": f"{path}.kind", "message": "kind must be business or technical"})
|
|
60
|
+
ref = claim.get("business_action_ref")
|
|
61
|
+
req = claim.get("requirement_ref")
|
|
62
|
+
if ref and ref not in actions:
|
|
63
|
+
errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.business_action_ref", "message": f"unknown business action {ref}"})
|
|
64
|
+
if req and (not args.traceability or req not in requirements):
|
|
65
|
+
errors.append({"code": "BUSINESS_CLAIM_REF_UNRESOLVED", "path": f"{path}.requirement_ref", "message": f"requirement is not confirmed or traceability input is missing: {req}"})
|
|
66
|
+
if kind == "business" and not ref and not req:
|
|
67
|
+
errors.append({"code": "BUSINESS_CLAIM_REF_MISSING", "path": path, "message": "business claim requires action or requirement reference"})
|
|
68
|
+
if kind == "technical" and not ref and not req:
|
|
69
|
+
warnings.append({"code": "TECHNICAL_CLAIM_UNGROUNDED", "path": path, "message": "technical claim has no business grounding reference"})
|
|
70
|
+
verdict = "block_unsafe" if errors else ("await_user" if warnings else "allow")
|
|
71
|
+
payload = {"valid": not errors, "verdict": verdict, "errors": errors, "warnings": warnings}
|
|
72
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
73
|
+
return 0 if verdict in {"allow", "await_user"} else 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|