master-skill 0.10.0 → 0.10.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/ETHICS.md +23 -17
- package/README.md +18 -11
- package/README_EN.md +22 -13
- package/SKILL.md +5 -5
- package/bin/cli.mjs +309 -77
- package/gemini-extension.json +1 -1
- package/hooks/run-hook.cmd +18 -5
- package/hooks/tests/test_run_hook.sh +114 -0
- package/hooks/tests/test_run_hook_cmd.sh +94 -0
- package/masters/.gitkeep +0 -0
- package/package.json +8 -2
- package/prebuilt/compare/SKILL.md +5 -5
- package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
- package/prebuilt/master-ajahn-chah/meta.json +8 -0
- package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
- package/prebuilt/master-atisha/SKILL.md +13 -11
- package/prebuilt/master-atisha/meta.json +8 -0
- package/prebuilt/master-atisha/references/voice.md +1 -1
- package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
- package/prebuilt/master-buddhaghosa/meta.json +8 -0
- package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-huineng/SKILL.md +3 -3
- package/prebuilt/master-huineng/meta.json +8 -0
- package/prebuilt/master-kumarajiva/SKILL.md +3 -3
- package/prebuilt/master-kumarajiva/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
- package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
- package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
- package/prebuilt/master-milarepa/SKILL.md +13 -11
- package/prebuilt/master-milarepa/meta.json +8 -0
- package/prebuilt/master-milarepa/references/voice.md +1 -1
- package/prebuilt/master-nagarjuna/SKILL.md +3 -3
- package/prebuilt/master-nagarjuna/meta.json +8 -0
- package/prebuilt/master-ouyi/SKILL.md +3 -3
- package/prebuilt/master-ouyi/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
- package/prebuilt/master-tsongkhapa/meta.json +8 -0
- package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
- package/prebuilt/master-xuanzang/SKILL.md +3 -3
- package/prebuilt/master-xuanzang/meta.json +8 -0
- package/prebuilt/master-xuyun/SKILL.md +3 -3
- package/prebuilt/master-xuyun/meta.json +8 -0
- package/prebuilt/master-yinguang/SKILL.md +3 -3
- package/prebuilt/master-yinguang/meta.json +8 -0
- package/prebuilt/master-zhiyi/SKILL.md +3 -3
- package/prebuilt/master-zhiyi/meta.json +8 -0
- package/prompts/correction_handler.md +104 -0
- package/prompts/doctrine_reviewer.md +61 -0
- package/prompts/intake.md +62 -0
- package/prompts/merger.md +62 -0
- package/prompts/rag_instructions.md +54 -0
- package/prompts/sutra_analyzer.md +83 -0
- package/prompts/teaching_builder.md +41 -0
- package/prompts/voice_analyzer.md +92 -0
- package/prompts/voice_builder.md +48 -0
- package/prompts/voice_reviewer.md +66 -0
- package/references/README.md +12 -0
- package/references/ethics-runtime.md +112 -0
- package/references/fojin-api.md +223 -0
- package/references/source-conventions.md +129 -0
- package/references/teaching-modes.md +84 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +19 -2
- package/scripts/tests/test_select_fidelity_smoke.py +142 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_workflow.py +265 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +147 -0
- package/tools/cross_reference.py +365 -0
- package/tools/fojin_bridge.py +146 -0
- package/tools/master_builder.py +341 -0
- package/tools/rag_query.py +336 -0
- package/tools/skill_writer.py +230 -0
- package/tools/sutra_collector.py +237 -0
- package/tools/verify_sources.py +512 -0
- package/tools/version_manager.py +88 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Tests for the cross-tradition citation contract validator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def validator():
|
|
14
|
+
script_path = Path(__file__).resolve().parents[1] / "validate-citation-contract.py"
|
|
15
|
+
spec = importlib.util.spec_from_file_location("validate_citation_contract", script_path)
|
|
16
|
+
module = importlib.util.module_from_spec(spec)
|
|
17
|
+
sys.modules["validate_citation_contract"] = module
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def fake_tree(tmp_path: Path) -> Path:
|
|
24
|
+
return tmp_path / "prebuilt"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def contract(*types: str) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"version": 1,
|
|
30
|
+
"claim_policy": "declared_sources_only",
|
|
31
|
+
"required_for": [
|
|
32
|
+
"doctrinal_claim",
|
|
33
|
+
"practice_guidance",
|
|
34
|
+
"text_interpretation",
|
|
35
|
+
],
|
|
36
|
+
"allowed_source_types": sorted(types),
|
|
37
|
+
"minimum_claim_coverage": 0.9,
|
|
38
|
+
"live_retrieval_allowed": True,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_meta(
|
|
43
|
+
prebuilt: Path,
|
|
44
|
+
slug: str,
|
|
45
|
+
source_types: list[str],
|
|
46
|
+
value: dict | None,
|
|
47
|
+
*,
|
|
48
|
+
kind: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
directory = prebuilt / f"master-{slug}"
|
|
51
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
data = {
|
|
53
|
+
"slug": slug,
|
|
54
|
+
"sources": [
|
|
55
|
+
{"type": source_type, "id": f"id-{index}", "title": f"source-{index}"}
|
|
56
|
+
for index, source_type in enumerate(source_types)
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
if kind is not None:
|
|
60
|
+
data["kind"] = kind
|
|
61
|
+
if value is not None:
|
|
62
|
+
data["citation_contract"] = value
|
|
63
|
+
(directory / "meta.json").write_text(json.dumps(data), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize(
|
|
67
|
+
"source_types",
|
|
68
|
+
[
|
|
69
|
+
["cbeta"],
|
|
70
|
+
["tibetan_canon", "tibetan_treatise"],
|
|
71
|
+
["compiled_teaching", "pali_canon", "pali_treatise"],
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
def test_valid_source_family_contracts_pass(validator, fake_tree, source_types):
|
|
75
|
+
write_meta(fake_tree, "demo", source_types, contract(*source_types))
|
|
76
|
+
assert validator.validate(fake_tree) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_missing_contract_fails(validator, fake_tree):
|
|
80
|
+
write_meta(fake_tree, "demo", ["cbeta"], None)
|
|
81
|
+
errors = validator.validate(fake_tree)
|
|
82
|
+
assert any(
|
|
83
|
+
"master-demo/meta.json" in error and "citation_contract" in error
|
|
84
|
+
for error in errors
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("allowed", [[], ["cbeta", "pali_canon"]])
|
|
89
|
+
def test_source_type_drift_fails(validator, fake_tree, allowed):
|
|
90
|
+
value = contract("cbeta")
|
|
91
|
+
value["allowed_source_types"] = allowed
|
|
92
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
93
|
+
errors = validator.validate(fake_tree)
|
|
94
|
+
assert any(
|
|
95
|
+
"master-demo/meta.json" in error and "allowed_source_types" in error
|
|
96
|
+
for error in errors
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize(
|
|
101
|
+
("field", "invalid"),
|
|
102
|
+
[
|
|
103
|
+
("version", 2),
|
|
104
|
+
("claim_policy", "any_source"),
|
|
105
|
+
("required_for", ["doctrinal_claim"]),
|
|
106
|
+
("minimum_claim_coverage", True),
|
|
107
|
+
("minimum_claim_coverage", 0.8),
|
|
108
|
+
("live_retrieval_allowed", "yes"),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
def test_fixed_contract_values_are_enforced(
|
|
112
|
+
validator,
|
|
113
|
+
fake_tree,
|
|
114
|
+
field,
|
|
115
|
+
invalid,
|
|
116
|
+
):
|
|
117
|
+
value = contract("cbeta")
|
|
118
|
+
value[field] = invalid
|
|
119
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
120
|
+
errors = validator.validate(fake_tree)
|
|
121
|
+
assert any(
|
|
122
|
+
"master-demo/meta.json" in error and field in error for error in errors
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_meta_skill_without_sources_is_ignored(validator, fake_tree):
|
|
127
|
+
write_meta(fake_tree, "debate", [], None, kind="meta-skill")
|
|
128
|
+
assert validator.validate(fake_tree) == []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_meta_skill_must_not_declare_persona_contract(validator, fake_tree):
|
|
132
|
+
write_meta(
|
|
133
|
+
fake_tree,
|
|
134
|
+
"debate",
|
|
135
|
+
["cbeta"],
|
|
136
|
+
contract("cbeta"),
|
|
137
|
+
kind="meta-skill",
|
|
138
|
+
)
|
|
139
|
+
errors = validator.validate(fake_tree)
|
|
140
|
+
assert any(
|
|
141
|
+
"master-debate/meta.json" in error and "meta-skill" in error
|
|
142
|
+
for error in errors
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_persona_without_sources_is_not_treated_as_meta_skill(validator, fake_tree):
|
|
147
|
+
write_meta(fake_tree, "demo", [], None)
|
|
148
|
+
errors = validator.validate(fake_tree)
|
|
149
|
+
assert any(
|
|
150
|
+
"master-demo/meta.json" in error and "sources" in error for error in errors
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.parametrize("mutation", ["missing", "extra"])
|
|
155
|
+
def test_contract_keys_are_exact(validator, fake_tree, mutation):
|
|
156
|
+
value = contract("cbeta")
|
|
157
|
+
if mutation == "missing":
|
|
158
|
+
del value["claim_policy"]
|
|
159
|
+
else:
|
|
160
|
+
value["unexpected"] = "value"
|
|
161
|
+
write_meta(fake_tree, "demo", ["cbeta"], value)
|
|
162
|
+
errors = validator.validate(fake_tree)
|
|
163
|
+
assert any(
|
|
164
|
+
"master-demo/meta.json" in error and "citation_contract" in error
|
|
165
|
+
for error in errors
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("source_type", ["", " ", None, True])
|
|
170
|
+
def test_source_types_must_be_non_empty_strings(
|
|
171
|
+
validator,
|
|
172
|
+
fake_tree,
|
|
173
|
+
source_type,
|
|
174
|
+
):
|
|
175
|
+
write_meta(fake_tree, "demo", [source_type], contract("cbeta"))
|
|
176
|
+
errors = validator.validate(fake_tree)
|
|
177
|
+
assert any(
|
|
178
|
+
"master-demo/meta.json" in error and "sources[].type" in error
|
|
179
|
+
for error in errors
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_cli_exit_codes_and_persona_count(
|
|
184
|
+
validator,
|
|
185
|
+
fake_tree,
|
|
186
|
+
monkeypatch,
|
|
187
|
+
capsys,
|
|
188
|
+
):
|
|
189
|
+
monkeypatch.setattr(validator, "PREBUILT_DIR", fake_tree)
|
|
190
|
+
monkeypatch.setattr(validator, "EXPECTED_PERSONA_SLUGS", ("demo",))
|
|
191
|
+
write_meta(fake_tree, "demo", ["cbeta"], None)
|
|
192
|
+
|
|
193
|
+
assert validator.main() == 1
|
|
194
|
+
assert "master-demo/meta.json" in capsys.readouterr().err
|
|
195
|
+
|
|
196
|
+
write_meta(fake_tree, "demo", ["cbeta"], contract("cbeta"))
|
|
197
|
+
assert validator.main() == 0
|
|
198
|
+
captured = capsys.readouterr()
|
|
199
|
+
assert captured.err == ""
|
|
200
|
+
assert captured.out.strip() == "citation contracts OK (1 personas)"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_repository_mode_fails_when_prebuilt_directory_is_missing(
|
|
204
|
+
validator,
|
|
205
|
+
tmp_path,
|
|
206
|
+
):
|
|
207
|
+
errors = validator.validate_repository(tmp_path / "missing")
|
|
208
|
+
assert any("prebuilt directory" in error for error in errors)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_repository_mode_fails_with_zero_personas(validator, fake_tree):
|
|
212
|
+
write_meta(fake_tree, "debate", [], None, kind="meta-skill")
|
|
213
|
+
errors = validator.validate_repository(fake_tree)
|
|
214
|
+
assert any("persona roster" in error and "missing" in error for error in errors)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_repository_mode_fails_with_fourteen_personas(validator, fake_tree):
|
|
218
|
+
for slug in validator.EXPECTED_PERSONA_SLUGS[:-1]:
|
|
219
|
+
write_meta(fake_tree, slug, ["cbeta"], contract("cbeta"))
|
|
220
|
+
|
|
221
|
+
errors = validator.validate_repository(fake_tree)
|
|
222
|
+
missing = validator.EXPECTED_PERSONA_SLUGS[-1]
|
|
223
|
+
assert any(
|
|
224
|
+
"persona roster" in error and f"master-{missing}" in error
|
|
225
|
+
for error in errors
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_repository_mode_accepts_the_real_fifteen_persona_roster(validator):
|
|
230
|
+
repository = Path(__file__).resolve().parents[2]
|
|
231
|
+
assert validator.validate_repository(repository / "prebuilt") == []
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_repository_wording_uses_declared_source_contract():
|
|
235
|
+
repository = Path(__file__).resolve().parents[2]
|
|
236
|
+
runtime_paths = [
|
|
237
|
+
repository / "SKILL.md",
|
|
238
|
+
repository / "prebuilt" / "compare" / "SKILL.md",
|
|
239
|
+
repository / "prompts" / "doctrine_reviewer.md",
|
|
240
|
+
repository / "references" / "ethics-runtime.md",
|
|
241
|
+
repository / "references" / "source-conventions.md",
|
|
242
|
+
repository / "docs" / "PRD.md",
|
|
243
|
+
repository / "docs" / "v1-framework-roadmap.md",
|
|
244
|
+
repository / "ETHICS.md",
|
|
245
|
+
repository / "README.md",
|
|
246
|
+
repository / "README_EN.md",
|
|
247
|
+
repository / "CONTRIBUTING.md",
|
|
248
|
+
]
|
|
249
|
+
forbidden = [
|
|
250
|
+
"NO DOCTRINAL CLAIM WITHOUT CBETA CITATION",
|
|
251
|
+
"每位祖师的回答必须附 CBETA 引用",
|
|
252
|
+
"CBETA 经证覆盖率 ≥ 90%",
|
|
253
|
+
"primary_cbeta_ids 过滤结果",
|
|
254
|
+
"当前版本仅汉传",
|
|
255
|
+
"无 CBETA 经号的教义断言不得写入",
|
|
256
|
+
"每一条教义断言必须附一个**真实**的 CBETA 经号",
|
|
257
|
+
]
|
|
258
|
+
for path in runtime_paths:
|
|
259
|
+
content = path.read_text(encoding="utf-8")
|
|
260
|
+
for phrase in forbidden:
|
|
261
|
+
assert phrase not in content, (
|
|
262
|
+
f"{path}: stale source-family rule: {phrase}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
root_text = runtime_paths[0].read_text(encoding="utf-8")
|
|
266
|
+
compare_text = runtime_paths[1].read_text(encoding="utf-8")
|
|
267
|
+
reviewer_text = runtime_paths[2].read_text(encoding="utf-8")
|
|
268
|
+
conventions_text = runtime_paths[4].read_text(encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
for text in (root_text, compare_text):
|
|
271
|
+
assert "NO DOCTRINAL CLAIM WITHOUT A DECLARED SOURCE CITATION" in text
|
|
272
|
+
assert "citation_contract.allowed_source_types" in text
|
|
273
|
+
assert "meta.json.sources[]" in text
|
|
274
|
+
assert "live_retrieval_allowed" in text
|
|
275
|
+
|
|
276
|
+
assert "primary_cbeta_ids" not in compare_text
|
|
277
|
+
assert "minimum_claim_coverage" in reviewer_text
|
|
278
|
+
assert "同等适用 citation contract" in conventions_text
|
|
279
|
+
contributing_text = runtime_paths[-1].read_text(encoding="utf-8")
|
|
280
|
+
assert "validate-citation-contract.py" in contributing_text
|
|
281
|
+
assert '"citation_contract"' in contributing_text
|
|
282
|
+
assert "meta.json.sources[]" in contributing_text
|
|
283
|
+
for source_family in (
|
|
284
|
+
"CBETA",
|
|
285
|
+
"BDRC / Toh",
|
|
286
|
+
"PTS / SuttaCentral",
|
|
287
|
+
"compiled teachings",
|
|
288
|
+
):
|
|
289
|
+
assert source_family in conventions_text
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_non_cbeta_runtime_instructions_are_source_family_aware():
|
|
293
|
+
repository = Path(__file__).resolve().parents[2]
|
|
294
|
+
slugs = (
|
|
295
|
+
"atisha",
|
|
296
|
+
"tsongkhapa",
|
|
297
|
+
"milarepa",
|
|
298
|
+
"buddhaghosa",
|
|
299
|
+
"mahasi-sayadaw",
|
|
300
|
+
"ajahn-chah",
|
|
301
|
+
)
|
|
302
|
+
for slug in slugs:
|
|
303
|
+
persona = repository / "prebuilt" / f"master-{slug}"
|
|
304
|
+
meta = json.loads((persona / "meta.json").read_text(encoding="utf-8"))
|
|
305
|
+
assert "cbeta" not in meta["citation_contract"]["allowed_source_types"]
|
|
306
|
+
instructions = (persona / "SKILL.md").read_text(encoding="utf-8")
|
|
307
|
+
for stale in ("cbeta_id", "--sources cbeta", "以 CBETA 汉文为主"):
|
|
308
|
+
assert stale not in instructions, f"master-{slug}: stale {stale}"
|
|
309
|
+
for required in (
|
|
310
|
+
"source_type",
|
|
311
|
+
"source_id",
|
|
312
|
+
"meta.json.sources[]",
|
|
313
|
+
"citation_contract.allowed_source_types",
|
|
314
|
+
):
|
|
315
|
+
assert required in instructions, f"master-{slug}: missing {required}"
|
|
316
|
+
|
|
317
|
+
rag = (repository / "prompts" / "rag_instructions.md").read_text(
|
|
318
|
+
encoding="utf-8"
|
|
319
|
+
)
|
|
320
|
+
assert "--sources cbeta" not in rag
|
|
321
|
+
for required in (
|
|
322
|
+
"source_type",
|
|
323
|
+
"source_id",
|
|
324
|
+
"meta.json.sources[]",
|
|
325
|
+
"citation_contract.allowed_source_types",
|
|
326
|
+
):
|
|
327
|
+
assert required in rag
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_live_retrieval_never_uses_sources_outside_the_declared_contract():
|
|
331
|
+
repository = Path(__file__).resolve().parents[2]
|
|
332
|
+
persona_skills = []
|
|
333
|
+
for path in sorted((repository / "prebuilt").glob("master-*/SKILL.md")):
|
|
334
|
+
meta_path = path.parent / "meta.json"
|
|
335
|
+
if not meta_path.is_file():
|
|
336
|
+
continue
|
|
337
|
+
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
338
|
+
if isinstance(metadata.get("sources"), list) and metadata["sources"]:
|
|
339
|
+
persona_skills.append(path)
|
|
340
|
+
assert len(persona_skills) == 15
|
|
341
|
+
for path in persona_skills:
|
|
342
|
+
content = path.read_text(encoding="utf-8")
|
|
343
|
+
for stale in (
|
|
344
|
+
"声明经典之外",
|
|
345
|
+
"所列经典之外",
|
|
346
|
+
"三部经之外",
|
|
347
|
+
"上述三部经之外",
|
|
348
|
+
):
|
|
349
|
+
assert stale not in content, f"{path}: contradictory live trigger {stale}"
|
|
350
|
+
assert "先人工扩充 `sources[]` / citation contract" in content
|
|
351
|
+
assert "已声明来源" in content
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_create_master_docs_match_the_real_generator_cli():
|
|
355
|
+
repository = Path(__file__).resolve().parents[2]
|
|
356
|
+
root = (repository / "SKILL.md").read_text(encoding="utf-8")
|
|
357
|
+
workflow = (repository / "references" / "workflow-details.md").read_text(
|
|
358
|
+
encoding="utf-8"
|
|
359
|
+
)
|
|
360
|
+
conventions = (repository / "references" / "source-conventions.md").read_text(
|
|
361
|
+
encoding="utf-8"
|
|
362
|
+
)
|
|
363
|
+
combined = root + workflow + conventions
|
|
364
|
+
assert "sutra_collector.py --name" in combined
|
|
365
|
+
assert "--output collected_data.json" in combined
|
|
366
|
+
assert "verify_sources.py --check-links collected_data.json" in combined
|
|
367
|
+
assert "master_builder.py --spec generated-master.json" in combined
|
|
368
|
+
assert "verify_sources.py --final-check" in combined
|
|
369
|
+
assert "masters/master-{slug}/" in root
|
|
370
|
+
assert "masters/master-{slug}/" in workflow
|
|
371
|
+
assert "masters/master-{slug}/" in conventions
|
|
372
|
+
assert "masters/{slug}/" not in combined
|
|
373
|
+
assert "生成器内存" in root
|
|
374
|
+
assert "master_builder.py --name" not in combined
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_create_master_prompts_and_contribution_paths_are_source_neutral():
|
|
378
|
+
repository = Path(__file__).resolve().parents[2]
|
|
379
|
+
source_prompts = [
|
|
380
|
+
repository / "prompts" / "sutra_analyzer.md",
|
|
381
|
+
repository / "prompts" / "teaching_builder.md",
|
|
382
|
+
repository / "prompts" / "voice_builder.md",
|
|
383
|
+
]
|
|
384
|
+
for path in source_prompts:
|
|
385
|
+
content = path.read_text(encoding="utf-8")
|
|
386
|
+
for stale in (
|
|
387
|
+
"`cbeta_id`",
|
|
388
|
+
"`fojin_url`",
|
|
389
|
+
"所有经文引用必须附 FoJin 链接",
|
|
390
|
+
"https://fojin.app/texts/{text_id}",
|
|
391
|
+
):
|
|
392
|
+
assert stale not in content, f"{path}: stale {stale}"
|
|
393
|
+
assert "source_id" in content, f"{path}: missing source_id"
|
|
394
|
+
|
|
395
|
+
intake = (repository / "prompts" / "intake.md").read_text(encoding="utf-8")
|
|
396
|
+
for tradition in ("印度", "汉传", "藏传", "南传"):
|
|
397
|
+
assert tradition in intake
|
|
398
|
+
assert "当前版本仅汉传" not in intake
|
|
399
|
+
|
|
400
|
+
pull_request = (
|
|
401
|
+
repository / ".github" / "PULL_REQUEST_TEMPLATE.md"
|
|
402
|
+
).read_text(encoding="utf-8")
|
|
403
|
+
issue = (
|
|
404
|
+
repository / ".github" / "ISSUE_TEMPLATE" / "new_master.yml"
|
|
405
|
+
).read_text(encoding="utf-8")
|
|
406
|
+
for content in (pull_request, issue):
|
|
407
|
+
assert "声明来源" in content
|
|
408
|
+
assert "validate-citation-contract.py" in pull_request
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Structural and behavior checks for the repository validation workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
WORKFLOW_PATH = ROOT / ".github" / "workflows" / "validate-and-test.yml"
|
|
15
|
+
WORKFLOW_TEXT = WORKFLOW_PATH.read_text(encoding="utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_workflow_text(text: str) -> dict:
|
|
19
|
+
workflow = yaml.safe_load(text)
|
|
20
|
+
assert isinstance(workflow, dict)
|
|
21
|
+
assert isinstance(workflow.get("jobs"), dict)
|
|
22
|
+
return workflow
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _job(workflow: dict, job_name: str) -> dict:
|
|
26
|
+
job = workflow["jobs"].get(job_name)
|
|
27
|
+
assert isinstance(job, dict), f"missing workflow job: {job_name}"
|
|
28
|
+
assert isinstance(job.get("steps"), list), f"job has no steps: {job_name}"
|
|
29
|
+
return job
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _step(workflow: dict, job_name: str, step_name: str) -> dict:
|
|
33
|
+
matches = [
|
|
34
|
+
step
|
|
35
|
+
for step in _job(workflow, job_name)["steps"]
|
|
36
|
+
if step.get("name") == step_name
|
|
37
|
+
]
|
|
38
|
+
assert len(matches) == 1, f"expected one {job_name}/{step_name} step, got {len(matches)}"
|
|
39
|
+
return matches[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _assert_hard(step: dict) -> None:
|
|
43
|
+
assert step.get("continue-on-error") not in (True, "true")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
WORKFLOW = _load_workflow_text(WORKFLOW_TEXT)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_free_text_tokens_cannot_substitute_for_workflow_structure():
|
|
50
|
+
fake = _load_workflow_text(
|
|
51
|
+
"""\
|
|
52
|
+
name: fake
|
|
53
|
+
jobs:
|
|
54
|
+
validate:
|
|
55
|
+
steps:
|
|
56
|
+
- name: comments only
|
|
57
|
+
run: echo 'python -m pytest tests/ scripts/tests/ -v'
|
|
58
|
+
"""
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with pytest.raises(AssertionError, match="Run Python tests"):
|
|
62
|
+
_step(fake, "validate", "Run Python tests")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_workflow_has_no_softened_steps():
|
|
66
|
+
for job_name, job in WORKFLOW["jobs"].items():
|
|
67
|
+
for step in job.get("steps", []):
|
|
68
|
+
assert step.get("continue-on-error") not in (True, "true"), (
|
|
69
|
+
f"softened workflow step in {job_name}: {step.get('name', step.get('uses'))}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize(
|
|
74
|
+
("step_name", "command"),
|
|
75
|
+
[
|
|
76
|
+
("Run Python tests", "python -m pytest tests/ scripts/tests/ -v"),
|
|
77
|
+
("Validate citation contracts", "python scripts/validate-citation-contract.py"),
|
|
78
|
+
(
|
|
79
|
+
"Validate lore_triggers content (v0.8 — hard gate)",
|
|
80
|
+
"python scripts/validate-lore-triggers-content.py --strict",
|
|
81
|
+
),
|
|
82
|
+
],
|
|
83
|
+
)
|
|
84
|
+
def test_validate_job_contains_hard_gate_commands(step_name: str, command: str):
|
|
85
|
+
step = _step(WORKFLOW, "validate", step_name)
|
|
86
|
+
assert step.get("run") == command
|
|
87
|
+
_assert_hard(step)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize(
|
|
91
|
+
("step_name", "command"),
|
|
92
|
+
[
|
|
93
|
+
("Check desktop formatting", "cargo fmt --manifest-path desktop/Cargo.toml -- --check"),
|
|
94
|
+
(
|
|
95
|
+
"Lint desktop app",
|
|
96
|
+
"cargo clippy --locked --manifest-path desktop/Cargo.toml "
|
|
97
|
+
"--all-targets -- -D warnings",
|
|
98
|
+
),
|
|
99
|
+
("Test desktop app", "cargo test --locked --manifest-path desktop/Cargo.toml"),
|
|
100
|
+
("Build desktop app", "cargo build --locked --manifest-path desktop/Cargo.toml"),
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
def test_desktop_job_contains_hard_gate_commands(step_name: str, command: str):
|
|
104
|
+
step = _step(WORKFLOW, "desktop-rust", step_name)
|
|
105
|
+
assert step.get("run") == command
|
|
106
|
+
_assert_hard(step)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_desktop_quality_gates_run_before_tests_and_build():
|
|
110
|
+
step_names = [step.get("name") for step in _job(WORKFLOW, "desktop-rust")["steps"]]
|
|
111
|
+
assert step_names.index("Check desktop formatting") < step_names.index("Lint desktop app")
|
|
112
|
+
assert step_names.index("Lint desktop app") < step_names.index("Test desktop app")
|
|
113
|
+
assert step_names.index("Test desktop app") < step_names.index("Build desktop app")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_windows_cli_job_installs_the_generator_python_runtime():
|
|
117
|
+
job = _job(WORKFLOW, "cli-windows")
|
|
118
|
+
uses = [step.get("uses", "") for step in job["steps"]]
|
|
119
|
+
assert any(use.startswith("actions/setup-python@") for use in uses)
|
|
120
|
+
install = _step(WORKFLOW, "cli-windows", "Install generator dependencies")
|
|
121
|
+
assert install.get("run") == "python -m pip install -r requirements.txt"
|
|
122
|
+
_assert_hard(install)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_python39_job_compiles_and_runs_the_four_generator_cli_steps():
|
|
126
|
+
job = _job(WORKFLOW, "python-compat")
|
|
127
|
+
setup = next(
|
|
128
|
+
step
|
|
129
|
+
for step in job["steps"]
|
|
130
|
+
if str(step.get("uses", "")).startswith("actions/setup-python@")
|
|
131
|
+
)
|
|
132
|
+
assert setup.get("with", {}).get("python-version") == "3.9"
|
|
133
|
+
smoke = _step(WORKFLOW, "python-compat", "Run Python 3.9 generator smoke")
|
|
134
|
+
command = smoke["run"]
|
|
135
|
+
assert "python -m compileall -q tools scripts" in command
|
|
136
|
+
assert "sutra_collector.py" in command
|
|
137
|
+
assert "verify_sources.py --check-links" in command
|
|
138
|
+
assert "master_builder.py" in command
|
|
139
|
+
assert "verify_sources.py --final-check" in command
|
|
140
|
+
_assert_hard(smoke)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_push_paths_include_distribution_and_generator_runtime():
|
|
144
|
+
triggers = WORKFLOW.get("on", WORKFLOW.get(True))
|
|
145
|
+
assert isinstance(triggers, dict)
|
|
146
|
+
paths = set(triggers["push"]["paths"])
|
|
147
|
+
assert {
|
|
148
|
+
"skill-catalog.json",
|
|
149
|
+
"SKILL.md",
|
|
150
|
+
"references/**",
|
|
151
|
+
"ETHICS.md",
|
|
152
|
+
"requirements.txt",
|
|
153
|
+
"README.md",
|
|
154
|
+
"README_EN.md",
|
|
155
|
+
"CONTRIBUTING.md",
|
|
156
|
+
"CHANGELOG.md",
|
|
157
|
+
"docs/PRD.md",
|
|
158
|
+
"docs/v1-framework-roadmap.md",
|
|
159
|
+
"masters/**",
|
|
160
|
+
".claude-plugin/**",
|
|
161
|
+
".cursor-plugin/**",
|
|
162
|
+
"gemini-extension.json",
|
|
163
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
164
|
+
".github/ISSUE_TEMPLATE/**",
|
|
165
|
+
} <= paths
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_pick_step_uses_checked_selector_without_fixed_roster():
|
|
169
|
+
checkout = next(
|
|
170
|
+
step
|
|
171
|
+
for step in _job(WORKFLOW, "fidelity-smoke")["steps"]
|
|
172
|
+
if str(step.get("uses", "")).startswith("actions/checkout@")
|
|
173
|
+
)
|
|
174
|
+
assert checkout.get("with", {}).get("fetch-depth") == 0
|
|
175
|
+
step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
176
|
+
script = step["run"]
|
|
177
|
+
assert "if ! CHANGED=$(python scripts/select-fidelity-smoke.py" in script
|
|
178
|
+
assert "if ! DIFF_OUTPUT=$(git diff --name-only" in script
|
|
179
|
+
assert "CHANGED_CANDIDATES" in script
|
|
180
|
+
assert "--prebuilt prebuilt" in script
|
|
181
|
+
assert '--day-of-year "$(date +%j)"' in script
|
|
182
|
+
assert "MASTERS=(" not in script
|
|
183
|
+
_assert_hard(step)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_smoke_selector_producer_failure_is_not_masked(tmp_path: Path):
|
|
187
|
+
pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
188
|
+
script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
|
|
189
|
+
|
|
190
|
+
bin_dir = tmp_path / "bin"
|
|
191
|
+
bin_dir.mkdir()
|
|
192
|
+
fake_python = bin_dir / "python"
|
|
193
|
+
fake_python.write_text(
|
|
194
|
+
"#!/bin/sh\nprintf 'master-partial\\n'\nexit 7\n",
|
|
195
|
+
encoding="utf-8",
|
|
196
|
+
)
|
|
197
|
+
fake_python.chmod(0o755)
|
|
198
|
+
output_path = tmp_path / "github-output"
|
|
199
|
+
env = os.environ.copy()
|
|
200
|
+
env["PATH"] = f"{bin_dir}:{env['PATH']}"
|
|
201
|
+
env["GITHUB_OUTPUT"] = str(output_path)
|
|
202
|
+
|
|
203
|
+
result = subprocess.run(
|
|
204
|
+
["bash", "-e", "-o", "pipefail", "-c", script],
|
|
205
|
+
cwd=ROOT,
|
|
206
|
+
env=env,
|
|
207
|
+
text=True,
|
|
208
|
+
capture_output=True,
|
|
209
|
+
check=False,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
assert result.returncode != 0
|
|
213
|
+
assert not output_path.exists()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_smoke_git_diff_failure_is_not_masked(tmp_path: Path):
|
|
217
|
+
pick_step = _step(WORKFLOW, "fidelity-smoke", "Pick smoke target")
|
|
218
|
+
script = pick_step["run"].replace("${{ github.base_ref || 'main' }}", "main")
|
|
219
|
+
|
|
220
|
+
bin_dir = tmp_path / "bin"
|
|
221
|
+
bin_dir.mkdir()
|
|
222
|
+
fake_git = bin_dir / "git"
|
|
223
|
+
fake_git.write_text("#!/bin/sh\nexit 7\n", encoding="utf-8")
|
|
224
|
+
fake_git.chmod(0o755)
|
|
225
|
+
fake_python = bin_dir / "python"
|
|
226
|
+
fake_python.write_text(
|
|
227
|
+
"#!/bin/sh\nprintf 'master-alpha\\n'\n",
|
|
228
|
+
encoding="utf-8",
|
|
229
|
+
)
|
|
230
|
+
fake_python.chmod(0o755)
|
|
231
|
+
output_path = tmp_path / "github-output"
|
|
232
|
+
env = os.environ.copy()
|
|
233
|
+
env["PATH"] = f"{bin_dir}:{env['PATH']}"
|
|
234
|
+
env["GITHUB_OUTPUT"] = str(output_path)
|
|
235
|
+
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
["bash", "-e", "-o", "pipefail", "-c", script],
|
|
238
|
+
cwd=ROOT,
|
|
239
|
+
env=env,
|
|
240
|
+
text=True,
|
|
241
|
+
capture_output=True,
|
|
242
|
+
check=False,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
assert result.returncode != 0
|
|
246
|
+
assert "git diff failed" in result.stdout
|
|
247
|
+
assert not output_path.exists()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.parametrize(
|
|
251
|
+
("job_name", "step_name"),
|
|
252
|
+
[
|
|
253
|
+
("fidelity-smoke", "Run fidelity smoke"),
|
|
254
|
+
("fidelity-full", "Run fidelity tests"),
|
|
255
|
+
],
|
|
256
|
+
)
|
|
257
|
+
def test_each_fidelity_no_key_branch_records_step_summary(job_name: str, step_name: str):
|
|
258
|
+
job = _job(WORKFLOW, job_name)
|
|
259
|
+
assert job.get("needs") == "validate"
|
|
260
|
+
step = _step(WORKFLOW, job_name, step_name)
|
|
261
|
+
script = step["run"]
|
|
262
|
+
assert 'if [ -z "${ANTHROPIC_API_KEY:-}" ]; then' in script
|
|
263
|
+
assert script.count('echo "### Fidelity grading skipped"') == 1
|
|
264
|
+
assert '} >> "$GITHUB_STEP_SUMMARY"' in script
|
|
265
|
+
_assert_hard(step)
|