@michaelxwb/code-flow 0.6.5 → 0.8.1
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 +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -42
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +52 -39
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -42
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +52 -39
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -42
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +52 -39
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -42
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +53 -45
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1741
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -389
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +5 -2
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +163 -35
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic merges for shared demand-state files across parallel worktrees.
|
|
3
|
+
|
|
4
|
+
Parallel workers each finish in their own worktree; their branches touch the
|
|
5
|
+
same demand files (task markdown, ``spec-context.yml``,
|
|
6
|
+
``.acceptance-manifest.json``), so a plain git rebase/merge always conflicts on
|
|
7
|
+
them. The rules here apply the documented deterministic priority
|
|
8
|
+
(cf-task:start 4.4) programmatically:
|
|
9
|
+
|
|
10
|
+
- task markdown: the merging TASK owns its section (branch wins); the
|
|
11
|
+
Acceptance Coverage table unions rows by scenario id with status priority;
|
|
12
|
+
every other section comes from main (the accumulating side).
|
|
13
|
+
- ``spec-context.yml``: bindings/rules union; only each rule's latest stage
|
|
14
|
+
state and latest evidence entry survive. When the post-merge input hash is
|
|
15
|
+
supplied, a verified state whose recorded hash no longer matches is
|
|
16
|
+
downgraded to ``stale`` (old proof is retained for audit).
|
|
17
|
+
- ``.acceptance-manifest.json``: checks union by id; the same check id with a
|
|
18
|
+
different command/cwd/timeout/env/inputs fails closed, as does a scenario
|
|
19
|
+
mapped to two different checks. Scenarios union by id (verified / higher
|
|
20
|
+
revision wins); run history unions and dedupes by (run_id, revision).
|
|
21
|
+
|
|
22
|
+
Only pure text/dict transformations live here; git orchestration stays in
|
|
23
|
+
``cf_task_parallel.py``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from typing import Mapping, Optional, Sequence
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
|
|
35
|
+
from codeflow.acceptance.cf_acceptance_evidence import line_status
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_COVERAGE_ROW_RE = re.compile(r"^\s*(?:\|\s*|[-*]\s+)([SEB]-\d+)(?=\s*[:|])")
|
|
39
|
+
_SECTION_SPLIT_RE = re.compile(r"(?m)(?=^## )")
|
|
40
|
+
|
|
41
|
+
# Coverage/contract cell statuses: a verified row always wins; deferred states
|
|
42
|
+
# outrank still-planned ones so done tasks keep their e2e_deferred/manual_pending.
|
|
43
|
+
_SCENARIO_PRIORITY = {
|
|
44
|
+
"verified": 100,
|
|
45
|
+
"e2e_deferred": 60,
|
|
46
|
+
"manual_pending": 60,
|
|
47
|
+
"unverified": 50,
|
|
48
|
+
"failed": 50,
|
|
49
|
+
"incomplete": 50,
|
|
50
|
+
"not_configured": 50,
|
|
51
|
+
"pending": 20,
|
|
52
|
+
"tbd": 20,
|
|
53
|
+
"planned": 10,
|
|
54
|
+
"designed": 5,
|
|
55
|
+
"": 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_RULE_STATUS_PRIORITY = {
|
|
59
|
+
"verified": 60,
|
|
60
|
+
"applied": 50,
|
|
61
|
+
"not_applicable": 40,
|
|
62
|
+
"waived": 40,
|
|
63
|
+
"unverified": 30,
|
|
64
|
+
"pending": 20,
|
|
65
|
+
"stale": 10,
|
|
66
|
+
"conflict": 10,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Manifest v3 execution identity: equal IDs must describe the same execution.
|
|
70
|
+
_CHECK_IDENTITY_FIELDS = (
|
|
71
|
+
"command", "cwd", "timeout", "env", "env_scope", "preflight", "parallel_safe", "profile", "inputs",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MergeConflictError(ValueError):
|
|
76
|
+
"""Deterministic state merge refused because two sides disagree.
|
|
77
|
+
|
|
78
|
+
``conflicts`` is a tuple of JSON-ready records so callers (parallel merge)
|
|
79
|
+
can surface the exact check/scenario and fields without parsing prose.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, code: str, message: str,
|
|
83
|
+
conflicts: Sequence[Mapping[str, object]] = ()) -> None:
|
|
84
|
+
super().__init__(message)
|
|
85
|
+
self.code = code
|
|
86
|
+
self.conflicts = tuple(dict(item) for item in conflicts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _as_dicts(value: object) -> list[dict]:
|
|
90
|
+
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _keyed(items: list[dict], key: str) -> dict:
|
|
94
|
+
result = {}
|
|
95
|
+
for item in items:
|
|
96
|
+
value = item.get(key)
|
|
97
|
+
if isinstance(value, str) and value:
|
|
98
|
+
result[value] = item
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- task markdown -----------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _section_key(first_line: str) -> str:
|
|
106
|
+
heading = first_line.strip().lstrip("#").strip()
|
|
107
|
+
if heading.startswith("Acceptance Coverage"):
|
|
108
|
+
return "@coverage"
|
|
109
|
+
match = re.match(r"(TASK-\d+):", heading)
|
|
110
|
+
return match.group(1) if match else heading
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _split_sections(text: str) -> tuple[str, list[tuple[str, str]]]:
|
|
114
|
+
parts = _SECTION_SPLIT_RE.split(text)
|
|
115
|
+
preamble, blocks = parts[0], parts[1:]
|
|
116
|
+
sections: list[tuple[str, str]] = []
|
|
117
|
+
for block in blocks:
|
|
118
|
+
lines = block.splitlines()
|
|
119
|
+
if lines:
|
|
120
|
+
sections.append((_section_key(lines[0]), block))
|
|
121
|
+
return preamble, sections
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scenario_status(line: str, scenario_id: str) -> str:
|
|
125
|
+
return line_status(line, scenario_id) or ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _merge_coverage(main_block: str, branch_block: str) -> str:
|
|
129
|
+
if not branch_block:
|
|
130
|
+
return main_block
|
|
131
|
+
lines = main_block.splitlines()
|
|
132
|
+
trailing = len(main_block) - len(main_block.rstrip("\n"))
|
|
133
|
+
row_index: dict[str, int] = {}
|
|
134
|
+
last_table = -1
|
|
135
|
+
for index, line in enumerate(lines):
|
|
136
|
+
scenario_id = _COVERAGE_ROW_RE.match(line)
|
|
137
|
+
if scenario_id:
|
|
138
|
+
row_index[scenario_id.group(1)] = index
|
|
139
|
+
if line.strip().startswith("|"):
|
|
140
|
+
last_table = index
|
|
141
|
+
insert_at = last_table if last_table >= 0 else len(lines) - 1
|
|
142
|
+
for line in branch_block.splitlines():
|
|
143
|
+
match = _COVERAGE_ROW_RE.match(line)
|
|
144
|
+
if not match:
|
|
145
|
+
continue
|
|
146
|
+
scenario_id = match.group(1)
|
|
147
|
+
if scenario_id in row_index:
|
|
148
|
+
current = lines[row_index[scenario_id]]
|
|
149
|
+
if _SCENARIO_PRIORITY.get(_scenario_status(line, scenario_id), 0) > \
|
|
150
|
+
_SCENARIO_PRIORITY.get(_scenario_status(current, scenario_id), 0):
|
|
151
|
+
lines[row_index[scenario_id]] = line
|
|
152
|
+
else:
|
|
153
|
+
insert_at += 1
|
|
154
|
+
lines.insert(insert_at, line)
|
|
155
|
+
row_index[scenario_id] = insert_at
|
|
156
|
+
return "\n".join(lines) + "\n" * trailing
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def merge_task_markdown(main_text: str, branch_text: str, task_id: str) -> str:
|
|
160
|
+
"""Merge the task file: own TASK section from branch, coverage union, rest main."""
|
|
161
|
+
main_preamble, main_sections = _split_sections(main_text)
|
|
162
|
+
_, branch_sections = _split_sections(branch_text)
|
|
163
|
+
branch_map = dict(branch_sections)
|
|
164
|
+
ordered: list[str] = []
|
|
165
|
+
seen: set[str] = set()
|
|
166
|
+
for key, block in main_sections:
|
|
167
|
+
seen.add(key)
|
|
168
|
+
if key == task_id:
|
|
169
|
+
block = branch_map.get(task_id, block)
|
|
170
|
+
elif key == "@coverage":
|
|
171
|
+
block = _merge_coverage(block, branch_map.get("@coverage", ""))
|
|
172
|
+
ordered.append(block)
|
|
173
|
+
for key, block in branch_sections:
|
|
174
|
+
if key not in seen:
|
|
175
|
+
ordered.append(block)
|
|
176
|
+
return main_preamble + "".join(ordered)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- spec-context.yml --------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _merge_sources(main: object, branch: object) -> list[dict]:
|
|
183
|
+
merged, seen = [], set()
|
|
184
|
+
for item in _as_dicts(main) + _as_dicts(branch):
|
|
185
|
+
key = (item.get("type"), item.get("ref"))
|
|
186
|
+
if key not in seen:
|
|
187
|
+
seen.add(key)
|
|
188
|
+
merged.append(item)
|
|
189
|
+
return merged
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _merge_refs(main: object, branch: object) -> list[dict]:
|
|
193
|
+
merged, seen = [], set()
|
|
194
|
+
for item in _as_dicts(main) + _as_dicts(branch):
|
|
195
|
+
key = (item.get("artifact"), item.get("section_id"),
|
|
196
|
+
item.get("item_id"), item.get("artifact_sha256"))
|
|
197
|
+
if key not in seen:
|
|
198
|
+
seen.add(key)
|
|
199
|
+
merged.append(item)
|
|
200
|
+
return merged
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _evidence_time(item: Mapping[str, object]) -> float:
|
|
204
|
+
value = item.get("executed_at")
|
|
205
|
+
if not isinstance(value, str) or not value.strip():
|
|
206
|
+
return 0.0
|
|
207
|
+
try:
|
|
208
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
209
|
+
except ValueError:
|
|
210
|
+
return 0.0
|
|
211
|
+
if parsed.tzinfo is None:
|
|
212
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
213
|
+
return parsed.timestamp()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _latest_evidence(status: Mapping[str, object]) -> Optional[dict]:
|
|
217
|
+
evidence = _as_dicts(status.get("evidence"))
|
|
218
|
+
if not evidence:
|
|
219
|
+
return None
|
|
220
|
+
# Timestamp wins; equal/missing timestamps keep list order (last entry).
|
|
221
|
+
return max(enumerate(evidence), key=lambda pair: (_evidence_time(pair[1]), pair[0]))[1]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _latest_status_time(status: Mapping[str, object]) -> float:
|
|
225
|
+
evidence = _as_dicts(status.get("evidence"))
|
|
226
|
+
return max((_evidence_time(item) for item in evidence), default=0.0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _choose_stage(main: object, branch: object) -> dict:
|
|
230
|
+
if not isinstance(main, dict):
|
|
231
|
+
return dict(branch) if isinstance(branch, dict) else {}
|
|
232
|
+
if not isinstance(branch, dict):
|
|
233
|
+
return dict(main)
|
|
234
|
+
main_time = _latest_status_time(main)
|
|
235
|
+
branch_time = _latest_status_time(branch)
|
|
236
|
+
if branch_time != main_time:
|
|
237
|
+
return dict(branch if branch_time > main_time else main)
|
|
238
|
+
main_status = str(main.get("status", ""))
|
|
239
|
+
branch_status = str(branch.get("status", ""))
|
|
240
|
+
if _RULE_STATUS_PRIORITY.get(branch_status, 0) > _RULE_STATUS_PRIORITY.get(main_status, 0):
|
|
241
|
+
return dict(branch)
|
|
242
|
+
return dict(main)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _merge_stage(main: object, branch: object, input_hash: Optional[str]) -> object:
|
|
246
|
+
stage = _choose_stage(main, branch)
|
|
247
|
+
if not stage:
|
|
248
|
+
return stage
|
|
249
|
+
if isinstance(main, dict) and isinstance(branch, dict):
|
|
250
|
+
stage["refs"] = _merge_refs(main.get("refs"), branch.get("refs"))
|
|
251
|
+
stage["decision"] = main.get("decision") or branch.get("decision")
|
|
252
|
+
stage["evidence"] = _single_latest_evidence(stage, main, branch)
|
|
253
|
+
else:
|
|
254
|
+
stage["evidence"] = _single_latest_evidence(stage, stage, stage)
|
|
255
|
+
return _downgrade_stale(stage, input_hash)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _single_latest_evidence(chosen: Mapping[str, object], main: Mapping[str, object],
|
|
259
|
+
branch: Mapping[str, object]) -> list[dict]:
|
|
260
|
+
latest = _latest_evidence(chosen)
|
|
261
|
+
if latest is None:
|
|
262
|
+
latest = _latest_evidence(main) or _latest_evidence(branch)
|
|
263
|
+
return [latest] if latest is not None else []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _evidence_input_hash(evidence: Mapping[str, object]) -> Optional[str]:
|
|
267
|
+
for key in ("input_hash", "diff_sha256"):
|
|
268
|
+
value = evidence.get(key)
|
|
269
|
+
if isinstance(value, str) and value:
|
|
270
|
+
return value
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _downgrade_stale(stage: dict, input_hash: Optional[str]) -> dict:
|
|
275
|
+
"""A verified proof is only valid for the input hash it ran against."""
|
|
276
|
+
if not input_hash or stage.get("status") != "verified":
|
|
277
|
+
return stage
|
|
278
|
+
latest = _latest_evidence(stage)
|
|
279
|
+
recorded = _evidence_input_hash(latest) if latest is not None else None
|
|
280
|
+
if recorded != input_hash:
|
|
281
|
+
stage["status"] = "stale"
|
|
282
|
+
return stage
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _merge_stage_status(main: object, branch: object, input_hash: Optional[str]) -> dict:
|
|
286
|
+
main_map = main if isinstance(main, dict) else {}
|
|
287
|
+
branch_map = branch if isinstance(branch, dict) else {}
|
|
288
|
+
return {
|
|
289
|
+
stage: _merge_stage(main_map.get(stage), branch_map.get(stage), input_hash)
|
|
290
|
+
for stage in sorted(set(main_map) | set(branch_map))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _merge_rule(main: dict, branch: dict, input_hash: Optional[str]) -> dict:
|
|
295
|
+
merged = dict(main)
|
|
296
|
+
merged["stage_status"] = _merge_stage_status(
|
|
297
|
+
main.get("stage_status"), branch.get("stage_status"), input_hash
|
|
298
|
+
)
|
|
299
|
+
return merged
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _merge_by_ref(main: object, branch: object, input_hash: Optional[str]) -> list[dict]:
|
|
303
|
+
main_by = _keyed(_as_dicts(main), "ref")
|
|
304
|
+
branch_by = _keyed(_as_dicts(branch), "ref")
|
|
305
|
+
merged: list[dict] = []
|
|
306
|
+
for ref in sorted(set(main_by) | set(branch_by)):
|
|
307
|
+
a, b = main_by.get(ref), branch_by.get(ref)
|
|
308
|
+
if a and b:
|
|
309
|
+
merged.append(_merge_rule(a, b, input_hash))
|
|
310
|
+
else:
|
|
311
|
+
merged.append(a or b)
|
|
312
|
+
return merged
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _merge_binding(main: dict, branch: dict, input_hash: Optional[str]) -> dict:
|
|
316
|
+
merged = dict(main)
|
|
317
|
+
merged["rules"] = _merge_by_ref(main.get("rules"), branch.get("rules"), input_hash)
|
|
318
|
+
return merged
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _merge_bindings(main: object, branch: object, input_hash: Optional[str]) -> list[dict]:
|
|
322
|
+
main_by = _keyed(_as_dicts(main), "spec_id")
|
|
323
|
+
branch_by = _keyed(_as_dicts(branch), "spec_id")
|
|
324
|
+
merged: list[dict] = []
|
|
325
|
+
for spec_id in sorted(set(main_by) | set(branch_by)):
|
|
326
|
+
a, b = main_by.get(spec_id), branch_by.get(spec_id)
|
|
327
|
+
if a and b:
|
|
328
|
+
merged.append(_merge_binding(a, b, input_hash))
|
|
329
|
+
else:
|
|
330
|
+
merged.append(a or b)
|
|
331
|
+
return merged
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _each_stage(doc: Mapping[str, object]):
|
|
335
|
+
for binding in _as_dicts(doc.get("bindings")):
|
|
336
|
+
for rule in _as_dicts(binding.get("rules")):
|
|
337
|
+
stage_status = rule.get("stage_status")
|
|
338
|
+
if not isinstance(stage_status, dict):
|
|
339
|
+
continue
|
|
340
|
+
for stage, status in list(stage_status.items()):
|
|
341
|
+
if isinstance(status, dict):
|
|
342
|
+
yield stage_status, stage, status
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _compact_context(doc: dict) -> dict:
|
|
346
|
+
"""Keep only the latest evidence entry for every rule/stage."""
|
|
347
|
+
for stage_status, stage, status in _each_stage(doc):
|
|
348
|
+
stage_status[stage] = {
|
|
349
|
+
**status,
|
|
350
|
+
"evidence": _single_latest_evidence(status, status, status),
|
|
351
|
+
}
|
|
352
|
+
return doc
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _downgrade_context(doc: dict, input_hash: Optional[str]) -> dict:
|
|
356
|
+
if not input_hash:
|
|
357
|
+
return doc
|
|
358
|
+
for stage_status, stage, status in _each_stage(doc):
|
|
359
|
+
stage_status[stage] = _downgrade_stale(dict(status), input_hash)
|
|
360
|
+
return doc
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def merge_context_docs(main: Mapping[str, object], branch: Mapping[str, object],
|
|
364
|
+
input_hash: Optional[str] = None) -> dict:
|
|
365
|
+
"""Union metadata but keep only each rule's latest state/evidence.
|
|
366
|
+
|
|
367
|
+
``input_hash`` is the post-merge execution input fingerprint. Verified
|
|
368
|
+
states whose recorded hash differs are downgraded to ``stale``.
|
|
369
|
+
"""
|
|
370
|
+
merged = dict(main)
|
|
371
|
+
merged["updated_at"] = max(
|
|
372
|
+
str(main.get("updated_at", "")), str(branch.get("updated_at", ""))
|
|
373
|
+
)
|
|
374
|
+
merged["sources"] = _merge_sources(main.get("sources"), branch.get("sources"))
|
|
375
|
+
merged["bindings"] = _merge_bindings(main.get("bindings"), branch.get("bindings"), None)
|
|
376
|
+
_compact_context(merged)
|
|
377
|
+
return _downgrade_context(merged, input_hash)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def merge_context_text(main_text: str, branch_text: str,
|
|
381
|
+
input_hash: Optional[str] = None) -> str:
|
|
382
|
+
try:
|
|
383
|
+
main_doc = yaml.safe_load(main_text) or {}
|
|
384
|
+
branch_doc = yaml.safe_load(branch_text) or {}
|
|
385
|
+
except yaml.YAMLError as exc:
|
|
386
|
+
raise ValueError(f"spec-context.yml 解析失败: {exc}") from exc
|
|
387
|
+
if not isinstance(main_doc, dict) or not isinstance(branch_doc, dict):
|
|
388
|
+
raise ValueError("spec-context.yml must be a mapping on both sides")
|
|
389
|
+
merged = merge_context_docs(main_doc, branch_doc, input_hash)
|
|
390
|
+
return yaml.safe_dump(merged, sort_keys=False, allow_unicode=True)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def stale_context_text(text: str, input_hash: str) -> tuple[str, list[str]]:
|
|
394
|
+
"""Downgrade verified rules whose evidence hash no longer matches."""
|
|
395
|
+
try:
|
|
396
|
+
doc = yaml.safe_load(text) or {}
|
|
397
|
+
except yaml.YAMLError as exc:
|
|
398
|
+
raise ValueError(f"spec-context.yml 解析失败: {exc}") from exc
|
|
399
|
+
if not isinstance(doc, dict):
|
|
400
|
+
raise ValueError("spec-context.yml must be a mapping")
|
|
401
|
+
before = _stale_statuses(doc)
|
|
402
|
+
if not input_hash:
|
|
403
|
+
return text, []
|
|
404
|
+
_downgrade_context(doc, input_hash)
|
|
405
|
+
stale = sorted(set(_stale_statuses(doc)) - set(before))
|
|
406
|
+
if not stale:
|
|
407
|
+
return text, []
|
|
408
|
+
return yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), stale
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _stale_statuses(doc: Mapping[str, object]) -> list[str]:
|
|
412
|
+
refs: list[str] = []
|
|
413
|
+
for binding in _as_dicts(doc.get("bindings")):
|
|
414
|
+
spec_id = str(binding.get("spec_id", ""))
|
|
415
|
+
for rule in _as_dicts(binding.get("rules")):
|
|
416
|
+
for stage, status in (rule.get("stage_status") or {}).items():
|
|
417
|
+
if isinstance(status, Mapping) and status.get("status") == "stale":
|
|
418
|
+
refs.append(f"{spec_id}#{rule.get('ref')}.{stage}")
|
|
419
|
+
return refs
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --- .acceptance-manifest.json ----------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _normalized_check_field(field: str, value: object) -> object:
|
|
426
|
+
if field == "timeout":
|
|
427
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else value
|
|
428
|
+
if field == "inputs":
|
|
429
|
+
return sorted(str(item) for item in value) if isinstance(value, list) else value
|
|
430
|
+
if field == "preflight":
|
|
431
|
+
return sorted(str(item) for item in value) if isinstance(value, list) else value
|
|
432
|
+
if field == "command":
|
|
433
|
+
return [str(item) for item in value] if isinstance(value, list) else value
|
|
434
|
+
return value
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _check_identity(check: Mapping[str, object]) -> dict[str, object]:
|
|
438
|
+
defaults = {
|
|
439
|
+
"command": [], "cwd": ".", "timeout": 60, "env": "none",
|
|
440
|
+
"env_scope": "shared", "preflight": [], "parallel_safe": False,
|
|
441
|
+
"profile": "changed", "inputs": [],
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
field: _normalized_check_field(field, check.get(field, defaults[field]))
|
|
445
|
+
for field in _CHECK_IDENTITY_FIELDS
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _check_conflict(check_id: str, main: Mapping[str, object],
|
|
450
|
+
branch: Mapping[str, object]) -> Optional[dict[str, object]]:
|
|
451
|
+
main_identity = _check_identity(main)
|
|
452
|
+
branch_identity = _check_identity(branch)
|
|
453
|
+
fields = [
|
|
454
|
+
field for field in _CHECK_IDENTITY_FIELDS
|
|
455
|
+
if main_identity.get(field) != branch_identity.get(field)
|
|
456
|
+
]
|
|
457
|
+
if not fields:
|
|
458
|
+
return None
|
|
459
|
+
return {
|
|
460
|
+
"check_id": check_id,
|
|
461
|
+
"fields": fields,
|
|
462
|
+
"main": {field: main_identity.get(field) for field in fields},
|
|
463
|
+
"branch": {field: branch_identity.get(field) for field in fields},
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _check_conflicts(main: object, branch: object) -> list[dict[str, object]]:
|
|
468
|
+
main_by = _keyed(_as_dicts(main), "id")
|
|
469
|
+
branch_by = _keyed(_as_dicts(branch), "id")
|
|
470
|
+
conflicts: list[dict[str, object]] = []
|
|
471
|
+
for check_id in sorted(set(main_by) & set(branch_by)):
|
|
472
|
+
conflict = _check_conflict(check_id, main_by[check_id], branch_by[check_id])
|
|
473
|
+
if conflict is not None:
|
|
474
|
+
conflicts.append(conflict)
|
|
475
|
+
# A check id can change when config changes; the same command identity must
|
|
476
|
+
# still not silently take two different environments.
|
|
477
|
+
main_cmd = {
|
|
478
|
+
(tuple(item.get("command") or []), str(item.get("cwd", ".")), float(item.get("timeout", 60))): item
|
|
479
|
+
for item in _as_dicts(main)
|
|
480
|
+
}
|
|
481
|
+
for item in _as_dicts(branch):
|
|
482
|
+
key = (tuple(item.get("command") or []), str(item.get("cwd", ".")), float(item.get("timeout", 60)))
|
|
483
|
+
other = main_cmd.get(key)
|
|
484
|
+
if other is None or str(other.get("id")) == str(item.get("id")):
|
|
485
|
+
continue
|
|
486
|
+
conflict = _check_conflict(str(other.get("id", "")), other, item)
|
|
487
|
+
if conflict is not None:
|
|
488
|
+
conflicts.append(conflict)
|
|
489
|
+
return conflicts
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def compare_manifest_checks(main: Mapping[str, object], branch: Mapping[str, object]) -> list[dict[str, object]]:
|
|
493
|
+
"""Report conflicting check contracts before merging two locked manifests."""
|
|
494
|
+
return _check_conflicts(main.get("checks"), branch.get("checks"))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _merge_checks(main: object, branch: object) -> list[dict]:
|
|
498
|
+
main_rows = _as_dicts(main)
|
|
499
|
+
branch_by = _keyed(_as_dicts(branch), "id")
|
|
500
|
+
merged: list[dict] = []
|
|
501
|
+
seen: set = set()
|
|
502
|
+
conflicts: list[dict[str, object]] = []
|
|
503
|
+
for row in main_rows:
|
|
504
|
+
check_id = row.get("id")
|
|
505
|
+
seen.add(check_id)
|
|
506
|
+
other = branch_by.get(check_id) if isinstance(check_id, str) else None
|
|
507
|
+
if other is not None:
|
|
508
|
+
conflict = _check_conflict(str(check_id), row, other)
|
|
509
|
+
if conflict is not None:
|
|
510
|
+
conflicts.append(conflict)
|
|
511
|
+
merged.append({**other, **row})
|
|
512
|
+
else:
|
|
513
|
+
merged.append(row)
|
|
514
|
+
for row in _as_dicts(branch):
|
|
515
|
+
if row.get("id") not in seen:
|
|
516
|
+
merged.append(row)
|
|
517
|
+
if conflicts:
|
|
518
|
+
detail = ", ".join(
|
|
519
|
+
f"{item['check_id']}({','.join(str(field) for field in item['fields'])})"
|
|
520
|
+
for item in conflicts
|
|
521
|
+
)
|
|
522
|
+
raise MergeConflictError(
|
|
523
|
+
"check_conflict",
|
|
524
|
+
f"acceptance manifest check 冲突(同 ID 字段不一致): {detail}",
|
|
525
|
+
conflicts,
|
|
526
|
+
)
|
|
527
|
+
return merged
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _scenario_conflicts(main: object, branch: object) -> list[dict[str, object]]:
|
|
531
|
+
main_by = _keyed(_as_dicts(main), "id")
|
|
532
|
+
branch_by = _keyed(_as_dicts(branch), "id")
|
|
533
|
+
conflicts: list[dict[str, object]] = []
|
|
534
|
+
for scenario_id in sorted(set(main_by) & set(branch_by)):
|
|
535
|
+
main_check = main_by[scenario_id].get("check")
|
|
536
|
+
branch_check = branch_by[scenario_id].get("check")
|
|
537
|
+
if (
|
|
538
|
+
isinstance(main_check, str) and main_check
|
|
539
|
+
and isinstance(branch_check, str) and branch_check
|
|
540
|
+
and main_check != branch_check
|
|
541
|
+
):
|
|
542
|
+
conflicts.append({
|
|
543
|
+
"scenario_id": scenario_id,
|
|
544
|
+
"fields": ["check"],
|
|
545
|
+
"main": main_check,
|
|
546
|
+
"branch": branch_check,
|
|
547
|
+
})
|
|
548
|
+
return conflicts
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _merge_runs(main: object, branch: object) -> list[dict]:
|
|
552
|
+
merged, seen = [], set()
|
|
553
|
+
for item in _as_dicts(main) + _as_dicts(branch):
|
|
554
|
+
key = (item.get("run_id"), item.get("revision"))
|
|
555
|
+
if key not in seen:
|
|
556
|
+
seen.add(key)
|
|
557
|
+
merged.append(item)
|
|
558
|
+
merged.sort(key=lambda item: (int(item.get("revision", 0) or 0), str(item.get("run_id", ""))))
|
|
559
|
+
return merged
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _choose_scenario(main: dict, branch: dict) -> dict:
|
|
563
|
+
main_status = str(main.get("status", ""))
|
|
564
|
+
branch_status = str(branch.get("status", ""))
|
|
565
|
+
if main_status == "verified" and branch_status != "verified":
|
|
566
|
+
return main
|
|
567
|
+
if branch_status == "verified" and main_status != "verified":
|
|
568
|
+
return branch
|
|
569
|
+
main_revision = int(main.get("revision", 0) or 0)
|
|
570
|
+
branch_revision = int(branch.get("revision", 0) or 0)
|
|
571
|
+
return main if main_revision >= branch_revision else branch
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _merge_scenario(main: dict, branch: dict) -> dict:
|
|
575
|
+
merged = dict(_choose_scenario(main, branch))
|
|
576
|
+
merged["runs"] = _merge_runs(main.get("runs"), branch.get("runs"))
|
|
577
|
+
if not merged.get("check"):
|
|
578
|
+
merged["check"] = main.get("check") or branch.get("check")
|
|
579
|
+
return merged
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _merge_scenarios(main: object, branch: object) -> list[dict]:
|
|
583
|
+
main_rows = _as_dicts(main)
|
|
584
|
+
branch_by = _keyed(_as_dicts(branch), "id")
|
|
585
|
+
merged: list[dict] = []
|
|
586
|
+
seen: set = set()
|
|
587
|
+
for row in main_rows:
|
|
588
|
+
scenario_id = row.get("id")
|
|
589
|
+
seen.add(scenario_id)
|
|
590
|
+
if scenario_id in branch_by:
|
|
591
|
+
merged.append(_merge_scenario(row, branch_by[scenario_id]))
|
|
592
|
+
else:
|
|
593
|
+
merged.append(row)
|
|
594
|
+
for row in _as_dicts(branch):
|
|
595
|
+
if row.get("id") not in seen:
|
|
596
|
+
merged.append(row)
|
|
597
|
+
return merged
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _merged_schema(main: Mapping[str, object], branch: Mapping[str, object]) -> object:
|
|
601
|
+
schema = main.get("schema")
|
|
602
|
+
other = branch.get("schema")
|
|
603
|
+
if isinstance(schema, int) and isinstance(other, int):
|
|
604
|
+
return max(schema, other)
|
|
605
|
+
return schema
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def merge_manifest_docs(main: Mapping[str, object], branch: Mapping[str, object]) -> dict:
|
|
609
|
+
merged_checks = _merge_checks(main.get("checks"), branch.get("checks")) if ("checks" in main or "checks" in branch) else None
|
|
610
|
+
conflicts = _scenario_conflicts(main.get("scenarios"), branch.get("scenarios"))
|
|
611
|
+
if conflicts:
|
|
612
|
+
detail = ", ".join(
|
|
613
|
+
f"{item['scenario_id']}({item['main']}->{item['branch']})" for item in conflicts
|
|
614
|
+
)
|
|
615
|
+
raise MergeConflictError(
|
|
616
|
+
"scenario_check_conflict",
|
|
617
|
+
f"scenario→check 映射冲突: {detail}",
|
|
618
|
+
conflicts,
|
|
619
|
+
)
|
|
620
|
+
merged = dict(main)
|
|
621
|
+
if "schema" in main or "schema" in branch:
|
|
622
|
+
merged["schema"] = _merged_schema(main, branch)
|
|
623
|
+
if "checks" in main or "checks" in branch:
|
|
624
|
+
merged["checks"] = _merge_checks(main.get("checks"), branch.get("checks"))
|
|
625
|
+
merged["scenarios"] = _merge_scenarios(main.get("scenarios"), branch.get("scenarios"))
|
|
626
|
+
return merged
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def merge_manifest_text(main_text: str, branch_text: str) -> str:
|
|
630
|
+
main_doc = json.loads(main_text)
|
|
631
|
+
branch_doc = json.loads(branch_text)
|
|
632
|
+
if not isinstance(main_doc, dict) or not isinstance(branch_doc, dict):
|
|
633
|
+
raise ValueError("acceptance manifest must be an object on both sides")
|
|
634
|
+
merged = merge_manifest_docs(main_doc, branch_doc)
|
|
635
|
+
return json.dumps(merged, ensure_ascii=False, indent=2) + "\n"
|