@harmonyos-arkts/d2h 0.1.27 → 0.1.29
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/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/README.md +43 -3
- package/agents/d2h.md +16 -14
- package/dist/bin/install-opencode.js +28 -23
- package/dist/src/install-codex.js +5 -126
- package/dist/src/install-skills.js +147 -0
- package/dist/src/install-vscode.js +2 -2
- package/dist/src/select-agent.js +207 -0
- package/docs/environment-check.md +3 -3
- package/package.json +6 -6
- package/skills/arkts-mvvm-architecture/SKILL.md +2 -0
- package/skills/d2h-architecture-plan/SKILL.md +19 -26
- package/skills/d2h-architecture-plan/references/architecture-plan.example.json +80 -315
- package/skills/d2h-architecture-plan/references/directory-creation.md +27 -34
- package/skills/d2h-architecture-plan/references/hybrid-design.md +13 -15
- package/skills/d2h-architecture-plan/references/json-contract.md +29 -91
- package/skills/d2h-architecture-plan/references/layout-reference.md +30 -27
- package/skills/d2h-architecture-plan/references/plan-contract.md +27 -34
- package/skills/d2h-architecture-plan/references/source-review.md +1 -1
- package/skills/d2h-architecture-plan/scripts/architecture_plan.py +29 -163
- package/skills/d2h-architecture-plan/scripts/test_architecture_plan.py +205 -342
- package/skills/d2h-spec-develop/SKILL.md +5 -4
- package/{install-opencode.sh → tooling/install-opencode.sh} +3 -3
- package/{opencode → tooling/opencode}/interactive-tools/advance-progress-demo.mjs +1 -1
- package/{opencode → tooling/opencode}/interactive-tools/demo.mjs +3 -3
- package/{opencode → tooling/opencode}/interactive-tools/prepare-demo.mjs +1 -1
- package/{scripts → tooling/scripts}/check-environment.mjs +3 -3
- package/skills/d2h-architecture-plan/references/selection-flow.md +0 -71
- /package/{opencode → tooling/opencode}/agents.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/browser-host.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/core.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/demo-workflow.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/package-lock.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/package.json +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/panel.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/prepare-progress-demo.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/progress.ts +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/server-legacy.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/server.mjs +0 -0
- /package/{opencode → tooling/opencode}/interactive-tools/tui.tsx +0 -0
- /package/{opencode → tooling/opencode}/vscode-d2h-package.json +0 -0
- /package/{opencode → tooling/opencode}/vscode-d2h-plugin.mjs +0 -0
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Final-display contract checks. No source or HarmonyOS project is touched."""
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
-
import contextlib
|
|
5
|
-
import io
|
|
6
4
|
import json
|
|
7
5
|
import os
|
|
8
6
|
from pathlib import Path
|
|
@@ -10,10 +8,8 @@ import subprocess
|
|
|
10
8
|
import sys
|
|
11
9
|
import tempfile
|
|
12
10
|
import unittest
|
|
13
|
-
from unittest.mock import patch
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
from architecture_plan import ContractError, apply_selection, read_selection, validate_plan
|
|
12
|
+
from architecture_plan import ContractError, directory_tree, load_json, validate_plan
|
|
17
13
|
|
|
18
14
|
|
|
19
15
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -22,356 +18,223 @@ HELPER = SKILL_ROOT / "scripts" / "architecture_plan.py"
|
|
|
22
18
|
|
|
23
19
|
class ArchitecturePlanTests(unittest.TestCase):
|
|
24
20
|
def setUp(self):
|
|
25
|
-
self.plan =
|
|
26
|
-
self.decision = {
|
|
27
|
-
"requestId": "request-1", "planId": self.plan["planId"],
|
|
28
|
-
"selectedProposalId": "android-mirror",
|
|
29
|
-
"action": "select-and-create", "source": "dashboard", "notes": "",
|
|
30
|
-
}
|
|
21
|
+
self.plan = load_json(SKILL_ROOT / "references" / "architecture-plan.example.json")
|
|
31
22
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
self.plan["androidAnalysis"]["risks"] = []
|
|
39
|
-
validate_plan(self.plan)
|
|
40
|
-
self.assertEqual(set(self.plan["androidAnalysis"]), {"summary", "techStack", "risks"})
|
|
41
|
-
self.assertEqual(set(self.plan["project"]), {"name", "summary"})
|
|
42
|
-
for proposal in self.plan["proposals"]:
|
|
43
|
-
self.assertEqual(set(proposal), {"id", "name", "summary", "directoryTree"})
|
|
44
|
-
updated, result = apply_selection(self.plan, self.decision)
|
|
45
|
-
self.assertEqual(result["nextAction"], "create-directory-shell")
|
|
46
|
-
self.assertEqual(updated["androidAnalysis"], self.plan["androidAnalysis"])
|
|
47
|
-
|
|
48
|
-
def test_selecting_nonrecommended_scheme_does_not_change_recommendation_or_trees(self):
|
|
49
|
-
original = copy.deepcopy(self.plan)
|
|
50
|
-
updated, result = apply_selection(self.plan, self.decision)
|
|
51
|
-
self.assertEqual(self.plan, original)
|
|
52
|
-
self.assertEqual(updated["recommendedProposalId"], "hybrid")
|
|
53
|
-
self.assertEqual(updated["selectedProposalId"], "android-mirror")
|
|
54
|
-
self.assertEqual(updated["proposals"], original["proposals"])
|
|
55
|
-
self.assertNotIn("selection", updated)
|
|
56
|
-
proposal, confirmation = read_selection(updated, self.decision)
|
|
57
|
-
self.assertEqual(proposal, updated["proposals"][0])
|
|
58
|
-
self.assertEqual(confirmation["requestId"], self.decision["requestId"])
|
|
59
|
-
self.assertEqual(result["nextAction"], "create-directory-shell")
|
|
60
|
-
validate_plan(updated)
|
|
61
|
-
|
|
62
|
-
def test_select_only_does_not_instruct_creation(self):
|
|
63
|
-
self.decision["action"] = "select"
|
|
64
|
-
updated, result = apply_selection(self.plan, self.decision)
|
|
65
|
-
self.assertEqual(result["nextAction"], "selection-recorded")
|
|
66
|
-
self.assertEqual(updated["selectedProposalId"], "android-mirror")
|
|
23
|
+
def nodes(self):
|
|
24
|
+
def visit(node):
|
|
25
|
+
yield node
|
|
26
|
+
for child in node["children"]:
|
|
27
|
+
yield from visit(child)
|
|
28
|
+
return list(visit(self.plan["directoryTree"]))
|
|
67
29
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
retry, result = apply_selection(updated, self.decision, self.decision)
|
|
71
|
-
self.assertEqual(retry, updated)
|
|
72
|
-
self.assertEqual(result["status"], "already-applied")
|
|
30
|
+
def node(self, path):
|
|
31
|
+
return next(node for node in self.nodes() if node["path"] == path)
|
|
73
32
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
changed = {**self.decision, key: value}
|
|
78
|
-
with self.subTest(key=key), self.assertRaisesRegex(ContractError, "requestId"):
|
|
79
|
-
apply_selection(updated, changed, self.decision)
|
|
33
|
+
def cli(self, *args, env=None):
|
|
34
|
+
return subprocess.run([sys.executable, "-X", "utf8", "-B", str(HELPER), *map(str, args)],
|
|
35
|
+
capture_output=True, encoding="utf-8", env=env, check=False)
|
|
80
36
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
changed = {**self.decision, "requestId": "request-2", "selectedProposalId": "harmony-practice"}
|
|
84
|
-
final, result = apply_selection(updated, changed, self.decision)
|
|
85
|
-
self.assertEqual(result["status"], "applied")
|
|
86
|
-
self.assertEqual(final["selectedProposalId"], "harmony-practice")
|
|
87
|
-
_, confirmation = read_selection(final, changed)
|
|
88
|
-
self.assertEqual(confirmation["selectedProposalId"], "harmony-practice")
|
|
89
|
-
|
|
90
|
-
def test_wrong_plan_scheme_action_and_extra_paths_are_rejected(self):
|
|
91
|
-
for key, value in (
|
|
92
|
-
("planId", "another-project"), ("planId", True),
|
|
93
|
-
("selectedProposalId", "unknown"), ("action", "run-command"),
|
|
94
|
-
("targetRoot", "E:/other"), ("directoryTree", {}),
|
|
95
|
-
):
|
|
96
|
-
with self.subTest(key=key), self.assertRaises(ContractError):
|
|
97
|
-
apply_selection(self.plan, {**self.decision, key: value})
|
|
98
|
-
self.assertIsNone(self.plan["selectedProposalId"])
|
|
99
|
-
|
|
100
|
-
def test_plan_and_callback_only_need_content_and_selection_metadata(self):
|
|
101
|
-
self.assertEqual(set(self.plan), {
|
|
102
|
-
"planId", "project", "androidAnalysis", "proposals", "recommendedProposalId",
|
|
103
|
-
"recommendationReason", "selectedProposalId",
|
|
104
|
-
})
|
|
105
|
-
self.assertEqual(set(self.decision), {
|
|
106
|
-
"requestId", "planId", "selectedProposalId", "action", "source", "notes",
|
|
107
|
-
})
|
|
37
|
+
def test_example_is_one_final_tree_with_decision_notes(self):
|
|
38
|
+
before = copy.deepcopy(self.plan)
|
|
108
39
|
validate_plan(self.plan)
|
|
109
|
-
|
|
110
|
-
self.assertEqual(set(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
with self.subTest(
|
|
141
|
-
validate_plan(
|
|
40
|
+
self.assertEqual(self.plan, before)
|
|
41
|
+
self.assertEqual(set(self.plan), {"projectName", "directoryTree", "decisions"})
|
|
42
|
+
paths = directory_tree(self.plan["directoryTree"])
|
|
43
|
+
self.assertIn("entry/src/main/ets/features/mail/messageview", paths)
|
|
44
|
+
self.assertNotIn("products/thunderbird", paths)
|
|
45
|
+
self.assertTrue(self.plan["decisions"])
|
|
46
|
+
|
|
47
|
+
def test_missing_extra_and_legacy_top_fields_are_rejected(self):
|
|
48
|
+
for key in self.plan:
|
|
49
|
+
changed = copy.deepcopy(self.plan)
|
|
50
|
+
del changed[key]
|
|
51
|
+
with self.subTest(missing=key), self.assertRaises(ContractError):
|
|
52
|
+
validate_plan(changed)
|
|
53
|
+
for key in ("project", "androidAnalysis", "proposals", "recommendedProposalId", "recommendationReason",
|
|
54
|
+
"selectedProposalId", "selection", "planId", "revision", "schemaVersion", "targetRoot", "dependencies"):
|
|
55
|
+
with self.subTest(extra=key), self.assertRaises(ContractError):
|
|
56
|
+
validate_plan({**self.plan, key: None})
|
|
57
|
+
|
|
58
|
+
def test_project_name_and_decision_fields_must_be_nonempty_text(self):
|
|
59
|
+
for value in (None, True, 0, [], {}, "", " \n"):
|
|
60
|
+
with self.subTest(projectName=value), self.assertRaises(ContractError):
|
|
61
|
+
validate_plan({**self.plan, "projectName": value})
|
|
62
|
+
for key in ("title", "description"):
|
|
63
|
+
changed = copy.deepcopy(self.plan)
|
|
64
|
+
changed["decisions"][0][key] = value
|
|
65
|
+
with self.subTest(decision=key, value=value), self.assertRaises(ContractError):
|
|
66
|
+
validate_plan(changed)
|
|
67
|
+
|
|
68
|
+
def test_decisions_require_a_nonempty_exactly_shaped_array(self):
|
|
69
|
+
for value in (None, {}, "decision", [], [{}], [{"title": "保留"}],
|
|
70
|
+
[{"title": "保留", "description": "原包族", "confirmed": True}]):
|
|
71
|
+
with self.subTest(value=value), self.assertRaises(ContractError):
|
|
72
|
+
validate_plan({**self.plan, "decisions": value})
|
|
73
|
+
|
|
74
|
+
def test_old_dependency_array_and_embedded_relationship_fields_are_rejected(self):
|
|
75
|
+
for value in ([], [{"fromPath": "entry", "toPath": "AppScope", "description": "旧关系模型"}]):
|
|
76
|
+
with self.subTest(value=value), self.assertRaises(ContractError):
|
|
77
|
+
validate_plan({**self.plan, "dependencies": value})
|
|
78
|
+
for key, value in (("fromPath", "entry"), ("toPath", "AppScope"), ("dependencies", [])):
|
|
79
|
+
changed = copy.deepcopy(self.plan)
|
|
80
|
+
changed["decisions"][0][key] = value
|
|
81
|
+
with self.subTest(key=key), self.assertRaises(ContractError):
|
|
82
|
+
validate_plan(changed)
|
|
83
|
+
|
|
84
|
+
def test_nodes_require_description_children_and_known_fields(self):
|
|
85
|
+
for key in ("name", "path", "description", "children"):
|
|
86
|
+
changed = copy.deepcopy(self.plan)
|
|
87
|
+
del changed["directoryTree"][key]
|
|
88
|
+
with self.subTest(missing=key), self.assertRaises(ContractError):
|
|
89
|
+
validate_plan(changed)
|
|
90
|
+
for key, value in (("description", ""), ("children", {}), ("kind", "module"),
|
|
91
|
+
("kind", None), ("selected", True)):
|
|
92
|
+
changed = copy.deepcopy(self.plan)
|
|
93
|
+
changed["directoryTree"][key] = value
|
|
94
|
+
with self.subTest(key=key), self.assertRaises(ContractError):
|
|
95
|
+
validate_plan(changed)
|
|
142
96
|
|
|
143
|
-
def
|
|
144
|
-
for
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
97
|
+
def test_root_is_a_normal_directory_at_dot(self):
|
|
98
|
+
for key, value in (("path", "entry"), ("kind", "HAP")):
|
|
99
|
+
changed = copy.deepcopy(self.plan)
|
|
100
|
+
changed["directoryTree"][key] = value
|
|
101
|
+
with self.subTest(key=key), self.assertRaises(ContractError):
|
|
102
|
+
validate_plan(changed)
|
|
103
|
+
|
|
104
|
+
def test_names_and_direct_nesting_must_match_paths(self):
|
|
105
|
+
for path, name in (("entry/src/main", "main"), ("entry/src", "sources")):
|
|
106
|
+
changed = copy.deepcopy(self.plan)
|
|
107
|
+
node = changed["directoryTree"]["children"][1]["children"][0]
|
|
108
|
+
node.update(path=path, name=name)
|
|
109
|
+
with self.subTest(path=path, name=name), self.assertRaises(ContractError):
|
|
110
|
+
validate_plan(changed)
|
|
111
|
+
|
|
112
|
+
def test_unsafe_and_placeholder_paths_are_rejected(self):
|
|
113
|
+
for path in ("/absolute", "E:/target", "../source", "a/../b", "a//b", "./entry", "a\\b",
|
|
114
|
+
"pages/*", "{pages,state}", "<产品>", "…", "CON", "NUL.txt", "aux", "COM¹", "lpt².log", "a.", "a ",
|
|
115
|
+
"bad\x00path", "bad?path"):
|
|
116
|
+
changed = copy.deepcopy(self.plan)
|
|
117
|
+
changed["directoryTree"]["children"][0].update(path=path, name=path.split("/")[-1])
|
|
149
118
|
with self.subTest(path=path), self.assertRaises(ContractError):
|
|
150
|
-
validate_plan(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
119
|
+
validate_plan(changed)
|
|
120
|
+
|
|
121
|
+
def test_duplicate_and_case_conflicting_paths_are_rejected(self):
|
|
122
|
+
for name in ("AppScope", "appscope"):
|
|
123
|
+
changed = copy.deepcopy(self.plan)
|
|
124
|
+
duplicate = copy.deepcopy(changed["directoryTree"]["children"][0])
|
|
125
|
+
duplicate.update(name=name, path=name)
|
|
126
|
+
changed["directoryTree"]["children"].append(duplicate)
|
|
127
|
+
with self.subTest(name=name), self.assertRaisesRegex(ContractError, "duplicate/case-conflicting"):
|
|
128
|
+
validate_plan(changed)
|
|
129
|
+
|
|
130
|
+
def test_application_hap_and_expanded_production_root_are_required(self):
|
|
131
|
+
changed = copy.deepcopy(self.plan)
|
|
132
|
+
changed["directoryTree"]["children"] = []
|
|
133
|
+
with self.assertRaisesRegex(ContractError, "application HAP"):
|
|
134
|
+
validate_plan(changed)
|
|
135
|
+
self.node("entry")["kind"] = "HAR"
|
|
136
|
+
with self.assertRaisesRegex(ContractError, "application HAP"):
|
|
154
137
|
validate_plan(self.plan)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
entry["children"][0]["kind"] = "HAR"
|
|
159
|
-
with self.assertRaisesRegex(ContractError, "nested module"):
|
|
160
|
-
validate_plan(self.plan)
|
|
161
|
-
entry["children"][0]["kind"] = "directory"
|
|
162
|
-
del entry["children"][0]["children"]
|
|
163
|
-
with self.assertRaises(ContractError):
|
|
138
|
+
self.node("entry")["kind"] = "HAP"
|
|
139
|
+
self.node("entry")["children"] = []
|
|
140
|
+
with self.assertRaisesRegex(ContractError, "src/main"):
|
|
164
141
|
validate_plan(self.plan)
|
|
165
142
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if mode == "empty":
|
|
171
|
-
tree["children"] = []
|
|
172
|
-
elif mode == "no-hap":
|
|
173
|
-
tree["children"][1].pop("kind")
|
|
174
|
-
else:
|
|
175
|
-
tree["children"][1]["children"] = []
|
|
176
|
-
with self.subTest(mode=mode), self.assertRaises(ContractError):
|
|
177
|
-
validate_plan(broken)
|
|
178
|
-
|
|
179
|
-
def test_read_preserves_user_notes_for_implementation_review(self):
|
|
180
|
-
answer = {**self.decision, "notes": "保留现有目录,只补充风险说明。"}
|
|
181
|
-
selected, _ = apply_selection(self.plan, answer)
|
|
182
|
-
_, result = read_selection(selected, answer)
|
|
183
|
-
self.assertEqual(result["notes"], answer["notes"])
|
|
184
|
-
|
|
185
|
-
def test_new_submission_cannot_overwrite_the_confirmation_input_in_place(self):
|
|
186
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
187
|
-
root = Path(directory)
|
|
188
|
-
plan_file, saved_file = root / "architecture-plan.json", root / "architecture-selection.json"
|
|
189
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
190
|
-
saved_file.write_text(json.dumps(self.decision), encoding="utf-8")
|
|
191
|
-
original = plan_file.read_bytes()
|
|
192
|
-
result = self.run_cli("apply-selection", plan_file, saved_file)
|
|
193
|
-
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
|
194
|
-
self.assertEqual(plan_file.read_bytes(), original)
|
|
195
|
-
|
|
196
|
-
def test_required_content_scheme_identity_and_selection_consistency_are_checked(self):
|
|
197
|
-
mutations = (
|
|
198
|
-
lambda p: p["project"].pop("summary"),
|
|
199
|
-
lambda p: p["androidAnalysis"].pop("risks"),
|
|
200
|
-
lambda p: p["androidAnalysis"].pop("techStack"),
|
|
201
|
-
lambda p: p["androidAnalysis"].update(risks="unknown"),
|
|
202
|
-
lambda p: p["proposals"][1].pop("directoryTree"),
|
|
203
|
-
lambda p: p["proposals"].pop(),
|
|
204
|
-
lambda p: p["proposals"][0].update(id="hybrid"),
|
|
205
|
-
lambda p: p.update(selectedProposalId="unknown"),
|
|
206
|
-
lambda p: p.update(selection=None),
|
|
207
|
-
)
|
|
208
|
-
for index, mutate in enumerate(mutations):
|
|
209
|
-
broken = copy.deepcopy(self.plan)
|
|
210
|
-
mutate(broken)
|
|
211
|
-
with self.subTest(index=index), self.assertRaises(ContractError):
|
|
212
|
-
validate_plan(broken)
|
|
213
|
-
|
|
214
|
-
def run_cli(self, *args):
|
|
215
|
-
return subprocess.run([sys.executable, str(HELPER), *map(str, args)],
|
|
216
|
-
capture_output=True, text=True, encoding="utf-8", timeout=10)
|
|
217
|
-
|
|
218
|
-
def test_dashboard_and_question_use_the_same_durable_confirmation_and_read_step(self):
|
|
219
|
-
for source in ("dashboard", "question"):
|
|
220
|
-
with self.subTest(source=source), tempfile.TemporaryDirectory() as directory:
|
|
221
|
-
root = Path(directory)
|
|
222
|
-
plan_file, answer_file = root / "architecture-plan.json", root / "tool-answer.json"
|
|
223
|
-
saved_file = root / "architecture-selection.json"
|
|
224
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
225
|
-
answer = {**self.decision, "source": source}
|
|
226
|
-
answer_file.write_text(json.dumps(answer), encoding="utf-8")
|
|
227
|
-
applied = self.run_cli("apply-selection", plan_file, answer_file)
|
|
228
|
-
self.assertEqual(applied.returncode, 0, applied.stdout + applied.stderr)
|
|
229
|
-
self.assertEqual(json.loads(saved_file.read_text(encoding="utf-8")), answer)
|
|
230
|
-
saved_plan = json.loads(plan_file.read_text(encoding="utf-8"))
|
|
231
|
-
self.assertEqual(saved_plan["selectedProposalId"], answer["selectedProposalId"])
|
|
232
|
-
self.assertNotIn("selection", saved_plan)
|
|
233
|
-
resumed = self.run_cli("read-selection", plan_file)
|
|
234
|
-
self.assertEqual(resumed.returncode, 0, resumed.stdout + resumed.stderr)
|
|
235
|
-
output = json.loads(resumed.stdout)
|
|
236
|
-
self.assertEqual(output["source"], source)
|
|
237
|
-
self.assertEqual(output["nextAction"], "create-directory-shell")
|
|
238
|
-
self.assertEqual(output["selectedProposalId"], "android-mirror")
|
|
239
|
-
self.assertEqual({p.name for p in root.iterdir()}, {plan_file.name, answer_file.name, saved_file.name})
|
|
240
|
-
|
|
241
|
-
def test_read_rejects_unconfirmed_or_mismatched_selection(self):
|
|
242
|
-
with self.assertRaisesRegex(ContractError, "no confirmed"):
|
|
243
|
-
read_selection(self.plan, self.decision)
|
|
244
|
-
updated, _ = apply_selection(self.plan, self.decision)
|
|
245
|
-
for key, value in (("planId", "other-plan"), ("selectedProposalId", "hybrid")):
|
|
246
|
-
with self.subTest(key=key), self.assertRaisesRegex(ContractError, "does not match"):
|
|
247
|
-
read_selection(updated, {**self.decision, key: value})
|
|
248
|
-
|
|
249
|
-
def test_read_select_only_never_requests_creation(self):
|
|
250
|
-
answer = {**self.decision, "action": "select", "source": "question"}
|
|
251
|
-
updated, _ = apply_selection(self.plan, answer)
|
|
252
|
-
_, result = read_selection(updated, answer)
|
|
253
|
-
self.assertEqual(result["nextAction"], "selection-recorded")
|
|
254
|
-
|
|
255
|
-
def test_missing_confirmation_file_prevents_directory_implementation(self):
|
|
256
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
257
|
-
plan_file = Path(directory) / "architecture-plan.json"
|
|
258
|
-
selected, _ = apply_selection(self.plan, self.decision)
|
|
259
|
-
plan_file.write_text(json.dumps(selected), encoding="utf-8")
|
|
260
|
-
result = self.run_cli("read-selection", plan_file)
|
|
261
|
-
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
|
262
|
-
self.assertEqual(json.loads(result.stdout)["status"], "error")
|
|
263
|
-
|
|
264
|
-
def test_recovery_applies_saved_question_answer_before_reading_implementation_input(self):
|
|
265
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
266
|
-
root = Path(directory)
|
|
267
|
-
plan_file, saved_file = root / "architecture-plan.json", root / "architecture-selection.json"
|
|
268
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
269
|
-
answer = {**self.decision, "source": "question"}
|
|
270
|
-
saved_file.write_text(json.dumps(answer), encoding="utf-8")
|
|
271
|
-
pending = self.run_cli("read-selection", plan_file)
|
|
272
|
-
self.assertEqual(pending.returncode, 1)
|
|
273
|
-
recovered = self.run_cli("apply-selection", plan_file, saved_file, "--recover")
|
|
274
|
-
self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr)
|
|
275
|
-
ready = self.run_cli("read-selection", plan_file)
|
|
276
|
-
self.assertEqual(ready.returncode, 0, ready.stdout + ready.stderr)
|
|
277
|
-
self.assertEqual(json.loads(ready.stdout)["source"], "question")
|
|
278
|
-
|
|
279
|
-
def test_cli_retries_without_writing_and_never_creates_target(self):
|
|
280
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
281
|
-
root = Path(directory)
|
|
282
|
-
target = root / "not-created"
|
|
283
|
-
plan_file, decision_file = root / "architecture-plan.json", root / "incoming-selection.json"
|
|
284
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
285
|
-
decision_file.write_text(json.dumps(self.decision), encoding="utf-8-sig")
|
|
286
|
-
result = self.run_cli("apply-selection", plan_file, decision_file)
|
|
287
|
-
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
288
|
-
self.assertEqual(json.loads(result.stdout)["nextAction"], "create-directory-shell")
|
|
289
|
-
first_write = plan_file.stat().st_mtime_ns
|
|
290
|
-
retry = self.run_cli("apply-selection", plan_file, decision_file)
|
|
291
|
-
self.assertEqual(retry.returncode, 0, retry.stdout + retry.stderr)
|
|
292
|
-
self.assertEqual(json.loads(retry.stdout)["status"], "already-applied")
|
|
293
|
-
self.assertEqual(plan_file.stat().st_mtime_ns, first_write)
|
|
294
|
-
self.assertFalse(target.exists())
|
|
295
|
-
self.assertEqual({p.name for p in root.iterdir()}, {plan_file.name, decision_file.name, "architecture-selection.json"})
|
|
296
|
-
|
|
297
|
-
def test_changed_retry_is_rejected_without_replacing_confirmation(self):
|
|
298
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
299
|
-
root = Path(directory)
|
|
300
|
-
plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
|
|
301
|
-
saved = root / "architecture-selection.json"
|
|
302
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
303
|
-
incoming.write_text(json.dumps(self.decision), encoding="utf-8")
|
|
304
|
-
self.assertEqual(self.run_cli("apply-selection", plan_file, incoming).returncode, 0)
|
|
305
|
-
plan_bytes, confirmed_bytes = plan_file.read_bytes(), saved.read_bytes()
|
|
306
|
-
incoming.write_text(json.dumps({**self.decision, "action": "select"}), encoding="utf-8")
|
|
307
|
-
rejected = self.run_cli("apply-selection", plan_file, incoming)
|
|
308
|
-
self.assertEqual(rejected.returncode, 1, rejected.stdout)
|
|
309
|
-
self.assertIn("requestId", json.loads(rejected.stdout)["error"])
|
|
310
|
-
self.assertEqual(plan_file.read_bytes(), plan_bytes)
|
|
311
|
-
self.assertEqual(saved.read_bytes(), confirmed_bytes)
|
|
312
|
-
|
|
313
|
-
def test_recovery_flag_requires_the_script_owned_confirmation(self):
|
|
314
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
315
|
-
root = Path(directory)
|
|
316
|
-
plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
|
|
317
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
318
|
-
incoming.write_text(json.dumps(self.decision), encoding="utf-8")
|
|
319
|
-
rejected = self.run_cli("apply-selection", plan_file, incoming, "--recover")
|
|
320
|
-
self.assertEqual(rejected.returncode, 1)
|
|
321
|
-
self.assertFalse((root / "architecture-selection.json").exists())
|
|
322
|
-
|
|
323
|
-
def test_failed_plan_write_cannot_enter_implementation_and_can_recover(self):
|
|
324
|
-
with tempfile.TemporaryDirectory() as directory:
|
|
325
|
-
root = Path(directory)
|
|
326
|
-
plan_file, incoming = root / "architecture-plan.json", root / "incoming.json"
|
|
327
|
-
saved = root / "architecture-selection.json"
|
|
328
|
-
plan_file.write_text(json.dumps(self.plan), encoding="utf-8")
|
|
329
|
-
incoming.write_text(json.dumps(self.decision), encoding="utf-8")
|
|
330
|
-
original = plan_file.read_bytes()
|
|
331
|
-
save_json = helper.save_json
|
|
332
|
-
|
|
333
|
-
def fail_plan_write(path, value):
|
|
334
|
-
if path == plan_file:
|
|
335
|
-
raise OSError("simulated plan write failure")
|
|
336
|
-
save_json(path, value)
|
|
143
|
+
def test_empty_hap_production_root_is_rejected(self):
|
|
144
|
+
self.node("entry/src/main")["children"] = []
|
|
145
|
+
with self.assertRaisesRegex(ContractError, "src/main"):
|
|
146
|
+
validate_plan(self.plan)
|
|
337
147
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
148
|
+
def test_hap_tree_validation_is_not_tied_to_one_source_language(self):
|
|
149
|
+
for name in ("js", "cpp"):
|
|
150
|
+
changed = copy.deepcopy(self.plan)
|
|
151
|
+
source = next(node for node in self.nodes() if node["path"] == "entry/src/main/ets")
|
|
152
|
+
source = copy.deepcopy(source)
|
|
153
|
+
def rebase(node):
|
|
154
|
+
node["path"] = node["path"].replace("entry/src/main/ets", f"entry/src/main/{name}", 1)
|
|
155
|
+
for child in node["children"]:
|
|
156
|
+
rebase(child)
|
|
157
|
+
rebase(source)
|
|
158
|
+
source["name"] = name
|
|
159
|
+
changed["directoryTree"]["children"][1]["children"][0]["children"][0]["children"][0] = source
|
|
160
|
+
with self.subTest(source=name):
|
|
161
|
+
validate_plan(changed)
|
|
162
|
+
|
|
163
|
+
def test_module_cannot_be_inside_another_modules_source_tree(self):
|
|
164
|
+
for kind in ("HAP", "HAR", "HSP"):
|
|
165
|
+
self.node("entry/src/main/ets/features")["kind"] = kind
|
|
166
|
+
with self.subTest(kind=kind), self.assertRaisesRegex(ContractError, "source tree"):
|
|
167
|
+
validate_plan(self.plan)
|
|
168
|
+
|
|
169
|
+
def test_module_grouping_outside_a_source_tree_is_not_rejected_as_nesting(self):
|
|
170
|
+
self.node("entry")["children"].append({
|
|
171
|
+
"name": "libraries", "path": "entry/libraries", "description": "库目录分组", "children": [{
|
|
172
|
+
"name": "mail", "path": "entry/libraries/mail", "kind": "HAR",
|
|
173
|
+
"description": "由独立构建配置注册的库模块", "children": [{
|
|
174
|
+
"name": "src", "path": "entry/libraries/mail/src", "description": "库源码", "children": [{
|
|
175
|
+
"name": "main", "path": "entry/libraries/mail/src/main", "description": "生产根", "children": [{
|
|
176
|
+
"name": "ets", "path": "entry/libraries/mail/src/main/ets",
|
|
177
|
+
"description": "库的 ArkTS 源码", "children": [],
|
|
178
|
+
}],
|
|
179
|
+
}],
|
|
180
|
+
}],
|
|
181
|
+
}],
|
|
182
|
+
})
|
|
183
|
+
validate_plan(self.plan)
|
|
348
184
|
|
|
349
|
-
def
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
185
|
+
def test_multiple_hap_modules_and_sibling_libraries_are_supported(self):
|
|
186
|
+
for kind in ("HAP", "HAR", "HSP"):
|
|
187
|
+
with self.subTest(kind=kind):
|
|
188
|
+
changed = copy.deepcopy(self.plan)
|
|
189
|
+
module = copy.deepcopy(changed["directoryTree"]["children"][1])
|
|
190
|
+
def rebase(node):
|
|
191
|
+
node["path"] = "second" + node["path"][len("entry"):]
|
|
192
|
+
for child in node["children"]:
|
|
193
|
+
rebase(child)
|
|
194
|
+
rebase(module)
|
|
195
|
+
module.update(name="second", kind=kind)
|
|
196
|
+
changed["directoryTree"]["children"].append(module)
|
|
197
|
+
validate_plan(changed)
|
|
198
|
+
|
|
199
|
+
def test_invalid_json_duplicate_keys_and_nonstandard_numbers_are_rejected(self):
|
|
200
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
201
|
+
path = Path(temporary) / "plan.json"
|
|
202
|
+
for content in ('{"projectName": "a", "projectName": "b"}', '{"bad": NaN}',
|
|
203
|
+
'{"bad": Infinity}', '{"bad": -Infinity}', '{broken', 'null'):
|
|
204
|
+
path.write_text(content, encoding="utf-8")
|
|
205
|
+
with self.subTest(content=content), self.assertRaises(ValueError):
|
|
206
|
+
validate_plan(load_json(path))
|
|
207
|
+
|
|
208
|
+
def test_cli_validates_utf8_bom_and_makes_no_writes_or_confirmation_files(self):
|
|
209
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
210
|
+
path = Path(temporary) / "architecture-plan.json"
|
|
211
|
+
path.write_text(json.dumps(self.plan, ensure_ascii=False), encoding="utf-8-sig")
|
|
212
|
+
before = path.read_bytes()
|
|
213
|
+
result = self.cli("validate", path, env={**os.environ, "PYTHONIOENCODING": "ascii"})
|
|
360
214
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
361
|
-
self.assertEqual(json.loads(result.stdout
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
|
215
|
+
self.assertEqual(json.loads(result.stdout), {"status": "valid", "projectName": "示例邮件客户端"})
|
|
216
|
+
self.assertEqual(path.read_bytes(), before)
|
|
217
|
+
self.assertEqual([p.name for p in path.parent.iterdir()], [path.name])
|
|
218
|
+
|
|
219
|
+
def test_cli_errors_do_not_rewrite_input_and_missing_file_is_reported(self):
|
|
220
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
221
|
+
path = Path(temporary) / "architecture-plan.json"
|
|
222
|
+
for content in ('{"proposals": []}', '{broken', '[]'):
|
|
223
|
+
path.write_text(content, encoding="utf-8")
|
|
224
|
+
result = self.cli("validate", path)
|
|
225
|
+
self.assertEqual(result.returncode, 1)
|
|
373
226
|
self.assertEqual(json.loads(result.stdout)["status"], "error")
|
|
374
|
-
self.assertEqual(
|
|
227
|
+
self.assertEqual(path.read_text(encoding="utf-8"), content)
|
|
228
|
+
result = self.cli("validate", path.parent / "missing.json")
|
|
229
|
+
self.assertEqual(result.returncode, 1)
|
|
230
|
+
self.assertEqual(json.loads(result.stdout)["status"], "error")
|
|
231
|
+
|
|
232
|
+
def test_legacy_selection_commands_are_unavailable(self):
|
|
233
|
+
for command in ("apply-selection", "read-selection"):
|
|
234
|
+
result = self.cli(command, "architecture-plan.json")
|
|
235
|
+
with self.subTest(command=command):
|
|
236
|
+
self.assertNotEqual(result.returncode, 0)
|
|
237
|
+
self.assertIn("invalid choice", result.stderr)
|
|
375
238
|
|
|
376
239
|
|
|
377
240
|
if __name__ == "__main__":
|
|
@@ -5,7 +5,7 @@ description: Implement ordered Android-to-HarmonyOS page tasks from pages.json,
|
|
|
5
5
|
|
|
6
6
|
# Page development loop
|
|
7
7
|
|
|
8
|
-
Each page-worker implements only the selected page and brings its build to a pass. After development is complete, the parent agent takes over testing, defect fixes, and acceptance for the current task.
|
|
8
|
+
Each page-worker implements only the selected page and brings its build to a pass. After development is complete, the parent agent takes over testing, defect fixes, and acceptance for the current task. A worker may be reused for a fixed ordered group of up to three related Specs. Implement one assigned task per handoff; the parent accepts it before authorizing the same worker to continue with the next member. Task identities, states, evidence and acceptance remain separate. Reuse is conditional on the host supporting child-session continuation and the parent confirming that the next task shares relevant context, the worker can use the refreshed baseline and interfaces, and no unresolved task or ownership conflict remains. Otherwise, explicitly revise the group and dispatch a fresh worker for the remaining scope.
|
|
9
9
|
|
|
10
10
|
## Inputs and boundaries
|
|
11
11
|
|
|
@@ -21,17 +21,18 @@ Each page-worker implements only the selected page and brings its build to a pas
|
|
|
21
21
|
## Parent-agent mode
|
|
22
22
|
|
|
23
23
|
1. Read `pages.json` and `batch_report.json`; verify task identities, Spec paths and dependencies against pages.json, and use ledger planning for development scope and execution order. Verify planning references, counts and states. Reject missing, stale or invalid planning instead of falling back to all tasks when a selected scope or planning is present.
|
|
24
|
-
2. Select the earliest eligible task in planning milestone/wave order (or legacy ledger tasks order when allowed) whose development/build/validation states are not `implemented/passed/passed` and whose declared dependencies are satisfied.
|
|
24
|
+
2. Select the earliest eligible task in planning milestone/wave order (or legacy ledger tasks order when allowed) whose development/build/validation states are not `implemented/passed/passed` and whose declared dependencies are satisfied. When bound planning is present, the parent may group it with adjacent unfinished tasks in the same wave that share page, module or component context, normally two or three tasks and never more than three. Fix the ordered task IDs before dispatch; keep complex or unclear work as a single task and verify dependencies again before each member starts.
|
|
25
25
|
3. Resolve the page package, Android root, HarmonyOS root, repository root, current `HEAD`, Git status, device target, and task evidence directory.
|
|
26
|
-
4. Dispatch exactly one page-worker with absolute paths,
|
|
26
|
+
4. Dispatch exactly one page-worker with absolute paths, the current task ID, ordered group task IDs when present, dispatch baseline, device target, and task evidence directory. Do not run page-workers in parallel. Wait for its result before the parent begins testing and fixes. Preserve the returned child-session identifier for possible continuation; use the host's actual resume parameter and confirm that the continuation returns the same session ID. A new child session is a fresh dispatch, not context reuse.
|
|
27
27
|
5. After the worker returns, the parent takes ownership of the current page and executes testing and all fixes according to **Managed page tests and fixes** below until current build and required tests pass.
|
|
28
28
|
6. Execute **Parent report and commits** below.
|
|
29
|
-
7. Apply **Parent verification** below. Only after the page reaches `implemented/passed/passed`, evidence is current, and all page-owned changes are committed may the parent
|
|
29
|
+
7. Apply **Parent verification** below. Only after the current page reaches `implemented/passed/passed`, evidence is current, and all page-owned changes are committed may the parent authorize the same worker to implement the next group member. Refresh its task package, baseline, permitted paths, dependency evidence and evidence directory. If continuation is unsupported or reuse review fails, explicitly revise the group and dispatch a fresh worker for the next eligible task with these refreshed inputs; lack of reuse support must not block normal serial development. If a member is blocked or the group becomes invalid, return to the parent with remaining tasks unchanged; do not skip members or expand the group. Dispatch a worker for a new group only after the current group closes or is explicitly revised.
|
|
30
30
|
|
|
31
31
|
Use this worker prompt contract:
|
|
32
32
|
|
|
33
33
|
```text
|
|
34
34
|
Use $d2h-spec-develop in page-worker mode. Complete the initial implementation and managed build for exactly <TASK_ID>.
|
|
35
|
+
Assigned task IDs: <fixed ordered group of 1–3 task IDs; implement only TASK_ID until parent acceptance and explicit continuation>
|
|
35
36
|
Skill path: <absolute path to this SKILL.md>
|
|
36
37
|
Migration root: <absolute path>
|
|
37
38
|
Page package: <absolute path>
|