@guanlin4924/devflow-pi 0.1.24 → 0.1.26
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/README.md +8 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +252 -33
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +50 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +9 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +19 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +24 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +25 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +136 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +159 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +36 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -3
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +13 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +2 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +36 -1
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb
CHANGED
|
@@ -78,6 +78,21 @@ module ArchitectureFeasibility
|
|
|
78
78
|
data = node.fetch("data")
|
|
79
79
|
if node["kind"] == "workload_shape"
|
|
80
80
|
facts = data.fetch("facts")
|
|
81
|
+
case facts["archetype"]
|
|
82
|
+
when "analytics_query"
|
|
83
|
+
level = promote(level, "full")
|
|
84
|
+
triggers << { "code" => "ARC_ANALYTICS_QUERY", "node_id" => node["id"] }
|
|
85
|
+
when "analytics_projection"
|
|
86
|
+
level = promote(level, "full")
|
|
87
|
+
triggers << { "code" => "ARC_ANALYTICS_PROJECTION", "node_id" => node["id"] }
|
|
88
|
+
when "transactional_command"
|
|
89
|
+
level = promote(level, "lightweight")
|
|
90
|
+
triggers << { "code" => "ARC_TRANSACTIONAL_COMMAND", "node_id" => node["id"] }
|
|
91
|
+
end
|
|
92
|
+
if facts["consistency"] == "unknown"
|
|
93
|
+
level = promote(level, "lightweight")
|
|
94
|
+
unresolved << { "code" => "ARC_CONSISTENCY_UNKNOWN", "node_id" => node["id"] }
|
|
95
|
+
end
|
|
81
96
|
case facts["data_effect"]
|
|
82
97
|
when "migration"
|
|
83
98
|
level = promote(level, "full")
|
|
@@ -121,10 +136,19 @@ module ArchitectureFeasibility
|
|
|
121
136
|
end
|
|
122
137
|
end
|
|
123
138
|
|
|
139
|
+
Array(graph["nodes"]).each do |node|
|
|
140
|
+
next unless node.is_a?(Hash) && node["kind"] == "read_model" && node["status"] == "confirmed"
|
|
141
|
+
data = node.fetch("data", {})
|
|
142
|
+
if data.dig("rebuild", "method") == "unknown" || data["late_data_policy"] == "unknown"
|
|
143
|
+
level = promote(level, "lightweight")
|
|
144
|
+
unresolved << { "code" => "ARC_READ_MODEL_RECOVERY_UNKNOWN", "node_id" => node["id"] }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
124
148
|
unless static_context["available"]
|
|
125
149
|
unresolved << { "code" => static_context["code"], "detail" => static_context["detail"] }.compact
|
|
126
150
|
end
|
|
127
|
-
workload_errors = graph_errors.grep(/\A(?:WSH|WLC)-/)
|
|
151
|
+
workload_errors = graph_errors.grep(/\A(?:WSH|WLC|METRIC|RM)-/)
|
|
128
152
|
unresolved << { "code" => "ARC_WORKLOAD_GRAPH_INVALID", "count" => workload_errors.length } if workload_errors.any?
|
|
129
153
|
|
|
130
154
|
# P2 deliberately uses no numeric threshold. A numeric constraint is a
|
|
@@ -31,6 +31,10 @@ module DevflowControlIO
|
|
|
31
31
|
when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
|
|
32
32
|
when :registry then normalized == ".planning/artifacts/index.yaml"
|
|
33
33
|
when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
|
|
34
|
+
# Feature names are user-facing and may be Unicode. `cleanpath`
|
|
35
|
+
# and the parent-traversal check above own containment; this
|
|
36
|
+
# matcher only fixes the handoff namespace and immutable name.
|
|
37
|
+
when :handoff then normalized.match?(%r{\A\.planning/handoffs/[^/]+/ROL-[0-9a-f-]+\.json\z})
|
|
34
38
|
else false
|
|
35
39
|
end
|
|
36
40
|
raise InvalidControlPath, "invalid #{kind} control path" unless allowed
|
|
@@ -13,7 +13,7 @@ require_relative "requirement_graph"
|
|
|
13
13
|
POLICY = {
|
|
14
14
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
15
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
16
|
-
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
|
|
16
|
+
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
17
17
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
18
18
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
19
19
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
@@ -13,16 +13,17 @@ require_relative "control_io"
|
|
|
13
13
|
require_relative "architecture_constraints"
|
|
14
14
|
|
|
15
15
|
module RequirementGraph
|
|
16
|
-
#
|
|
16
|
+
# v5 adds workload archetypes plus METRIC/RM nodes. Existing v1-v4
|
|
17
17
|
# graphs are migrated in memory and re-written only by this Core writer.
|
|
18
|
-
SCHEMA_VERSION =
|
|
19
|
-
SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, SCHEMA_VERSION].freeze
|
|
18
|
+
SCHEMA_VERSION = 5
|
|
19
|
+
SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, 4, SCHEMA_VERSION].freeze
|
|
20
20
|
ROOT = ".planning/requirements"
|
|
21
21
|
KINDS = {
|
|
22
22
|
"source" => "SRC", "code" => "CODE", "term" => "TERM", "object" => "OBJECT",
|
|
23
23
|
"behavior" => "BEH", "requirement" => "REQ", "rule" => "BR", "boundary" => "BC",
|
|
24
24
|
"slice" => "SL", "risk" => "RISK", "decision" => "DECISION", "acceptance" => "AC",
|
|
25
|
-
"api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "
|
|
25
|
+
"api" => "API", "workload_shape" => "WSH", "workload_constraint" => "WLC", "metric" => "METRIC",
|
|
26
|
+
"read_model" => "RM", "test_case" => "TC"
|
|
26
27
|
}.freeze
|
|
27
28
|
VIEW_NAMES = %w[evidence definitions behavior-graph requirement-graph slices acceptance tests traceability].freeze
|
|
28
29
|
FEATURE_INVALID = /[\\\/\x00-\x1F\x7F]/
|
|
@@ -176,7 +177,7 @@ module RequirementGraph
|
|
|
176
177
|
raise ArgumentError, "node kind #{kind} is not allowed by the execution contract" if allowed_kinds && !allowed_kinds.include?(kind)
|
|
177
178
|
data = normalize_data(delta["data"])
|
|
178
179
|
node = { "id" => next_id(graph, prefix), "kind" => kind, "status" => "candidate", "data" => data }
|
|
179
|
-
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
180
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
180
181
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
181
182
|
set_node_fingerprint!(node)
|
|
182
183
|
graph["nodes"] << node
|
|
@@ -193,12 +194,12 @@ module RequirementGraph
|
|
|
193
194
|
id = delta["id"]
|
|
194
195
|
node = Array(graph["nodes"]).find { |entry| entry.is_a?(Hash) && entry["id"] == id }
|
|
195
196
|
raise ArgumentError, "update requires a known node id" unless node
|
|
196
|
-
if (%w[workload_shape workload_constraint test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
|
|
197
|
-
raise ArgumentError, "confirmed workload
|
|
197
|
+
if (%w[workload_shape workload_constraint metric read_model test_case].include?(node["kind"]) || architecture_control_node?(node)) && node["status"] == "confirmed"
|
|
198
|
+
raise ArgumentError, "confirmed workload, metric/read-model, and architecture decision nodes are immutable; confirmed test cases are also immutable; add a new candidate and relink consumers"
|
|
198
199
|
end
|
|
199
200
|
data = normalize_data(delta["data"])
|
|
200
201
|
node["data"] = node.fetch("data", {}).merge(data)
|
|
201
|
-
candidate_errors = workload_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
202
|
+
candidate_errors = workload_node_errors(graph, node) + metric_node_errors(graph, node) + read_model_node_errors(graph, node) + architecture_decision_errors(graph, node) + architecture_risk_errors(graph, node) + test_case_errors(graph, node)
|
|
202
203
|
raise ArgumentError, candidate_errors.join("; ") if candidate_errors.any?
|
|
203
204
|
set_node_fingerprint!(node)
|
|
204
205
|
clear_stale_for!(graph, id)
|
|
@@ -221,10 +222,10 @@ module RequirementGraph
|
|
|
221
222
|
unless evidence.is_a?(Hash) && (evidence["cp_record"] || evidence["evidence_ref"])
|
|
222
223
|
raise ArgumentError, "confirm requires cp_record or evidence_ref"
|
|
223
224
|
end
|
|
224
|
-
if (%w[workload_shape workload_constraint].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
|
|
225
|
-
raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog"
|
|
225
|
+
if (%w[workload_shape workload_constraint metric read_model].include?(node["kind"]) || architecture_control_node?(node)) && !confirmed_interactively
|
|
226
|
+
raise ArgumentError, "workload and architecture-control confirmation requires --confirmed-interactively from the Core confirmation dialog; metric/read-model candidates use the same dialog"
|
|
226
227
|
end
|
|
227
|
-
confirmation_errors = workload_confirmation_errors(graph, node, evidence) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
|
|
228
|
+
confirmation_errors = workload_confirmation_errors(graph, node, evidence) + metric_confirmation_errors(graph, node) + read_model_confirmation_errors(graph, node) + architecture_decision_confirmation_errors(graph, node) + architecture_risk_confirmation_errors(graph, node)
|
|
228
229
|
raise ArgumentError, confirmation_errors.join("; ") if confirmation_errors.any?
|
|
229
230
|
node["status"] = "confirmed"
|
|
230
231
|
node["confirmation"] = { "origin" => "core", "evidence" => evidence }
|
|
@@ -271,8 +272,14 @@ module RequirementGraph
|
|
|
271
272
|
end
|
|
272
273
|
end
|
|
273
274
|
|
|
274
|
-
TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance].freeze
|
|
275
|
+
TEST_CASE_TYPES = %w[api page permission boundary resilience migration performance analytics freshness reconciliation].freeze
|
|
275
276
|
TEST_AUTOMATION_MODES = %w[automated manual blocked].freeze
|
|
277
|
+
# A verification strategy says what would constitute trustworthy evidence.
|
|
278
|
+
# It is intentionally separate from automation_mode: a page test may be
|
|
279
|
+
# replayable yet initially manual, while an intermittent incident may be
|
|
280
|
+
# causally proven without a deterministic replay.
|
|
281
|
+
TEST_EVIDENCE_STRATEGIES = %w[replay causal observation].freeze
|
|
282
|
+
REPLAY_SCOPES = %w[component page api integration].freeze
|
|
276
283
|
|
|
277
284
|
# TC nodes are derived verification intent. They deliberately store only
|
|
278
285
|
# externally observable setup, operation and oracle; implementation paths,
|
|
@@ -282,7 +289,7 @@ module RequirementGraph
|
|
|
282
289
|
|
|
283
290
|
data = node["data"].is_a?(Hash) ? node["data"] : {}
|
|
284
291
|
errors = []
|
|
285
|
-
%w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode].each do |field|
|
|
292
|
+
%w[acceptance_refs requirement_refs test_type target preconditions steps oracle automation_mode evidence_strategy].each do |field|
|
|
286
293
|
errors << "#{node["id"]}: missing #{field}" unless present?(data[field])
|
|
287
294
|
end
|
|
288
295
|
errors << "#{node["id"]}: acceptance_refs must be a non-empty array" unless data["acceptance_refs"].is_a?(Array) && !data["acceptance_refs"].empty?
|
|
@@ -291,6 +298,25 @@ module RequirementGraph
|
|
|
291
298
|
errors << "#{node["id"]}: steps must be a non-empty array" unless data["steps"].is_a?(Array) && !data["steps"].empty?
|
|
292
299
|
errors << "#{node["id"]}: invalid test_type" unless TEST_CASE_TYPES.include?(data["test_type"])
|
|
293
300
|
errors << "#{node["id"]}: invalid automation_mode" unless TEST_AUTOMATION_MODES.include?(data["automation_mode"])
|
|
301
|
+
errors << "#{node["id"]}: invalid evidence_strategy" unless TEST_EVIDENCE_STRATEGIES.include?(data["evidence_strategy"])
|
|
302
|
+
case data["evidence_strategy"]
|
|
303
|
+
when "replay"
|
|
304
|
+
errors << "#{node["id"]}: replay tests require replay_scope" unless REPLAY_SCOPES.include?(data["replay_scope"])
|
|
305
|
+
when "causal"
|
|
306
|
+
refs = data["causal_evidence_refs"]
|
|
307
|
+
errors << "#{node["id"]}: causal tests require non-empty causal_evidence_refs" unless refs.is_a?(Array) && !refs.empty?
|
|
308
|
+
Array(refs).each do |ref|
|
|
309
|
+
errors << "#{node["id"]}: causal evidence ref #{ref} must reference SRC/CODE" unless refs_known?(graph, [ref], %w[SRC CODE])
|
|
310
|
+
end
|
|
311
|
+
errors << "#{node["id"]}: causal tests require first_divergence" unless present?(data["first_divergence"])
|
|
312
|
+
when "observation"
|
|
313
|
+
plan = data["observation_plan"]
|
|
314
|
+
errors << "#{node["id"]}: observation tests require observation_plan" unless plan.is_a?(Hash)
|
|
315
|
+
%w[signal owner window alert_condition].each do |field|
|
|
316
|
+
errors << "#{node["id"]}: observation_plan requires #{field}" unless plan.is_a?(Hash) && present?(plan[field])
|
|
317
|
+
end
|
|
318
|
+
errors << "#{node["id"]}: observation tests must remain blocked until Core-observed evidence exists" unless data["automation_mode"] == "blocked"
|
|
319
|
+
end
|
|
294
320
|
if data.key?("test_obligation_refs")
|
|
295
321
|
errors << "#{node["id"]}: test_obligation_refs must be a non-empty string array when present" unless data["test_obligation_refs"].is_a?(Array) && !data["test_obligation_refs"].empty? && data["test_obligation_refs"].all? { |ref| ref.is_a?(String) && ref.match?(/\AOBL-[A-Z0-9-]+\z/) }
|
|
296
322
|
end
|
|
@@ -322,7 +348,9 @@ module RequirementGraph
|
|
|
322
348
|
{
|
|
323
349
|
"api_refs" => %w[API],
|
|
324
350
|
"workload_refs" => %w[WSH WLC],
|
|
325
|
-
"risk_refs" => %w[RISK]
|
|
351
|
+
"risk_refs" => %w[RISK],
|
|
352
|
+
"metric_refs" => %w[METRIC],
|
|
353
|
+
"read_model_refs" => %w[RM]
|
|
326
354
|
}.each do |field, prefixes|
|
|
327
355
|
next unless data.key?(field)
|
|
328
356
|
|
|
@@ -504,6 +532,16 @@ module RequirementGraph
|
|
|
504
532
|
errors << "#{node["id"]}: workload source #{ref} must link with shapes_workload" if require_edges && !incoming_edge?(graph, ref, node["id"], "shapes_workload")
|
|
505
533
|
errors << "#{node["id"]}: workload source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
506
534
|
end
|
|
535
|
+
{ "metric_refs" => %w[METRIC], "read_model_refs" => %w[RM] }.each do |field, prefixes|
|
|
536
|
+
next unless data.key?(field)
|
|
537
|
+
errors << "#{node["id"]}: #{field} must be a non-empty array when present" unless data[field].is_a?(Array) && !data[field].empty?
|
|
538
|
+
Array(data[field]).each do |ref|
|
|
539
|
+
errors << "#{node["id"]}: #{field} #{ref} must exist in the graph" unless refs_known?(graph, [ref], prefixes)
|
|
540
|
+
relation = field == "metric_refs" ? "defines_metric" : "uses_read_model"
|
|
541
|
+
errors << "#{node["id"]}: #{field} #{ref} must link with #{relation}" if require_edges && !incoming_edge?(graph, ref, node["id"], relation)
|
|
542
|
+
errors << "#{node["id"]}: #{field} #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
543
|
+
end
|
|
544
|
+
end
|
|
507
545
|
else
|
|
508
546
|
workload_ref = data["workload_ref"]
|
|
509
547
|
errors << "#{node["id"]}: workload_ref #{workload_ref} must exist in the graph" unless refs_known?(graph, [workload_ref], %w[BEH SL API])
|
|
@@ -528,6 +566,74 @@ module RequirementGraph
|
|
|
528
566
|
errors
|
|
529
567
|
end
|
|
530
568
|
|
|
569
|
+
# A metric is a business contract, not a free-text label on a dashboard.
|
|
570
|
+
# It deliberately has no status/ID input: those remain Core-owned.
|
|
571
|
+
def metric_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
572
|
+
return [] unless node["kind"] == "metric"
|
|
573
|
+
|
|
574
|
+
data = node.fetch("data", {})
|
|
575
|
+
errors = []
|
|
576
|
+
%w[name grain measure inclusion_rule_refs exclusion_rule_refs dimensions time_basis timezone correction_policy owner].each do |field|
|
|
577
|
+
errors << "#{node["id"]}: metric missing #{field}" unless present?(data[field])
|
|
578
|
+
end
|
|
579
|
+
%w[inclusion_rule_refs exclusion_rule_refs dimensions].each do |field|
|
|
580
|
+
errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
|
|
581
|
+
end
|
|
582
|
+
Array(data["inclusion_rule_refs"]) .concat(Array(data["exclusion_rule_refs"])).each do |ref|
|
|
583
|
+
errors << "#{node["id"]}: metric rule #{ref} must reference BR" unless refs_known?(graph, [ref], %w[BR])
|
|
584
|
+
errors << "#{node["id"]}: metric rule #{ref} must link with defines_metric" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_metric")
|
|
585
|
+
errors << "#{node["id"]}: metric rule #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
586
|
+
end
|
|
587
|
+
errors.uniq
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def metric_confirmation_errors(graph, node)
|
|
591
|
+
metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
READ_MODEL_MODES = %w[direct_query materialized_view scheduled_aggregate event_projection warehouse].freeze
|
|
595
|
+
REBUILD_METHODS = %w[replay_from_fact full_recompute unknown].freeze
|
|
596
|
+
LATE_DATA_POLICIES = %w[recompute_window correction_event reject unknown].freeze
|
|
597
|
+
|
|
598
|
+
def read_model_node_errors(graph, node, require_edges: false, require_confirmed_inputs: false)
|
|
599
|
+
return [] unless node["kind"] == "read_model"
|
|
600
|
+
|
|
601
|
+
data = node.fetch("data", {})
|
|
602
|
+
errors = []
|
|
603
|
+
%w[source_of_truth_refs serves metric_contract_refs projection_mode rebuild late_data_policy reconciliation owner].each do |field|
|
|
604
|
+
errors << "#{node["id"]}: read model missing #{field}" unless present?(data[field])
|
|
605
|
+
end
|
|
606
|
+
%w[source_of_truth_refs serves metric_contract_refs].each do |field|
|
|
607
|
+
errors << "#{node["id"]}: #{field} must be a non-empty array" unless data[field].is_a?(Array) && !data[field].empty?
|
|
608
|
+
end
|
|
609
|
+
errors << "#{node["id"]}: invalid projection_mode" unless READ_MODEL_MODES.include?(data["projection_mode"])
|
|
610
|
+
errors << "#{node["id"]}: rebuild must be an object" unless data["rebuild"].is_a?(Hash)
|
|
611
|
+
errors << "#{node["id"]}: invalid rebuild.method" unless REBUILD_METHODS.include?(data.dig("rebuild", "method"))
|
|
612
|
+
errors << "#{node["id"]}: invalid late_data_policy" unless LATE_DATA_POLICIES.include?(data["late_data_policy"])
|
|
613
|
+
errors << "#{node["id"]}: reconciliation must be an object" unless data["reconciliation"].is_a?(Hash)
|
|
614
|
+
%w[method frequency].each { |field| errors << "#{node["id"]}: reconciliation missing #{field}" unless present?(data.dig("reconciliation", field)) }
|
|
615
|
+
Array(data["source_of_truth_refs"]).each do |ref|
|
|
616
|
+
errors << "#{node["id"]}: source #{ref} must reference BEH/OBJECT" unless refs_known?(graph, [ref], %w[BEH OBJECT])
|
|
617
|
+
errors << "#{node["id"]}: source #{ref} must link with derives_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "derives_read_model")
|
|
618
|
+
errors << "#{node["id"]}: source #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
619
|
+
end
|
|
620
|
+
Array(data["serves"]).each do |ref|
|
|
621
|
+
errors << "#{node["id"]}: serves #{ref} must reference API" unless refs_known?(graph, [ref], %w[API])
|
|
622
|
+
errors << "#{node["id"]}: serves #{ref} must link with serves_query" if require_edges && !incoming_edge?(graph, node["id"], ref, "serves_query")
|
|
623
|
+
errors << "#{node["id"]}: serves #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
624
|
+
end
|
|
625
|
+
Array(data["metric_contract_refs"]).each do |ref|
|
|
626
|
+
errors << "#{node["id"]}: metric #{ref} must reference METRIC" unless refs_known?(graph, [ref], %w[METRIC])
|
|
627
|
+
errors << "#{node["id"]}: metric #{ref} must link with defines_read_model" if require_edges && !incoming_edge?(graph, ref, node["id"], "defines_read_model")
|
|
628
|
+
errors << "#{node["id"]}: metric #{ref} must be confirmed" if require_confirmed_inputs && !confirmed?(graph, ref)
|
|
629
|
+
end
|
|
630
|
+
errors.uniq
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def read_model_confirmation_errors(graph, node)
|
|
634
|
+
read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: true)
|
|
635
|
+
end
|
|
636
|
+
|
|
531
637
|
def architecture_option_decision?(node)
|
|
532
638
|
node["kind"] == "decision" && node.dig("data", "decision_type") == "architecture_option_comparison"
|
|
533
639
|
end
|
|
@@ -666,6 +772,14 @@ module RequirementGraph
|
|
|
666
772
|
results["requirement-graph-check"].concat(
|
|
667
773
|
workload_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
668
774
|
)
|
|
775
|
+
when "metric"
|
|
776
|
+
results["requirement-graph-check"].concat(
|
|
777
|
+
metric_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
778
|
+
)
|
|
779
|
+
when "read_model"
|
|
780
|
+
results["requirement-graph-check"].concat(
|
|
781
|
+
read_model_node_errors(graph, node, require_edges: true, require_confirmed_inputs: node["status"] == "confirmed")
|
|
782
|
+
)
|
|
669
783
|
when "acceptance"
|
|
670
784
|
required(results["acceptance-coverage-check"], node, %w[requirement_ref verification_types])
|
|
671
785
|
types = Array(node.dig("data", "verification_types"))
|
|
@@ -705,6 +819,13 @@ module RequirementGraph
|
|
|
705
819
|
if node["kind"] == "workload_shape" && node.dig("data", "facts", "data_effect") == "migration"
|
|
706
820
|
results["test-obligation-check"] << "#{node["id"]}: migration workload requires a migration TC with workload_refs" unless covered_by.call("migration", "workload_refs", node["id"])
|
|
707
821
|
end
|
|
822
|
+
if node["kind"] == "workload_shape" && %w[analytics_query analytics_projection].include?(node.dig("data", "facts", "archetype"))
|
|
823
|
+
results["test-obligation-check"] << "#{node["id"]}: analytics workload requires an analytics TC with workload_refs" unless covered_by.call("analytics", "workload_refs", node["id"])
|
|
824
|
+
end
|
|
825
|
+
when "metric"
|
|
826
|
+
results["test-obligation-check"] << "#{node["id"]}: metric requires a reconciliation TC with metric_refs" unless covered_by.call("reconciliation", "metric_refs", node["id"])
|
|
827
|
+
when "read_model"
|
|
828
|
+
results["test-obligation-check"] << "#{node["id"]}: read model requires a freshness TC with read_model_refs" unless covered_by.call("freshness", "read_model_refs", node["id"])
|
|
708
829
|
when "risk"
|
|
709
830
|
if architecture_option_risk?(node)
|
|
710
831
|
results["test-obligation-check"] << "#{node["id"]}: architecture risk requires a resilience TC with risk_refs" unless covered_by.call("resilience", "risk_refs", node["id"])
|
|
@@ -760,7 +881,7 @@ module RequirementGraph
|
|
|
760
881
|
when "evidence" then %w[source code]
|
|
761
882
|
when "definitions" then %w[term object rule boundary]
|
|
762
883
|
when "behavior-graph" then %w[behavior]
|
|
763
|
-
when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint test_case]
|
|
884
|
+
when "requirement-graph", "traceability" then %w[requirement rule boundary api workload_shape workload_constraint metric read_model test_case]
|
|
764
885
|
when "slices" then %w[slice]
|
|
765
886
|
when "acceptance" then %w[acceptance risk decision]
|
|
766
887
|
when "tests" then %w[test_case]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Controlled, content-addressed session handoff state. This script is the only
|
|
5
|
+
# writer for STATE.rollover; callers must hold no assumptions about a
|
|
6
|
+
# filesystem "latest" handoff. STATE is the authority and immutable handoff
|
|
7
|
+
# JSON is the evidence it points to.
|
|
8
|
+
|
|
9
|
+
require "digest"
|
|
10
|
+
require "json"
|
|
11
|
+
require "optparse"
|
|
12
|
+
require "securerandom"
|
|
13
|
+
require "time"
|
|
14
|
+
require "yaml"
|
|
15
|
+
require_relative "control_io"
|
|
16
|
+
|
|
17
|
+
options = { json: false, write: false }
|
|
18
|
+
parser = OptionParser.new do |opts|
|
|
19
|
+
opts.banner = "Usage: session_rollover.rb prepare|claim|consume|inspect [options]"
|
|
20
|
+
opts.on("--write", "Apply the requested transition") { options[:write] = true }
|
|
21
|
+
opts.on("--json", "Print JSON") { options[:json] = true }
|
|
22
|
+
opts.on("--expected-revision N", Integer, "STATE revision compare-and-swap") { |v| options[:expected_revision] = v }
|
|
23
|
+
opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
|
|
24
|
+
opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
|
|
25
|
+
opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
|
|
26
|
+
end
|
|
27
|
+
action = ARGV.shift
|
|
28
|
+
parser.parse!
|
|
29
|
+
|
|
30
|
+
def fail_result(reason, details = {})
|
|
31
|
+
{ "ok" => false, "reason" => reason }.merge(details)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_state(path)
|
|
35
|
+
raw = File.read(path)
|
|
36
|
+
match = raw.match(/\A---\n(.*?)\n---\n/m)
|
|
37
|
+
raise "STATE.md is missing YAML front matter" unless match
|
|
38
|
+
[YAML.safe_load(match[1], permitted_classes: [], aliases: false), raw]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_state(path, state, previous)
|
|
42
|
+
body = previous.sub(/\A---\n.*?\n---\n/m, "")
|
|
43
|
+
front = state.to_yaml.sub(/\A---\n/, "")
|
|
44
|
+
DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid_session_id?(value)
|
|
48
|
+
value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def iso_now
|
|
52
|
+
Time.now.utc.iso8601(3)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
root = Dir.pwd
|
|
56
|
+
state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
|
|
57
|
+
result = catch(:transition_done) do
|
|
58
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
59
|
+
begin
|
|
60
|
+
unless File.exist?(state_path)
|
|
61
|
+
result = fail_result("missing_state")
|
|
62
|
+
throw :transition_done, result
|
|
63
|
+
end
|
|
64
|
+
state, raw = parse_state(state_path)
|
|
65
|
+
revision = Integer(state.fetch("revision", 0))
|
|
66
|
+
if !options[:expected_revision].nil? && revision != options[:expected_revision]
|
|
67
|
+
result = fail_result("stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => revision)
|
|
68
|
+
throw :transition_done, result
|
|
69
|
+
end
|
|
70
|
+
rollover = state["rollover"]
|
|
71
|
+
|
|
72
|
+
case action
|
|
73
|
+
when "inspect"
|
|
74
|
+
result = { "ok" => true, "revision" => revision, "rollover" => rollover }
|
|
75
|
+
when "prepare"
|
|
76
|
+
unless valid_session_id?(options[:source_session_id])
|
|
77
|
+
result = fail_result("invalid_source_session_id")
|
|
78
|
+
throw :transition_done, result
|
|
79
|
+
end
|
|
80
|
+
if rollover && %w[prepared claimed].include?(rollover["status"])
|
|
81
|
+
result = fail_result("rollover_in_progress", "rollover" => rollover)
|
|
82
|
+
throw :transition_done, result
|
|
83
|
+
end
|
|
84
|
+
rollover_id = "ROL-#{SecureRandom.uuid}"
|
|
85
|
+
relative_path = ".planning/handoffs/#{state.fetch("current_feature")}/#{rollover_id}.json"
|
|
86
|
+
absolute_path, = DevflowControlIO.resolve_control_path(root, relative_path, :handoff)
|
|
87
|
+
payload = {
|
|
88
|
+
"schema_version" => 1,
|
|
89
|
+
"rollover_id" => rollover_id,
|
|
90
|
+
"created_at" => iso_now,
|
|
91
|
+
"reason" => options[:reason].to_s.empty? ? "workflow_step_boundary" : options[:reason],
|
|
92
|
+
"source_session_id" => options[:source_session_id],
|
|
93
|
+
"state" => {
|
|
94
|
+
"revision" => revision,
|
|
95
|
+
"current_flow" => state["current_flow"], "current_feature" => state["current_feature"],
|
|
96
|
+
"current_phase" => state["current_phase"], "current_step" => state["current_step"],
|
|
97
|
+
"status" => state["status"], "completed_steps" => state["completed_steps"], "pending_steps" => state["pending_steps"]
|
|
98
|
+
},
|
|
99
|
+
"references" => {
|
|
100
|
+
"state_path" => ".planning/STATE.md",
|
|
101
|
+
"workflow_path" => state["workflow_config"],
|
|
102
|
+
"artifact_registry" => ".planning/artifacts/index.yaml"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
serialized = JSON.generate(payload) + "\n"
|
|
106
|
+
digest = Digest::SHA256.hexdigest(serialized)
|
|
107
|
+
if options[:write]
|
|
108
|
+
DevflowControlIO.atomic_create(absolute_path, serialized)
|
|
109
|
+
state["rollover"] = {
|
|
110
|
+
"id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
|
|
111
|
+
"handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
|
|
112
|
+
"target_session_id" => nil, "expected_state_revision" => revision,
|
|
113
|
+
"prepared_at" => iso_now, "claimed_at" => nil
|
|
114
|
+
}
|
|
115
|
+
state["revision"] = revision + 1
|
|
116
|
+
write_state(state_path, state, raw)
|
|
117
|
+
end
|
|
118
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
|
|
119
|
+
when "claim"
|
|
120
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
|
|
121
|
+
result = fail_result("rollover_not_prepared", "rollover" => rollover)
|
|
122
|
+
throw :transition_done, result
|
|
123
|
+
end
|
|
124
|
+
unless valid_session_id?(options[:target_session_id])
|
|
125
|
+
result = fail_result("invalid_target_session_id")
|
|
126
|
+
throw :transition_done, result
|
|
127
|
+
end
|
|
128
|
+
path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
129
|
+
unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
|
|
130
|
+
result = fail_result("handoff_integrity_failure")
|
|
131
|
+
throw :transition_done, result
|
|
132
|
+
end
|
|
133
|
+
if options[:write]
|
|
134
|
+
rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
|
|
135
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
136
|
+
end
|
|
137
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
138
|
+
when "consume"
|
|
139
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
|
|
140
|
+
result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
|
|
141
|
+
throw :transition_done, result
|
|
142
|
+
end
|
|
143
|
+
if options[:write]
|
|
144
|
+
rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
|
|
145
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
146
|
+
end
|
|
147
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
148
|
+
else
|
|
149
|
+
result = fail_result("unknown_action")
|
|
150
|
+
end
|
|
151
|
+
rescue DevflowControlIO::InvalidControlPath, Psych::Exception, SystemCallError, RuntimeError => error
|
|
152
|
+
result = fail_result("rollover_transition_failed", "message" => error.message)
|
|
153
|
+
ensure
|
|
154
|
+
DevflowControlIO.release_lock(lock)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
puts JSON.pretty_generate(result)
|
|
159
|
+
exit(result["ok"] ? 0 : 1)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
|
+
require "digest"
|
|
5
6
|
require "optparse"
|
|
6
7
|
require "yaml"
|
|
7
8
|
require_relative "control_io"
|
|
@@ -42,6 +43,7 @@ module WorkflowStateValidator
|
|
|
42
43
|
REQUIRED_STATE_FIELDS.each do |field|
|
|
43
44
|
issues << issue("error", "missing_state_field", "STATE.md is missing required field: #{field}", field: field) unless state.key?(field)
|
|
44
45
|
end
|
|
46
|
+
validate_rollover(issues, root, state)
|
|
45
47
|
return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
|
|
46
48
|
|
|
47
49
|
ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
|
|
@@ -122,6 +124,40 @@ module WorkflowStateValidator
|
|
|
122
124
|
nil
|
|
123
125
|
end
|
|
124
126
|
|
|
127
|
+
def validate_rollover(issues, root, state)
|
|
128
|
+
rollover = state["rollover"]
|
|
129
|
+
return if rollover.nil?
|
|
130
|
+
unless rollover.is_a?(Hash)
|
|
131
|
+
issues << issue("error", "invalid_rollover", "STATE.rollover must be an object")
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
status = rollover["status"]
|
|
135
|
+
unless %w[prepared claimed consumed failed].include?(status)
|
|
136
|
+
issues << issue("error", "invalid_rollover_status", "STATE.rollover.status is invalid", status: status)
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
%w[id handoff_path handoff_sha256 source_session_id prepared_at].each do |field|
|
|
140
|
+
issues << issue("error", "missing_rollover_field", "STATE.rollover is missing #{field}", field: field) unless rollover[field].is_a?(String) && !rollover[field].empty?
|
|
141
|
+
end
|
|
142
|
+
return if issues.any? { |entry| entry["severity"] == "error" }
|
|
143
|
+
begin
|
|
144
|
+
handoff_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
145
|
+
if %w[prepared claimed consumed].include?(status) && !File.exist?(handoff_path)
|
|
146
|
+
issues << issue("error", "missing_rollover_handoff", "STATE.rollover references a missing immutable handoff", path: rollover["handoff_path"])
|
|
147
|
+
elsif File.exist?(handoff_path) && Digest::SHA256.hexdigest(File.binread(handoff_path)) != rollover["handoff_sha256"]
|
|
148
|
+
issues << issue("error", "rollover_handoff_hash_mismatch", "STATE.rollover handoff hash does not match immutable payload", path: rollover["handoff_path"])
|
|
149
|
+
end
|
|
150
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
151
|
+
issues << issue("error", "invalid_rollover_handoff_path", error.message)
|
|
152
|
+
end
|
|
153
|
+
if rollover["handoff_sha256"].to_s !~ /\A[0-9a-f]{64}\z/
|
|
154
|
+
issues << issue("error", "invalid_rollover_handoff_hash", "STATE.rollover.handoff_sha256 must be SHA-256 hex")
|
|
155
|
+
end
|
|
156
|
+
if status == "claimed" && (rollover["target_session_id"].to_s.empty? || rollover["claimed_at"].to_s.empty?)
|
|
157
|
+
issues << issue("error", "incomplete_rollover_claim", "Claimed rollover requires target_session_id and claimed_at")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
125
161
|
def load_yaml(path, issues, code)
|
|
126
162
|
unless File.exist?(path)
|
|
127
163
|
issues << issue("error", "missing_#{code}", "YAML file does not exist", path: path)
|
|
@@ -43,9 +43,10 @@ The meta file must also set `traceability_status: "passed"` only after every API
|
|
|
43
43
|
1. **只定义接口契约,不谈实现细节** — 不写数据表结构、不写具体代码、不写业务流程内部逻辑
|
|
44
44
|
2. **分叉后不变更** — 一旦确定,Track A/B 都基于此契约工作。变更走 `api-supplement` 异常路径
|
|
45
45
|
3. **接口签名必须完整** — 包括 endpoint、method、请求参数(含类型约束)、响应结构(含状态码)、错误契约与行为语义(过滤、排序、权限、幂等/兼容规则)
|
|
46
|
-
4.
|
|
47
|
-
5.
|
|
48
|
-
6.
|
|
46
|
+
4. **负载职责明确** — 事务命令、运营查询和统计查询不得在同一个接口语义中混写;统计查询必须引用已确认的 `METRIC-*`,并在响应中约定 `as_of`/新鲜度语义或明确强一致依据。
|
|
47
|
+
5. **数据模型共享** — 核心业务实体定义在这里,前后端共用一个数据模型
|
|
48
|
+
6. **切片对齐** — 若 baseline 包含 `SL-*`,先读取 `skill://workflow-pipeline/references/slice-safety-guide.md`,只为当前执行切片定义 API。后置切片接口不得提前进入契约。
|
|
49
|
+
7. **既有接口优先查事实索引** — 当 `.planning/code-index/` 存在时,先用 Symbol/Relation 定位候选接口、调用方和处理器,再读取源码证据;缺失或过期时再按 navigation 和 `rg` 兜底。
|
|
49
50
|
|
|
50
51
|
## 可测试 API 契约(强制)
|
|
51
52
|
|
|
@@ -15,6 +15,8 @@ description: "Use after API contract and before frontend/backend technical speci
|
|
|
15
15
|
- WSH 使用 `workload_shape`,并连接 `REQ/BEH/API → WSH`,relation 为 `shapes_workload`。
|
|
16
16
|
- WLC 使用 `workload_constraint`,并连接 `BEH/SL/API → WLC`,relation 为 `constrains_workload`。
|
|
17
17
|
- `unknown` 只能写缺口、责任人、截止 checkpoint;不能填 target、evidence 或 assumption。
|
|
18
|
+
- 每个新增/变更的 API、BEH、Job 至少归类为 `transactional_command`、`operational_query`、`analytics_query`、`analytics_projection`、`batch_processing` 或 `external_integration`;历史节点没有 `archetype` 时按 legacy 处理,不倒逼补录。
|
|
19
|
+
- `analytics_query` 必须关联 `METRIC-*`;`analytics_projection` 还必须关联 `RM-*`。先提交指标口径(粒度、纳入/排除规则、时间字段/时区、修正规则、owner),再提交读模型(事实源、服务 API、投影/刷新方式、重建、迟到数据和对账)。
|
|
18
20
|
4. 对 `lightweight` 或 `full` 且存在实质取舍的结果,提交 `decision_type: architecture_option_comparison` 的 `DECISION` candidate:
|
|
19
21
|
- `subject_refs` 只引用 WSH/WLC;WSH 用 `informs_decision` 连到 DECISION,WLC 用 `constrains_decision` 连到 DECISION。
|
|
20
22
|
- 每个 option 使用 `option_key`,并包含 `mechanism`、非空 `fact_refs`、非空 `tradeoffs`。
|
|
@@ -31,6 +33,8 @@ description: "Use after API contract and before frontend/backend technical speci
|
|
|
31
33
|
- `assessment_status: incomplete`、`comparison_status: missing` 或 `awaiting_*` 必须原样交给下游技术方案,不能写成“评估通过”。
|
|
32
34
|
- 已确认 WSH/WLC/架构 DECISION/RISK 不可原地更新;新建 candidate、重新连边,并让 stale 传播要求消费者复核。
|
|
33
35
|
- 不替用户选择方案、接受残余风险或伪造 CP 记录。
|
|
36
|
+
- 业务事实表是唯一可审计真相源;`RM-*` 是可重建的派生读模型,不能被业务命令当作事实源回写。
|
|
37
|
+
- 不因“统计”自动要求拆库、消息队列或数仓。`direct_query`、物化视图、定时汇总、事件投影和数仓必须根据 WSH/WLC 给出有实质差异的比较与升级条件。
|
|
34
38
|
|
|
35
39
|
## 下游交接
|
|
36
40
|
|
|
@@ -48,6 +48,8 @@ allowed-tools:
|
|
|
48
48
|
|
|
49
49
|
读取 `.planning/requirements/{feature}/traceability.json`。每个设计章节必须列 `覆盖 REQ: REQ-*`;定时任务、批处理、导入和通知至少拆为“编排(扫描/触发/锁/重试)”与“业务算法(判定/计算/写入)”两个可独立验证的小节。不得把遍历全部活跃组织、发送通知、文件解析等编排事实藏在算法描述中。
|
|
50
50
|
|
|
51
|
+
当架构简报包含 `analytics_query`、`analytics_projection`、`METRIC-*` 或 `RM-*` 时,后端方案额外必须分别写明:业务事实源;指标粒度/纳入排除/时区与修正;读模型或直接聚合路径;索引/扫描范围;新鲜度;watermark/迟到数据;重建与对账。不得把统计 SQL 混进事务命令,或以“后续补统计”跳过这些字段。
|
|
52
|
+
|
|
51
53
|
根据需求基线和项目现有代码,生成聚焦于影响分析的后端技术方案。
|
|
52
54
|
|
|
53
55
|
## 核心原则
|
|
@@ -7,6 +7,14 @@ description: "Use after integration verification and before change planning to d
|
|
|
7
7
|
|
|
8
8
|
在代码执行前,基于已确认的需求、契约和架构约束提出独立的 `TC-*` 验收测试候选。测试意图必须独立于实现:禁止读取 `SUMMARY.md`、实现源码、git diff 或 agent 交付自述来决定测试范围。
|
|
9
9
|
|
|
10
|
+
## 执行合同
|
|
11
|
+
|
|
12
|
+
本步骤的图谱写入受 ExecutionContract v2 约束:`devflow_requirement_graph(action="propose", kind="test_case")` 与 `devflow_api_test_matrix(action="import")` 仅在 active contract 的 step 为 `acceptance-test-design` 时放行。步骤开始且无 active contract 时:
|
|
13
|
+
|
|
14
|
+
1. 先调用 `devflow_contract(action="status")` 检查当前会话是否有 active contract;
|
|
15
|
+
2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval 阶段均可,Core 校验 STATE 与图谱 revision 后由用户确认);
|
|
16
|
+
3. 会话中断后 contract 由 runtime 在 `session_start` 时从 `.planning/contracts/{feature}/EC-*.json` 自动恢复;恢复时校验 state/graph revision,推进后自动失效,需重新合成。
|
|
17
|
+
|
|
10
18
|
## 输入
|
|
11
19
|
|
|
12
20
|
按顺序读取:
|
|
@@ -39,11 +47,15 @@ description: "Use after integration verification and before change planning to d
|
|
|
39
47
|
"preconditions": ["身份、数据和环境前置条件"],
|
|
40
48
|
"steps": ["测试操作"],
|
|
41
49
|
"oracle": "可观察的预期结果",
|
|
42
|
-
"automation_mode": "automated|manual|blocked"
|
|
50
|
+
"automation_mode": "automated|manual|blocked",
|
|
51
|
+
"evidence_strategy": "replay|causal|observation"
|
|
43
52
|
}
|
|
44
53
|
```
|
|
45
54
|
|
|
46
55
|
- `manual` 额外写 `manual_reason` 和 `owner`;`blocked` 额外写 `blocked_by` 和 `owner`。
|
|
56
|
+
- `replay`:可用稳定步骤再次触发,必须写 `replay_scope`(`component|page|api|integration`)。页面优先拆到组件、页面、接口或集成边界,不要求 AI 在真实环境盲目重试。
|
|
57
|
+
- `causal`:用于已由 HAR、控制台、DOM 快照、服务日志或请求链路证明首个偏差、但暂不能稳定重放的缺陷。必须写 `causal_evidence_refs`(已有 `SRC-*` / `CODE-*`)和 `first_divergence`;后续执行绑定还必须提供实际证据文件,Core 会哈希留档。没有这些证据不能称为“已证实”。
|
|
58
|
+
- `observation`:用于当前无法重放也无法证明根因的偶发问题。必须写 `observation_plan` 的 `signal`、`owner`、`window`、`alert_condition`,且 `automation_mode` 必须为 `blocked`。它会阻止版本收口,不能被“人工已看过”替代。
|
|
47
59
|
- 当 TC 承接结构性专项义务时,额外带对应引用并建立同样的 `verified_by` edge:`api_refs`(API→`api`)、`workload_refs`(WSH/WLC→`performance` 或 `migration`)、`risk_refs`(架构 RISK→`resilience`)。跨数据域的 REQ 必须有 `permission` TC,BC 必须有 `boundary` TC。
|
|
48
60
|
- 页面/API 的具体 selector、endpoint URL、测试数据和 runner 命令属于后续执行绑定,不得改变本步骤的步骤或 oracle。
|
|
49
61
|
- 高风险边界、权限、迁移、外部依赖和工作负载事实必须产生相应 TC;`test-obligation-check` 是机器判定,不接受“无架构影响”的模型自述。环境未准备好只能标记 blocked,不得省略。
|
|
@@ -36,6 +36,8 @@ description: "Execute the frozen TC acceptance suite after code review. Use the
|
|
|
36
36
|
- 通用自动化用例使用 `{ "kind": "command", "argv": [ ... ] }`,不使用 shell 字符串;
|
|
37
37
|
- Core 生成的 API TC 可直接使用 `{ "kind": "http", "base_url_env": "DEVFLOW_TEST_BASE_URL", "headers_env": { "Authorization": "DEVFLOW_TEST_AUTHORIZATION" } }`。请求、HTTP 状态和异常码断言来自冻结 TC 的 `execution`,绑定只能提供环境地址和鉴权,不得重写测试意图;若场景需要专用夹具,可额外配置 `{ "setup": { "argv": [ ... ] } }`,Core 会先记录并执行该受控命令,再发 HTTP 请求;
|
|
38
38
|
- 人工或环境阻塞用例必须有 owner 与原因/阻塞源。
|
|
39
|
+
- `evidence_strategy=causal` 的自动用例额外写 `evidence_files`(项目根目录内的 HAR、控制台、DOM 快照、截图或日志文件相对路径)。Core 在执行前校验文件存在且未越出项目根目录,并把内容哈希写入结果;不能只填 Markdown 描述。
|
|
40
|
+
- `evidence_strategy=observation` 只能保持 `blocked`,其观测计划是风险收口输入,不是测试通过证据。
|
|
39
41
|
5. 调用 `devflow_run_acceptance_tests(action="validate", feature=...)`。不通过时修正绑定,不得绕过。
|
|
40
42
|
6. 调用 `devflow_run_acceptance_tests(action="run", feature=...)`。该 Core 工具记录每个 TC 的命令哈希、退出码和输出摘要。
|
|
41
43
|
7. 失败、未执行或 blocked 的 TC 只能报告为 `blocked`/`failed`,不得进入版本收口。
|