master-skill 0.10.0 → 0.11.0
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/GEMINI.md +1 -1
- package/README.md +57 -295
- package/README_EN.md +59 -276
- package/SKILL.md +5 -5
- package/bin/cli.mjs +545 -78
- package/gemini-extension.json +1 -1
- package/hooks/run-hook.cmd +18 -5
- package/hooks/session-start +4 -1
- 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 +10 -3
- package/prebuilt/{compare → compare-masters}/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-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-fazang/SKILL.md +3 -3
- package/prebuilt/master-fazang/meta.json +8 -0
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -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 +20 -1
- 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 +25 -2
- 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 +32 -3
- 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 +91 -0
- package/references/traditions.md +72 -0
- package/references/workflow-details.md +361 -0
- package/requirements.txt +6 -0
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/select-fidelity-smoke.py +78 -0
- package/scripts/test-fidelity.py +339 -51
- package/scripts/tests/test_check_gate_liveness.py +232 -0
- package/scripts/tests/test_check_response.py +190 -0
- package/scripts/tests/test_fidelity_providers.py +202 -0
- package/scripts/tests/test_select_fidelity_smoke.py +142 -0
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +408 -0
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +284 -0
- package/scripts/validate-citation-contract.py +193 -0
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/scripts/verify_citations.py +8 -1
- package/skill-catalog.json +210 -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
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Behavior tests for deterministic fidelity smoke-target selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
14
|
+
SELECTOR = ROOT / "scripts" / "select-fidelity-smoke.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _write_meta(prebuilt: Path, slug: str, sources: object) -> None:
|
|
18
|
+
master_dir = prebuilt / slug
|
|
19
|
+
master_dir.mkdir(parents=True)
|
|
20
|
+
(master_dir / "meta.json").write_text(
|
|
21
|
+
json.dumps({"sources": sources}),
|
|
22
|
+
encoding="utf-8",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_selector(
|
|
27
|
+
prebuilt: Path,
|
|
28
|
+
day_of_year: str,
|
|
29
|
+
changed: str | list[str] | None = None,
|
|
30
|
+
) -> subprocess.CompletedProcess[str]:
|
|
31
|
+
command = [
|
|
32
|
+
sys.executable,
|
|
33
|
+
str(SELECTOR),
|
|
34
|
+
"--prebuilt",
|
|
35
|
+
str(prebuilt),
|
|
36
|
+
"--day-of-year",
|
|
37
|
+
day_of_year,
|
|
38
|
+
]
|
|
39
|
+
if changed is not None:
|
|
40
|
+
changed_values = [changed] if isinstance(changed, str) else changed
|
|
41
|
+
for candidate in changed_values:
|
|
42
|
+
command.extend(["--changed", candidate])
|
|
43
|
+
return subprocess.run(command, text=True, capture_output=True, check=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(
|
|
47
|
+
("day_of_year", "expected"),
|
|
48
|
+
[("008", "master-gamma"), ("099", "master-alpha"), ("100", "master-beta")],
|
|
49
|
+
)
|
|
50
|
+
def test_rotation_parses_zero_padded_days_as_decimal(
|
|
51
|
+
tmp_path: Path,
|
|
52
|
+
day_of_year: str,
|
|
53
|
+
expected: str,
|
|
54
|
+
):
|
|
55
|
+
prebuilt = tmp_path / "prebuilt"
|
|
56
|
+
_write_meta(prebuilt, "master-gamma", [{"id": "g"}])
|
|
57
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
58
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
59
|
+
|
|
60
|
+
result = _run_selector(prebuilt, day_of_year)
|
|
61
|
+
|
|
62
|
+
assert result.returncode == 0, result.stderr
|
|
63
|
+
assert result.stdout == f"{expected}\n"
|
|
64
|
+
assert result.stderr == ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_changed_persona_wins_only_when_in_discovered_roster(tmp_path: Path):
|
|
68
|
+
prebuilt = tmp_path / "prebuilt"
|
|
69
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
70
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
71
|
+
|
|
72
|
+
selected = _run_selector(prebuilt, "008", "master-beta")
|
|
73
|
+
rotated = _run_selector(prebuilt, "008", "master-not-discovered")
|
|
74
|
+
|
|
75
|
+
assert selected.returncode == 0, selected.stderr
|
|
76
|
+
assert selected.stdout == "master-beta\n"
|
|
77
|
+
assert rotated.returncode == 0, rotated.stderr
|
|
78
|
+
assert rotated.stdout == "master-alpha\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path):
|
|
82
|
+
prebuilt = tmp_path / "prebuilt"
|
|
83
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
84
|
+
_write_meta(prebuilt, "master-beta", [{"id": "b"}])
|
|
85
|
+
|
|
86
|
+
result = _run_selector(
|
|
87
|
+
prebuilt,
|
|
88
|
+
"008",
|
|
89
|
+
["compare-masters", "master-beta", "master-alpha"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert result.returncode == 0, result.stderr
|
|
93
|
+
assert result.stdout == "master-beta\n"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_discovery_ignores_meta_skills_and_empty_sources(tmp_path: Path):
|
|
97
|
+
prebuilt = tmp_path / "prebuilt"
|
|
98
|
+
_write_meta(prebuilt, "compare-masters", [{"id": "meta"}])
|
|
99
|
+
_write_meta(prebuilt, "master-empty", [])
|
|
100
|
+
_write_meta(prebuilt, "master-valid", [{"id": "source"}])
|
|
101
|
+
|
|
102
|
+
result = _run_selector(prebuilt, "100")
|
|
103
|
+
|
|
104
|
+
assert result.returncode == 0, result.stderr
|
|
105
|
+
assert result.stdout == "master-valid\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_empty_roster_fails_closed(tmp_path: Path):
|
|
109
|
+
prebuilt = tmp_path / "prebuilt"
|
|
110
|
+
_write_meta(prebuilt, "master-empty", [])
|
|
111
|
+
|
|
112
|
+
result = _run_selector(prebuilt, "008")
|
|
113
|
+
|
|
114
|
+
assert result.returncode != 0
|
|
115
|
+
assert result.stdout == ""
|
|
116
|
+
assert "No persona smoke targets" in result.stderr
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_invalid_json_after_valid_metadata_fails_without_partial_stdout(tmp_path: Path):
|
|
120
|
+
prebuilt = tmp_path / "prebuilt"
|
|
121
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
122
|
+
invalid_dir = prebuilt / "master-zeta"
|
|
123
|
+
invalid_dir.mkdir(parents=True)
|
|
124
|
+
(invalid_dir / "meta.json").write_text("{not-json", encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
result = _run_selector(prebuilt, "008")
|
|
127
|
+
|
|
128
|
+
assert result.returncode != 0
|
|
129
|
+
assert result.stdout == ""
|
|
130
|
+
assert "master-zeta/meta.json" in result.stderr
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("day_of_year", ["0", "367", "not-a-day"])
|
|
134
|
+
def test_invalid_day_fails_closed(tmp_path: Path, day_of_year: str):
|
|
135
|
+
prebuilt = tmp_path / "prebuilt"
|
|
136
|
+
_write_meta(prebuilt, "master-alpha", [{"id": "a"}])
|
|
137
|
+
|
|
138
|
+
result = _run_selector(prebuilt, day_of_year)
|
|
139
|
+
|
|
140
|
+
assert result.returncode != 0
|
|
141
|
+
assert result.stdout == ""
|
|
142
|
+
assert "day-of-year" in result.stderr
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Tests for scripts/validate.py — the strict gate `npm run validate` runs.
|
|
2
|
+
|
|
3
|
+
parse_frontmatter had no tests, which is how it kept only the last entry of
|
|
4
|
+
`sources:` and dropped every `cbeta_id`. The sources[] checks in lint_master
|
|
5
|
+
were reading a shape the file never had.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
MODULE_PATH = Path(__file__).resolve().parents[1] / "validate.py"
|
|
12
|
+
SPEC = importlib.util.spec_from_file_location("validate_module", MODULE_PATH)
|
|
13
|
+
validate_module = importlib.util.module_from_spec(SPEC)
|
|
14
|
+
SPEC.loader.exec_module(validate_module)
|
|
15
|
+
|
|
16
|
+
parse_frontmatter = validate_module.parse_frontmatter
|
|
17
|
+
|
|
18
|
+
FRONTMATTER = """---
|
|
19
|
+
name: master-test
|
|
20
|
+
description: Test master.
|
|
21
|
+
version: 0.5.0
|
|
22
|
+
license: MIT
|
|
23
|
+
lineage: 测试宗
|
|
24
|
+
dates: 638-713
|
|
25
|
+
sources:
|
|
26
|
+
- title: 六祖大师法宝坛经
|
|
27
|
+
cbeta_id: T48n2008
|
|
28
|
+
fojin_text_id: 58
|
|
29
|
+
- title: 金刚般若波罗蜜经
|
|
30
|
+
cbeta_id: T08n0235
|
|
31
|
+
fojin_text_id: 7
|
|
32
|
+
- title: 维摩诘所说经
|
|
33
|
+
cbeta_id: T14n0475
|
|
34
|
+
fojin_text_id: 28
|
|
35
|
+
citation_format: "【《{title}》{section},{cbeta_id}】"
|
|
36
|
+
verified_by: xr843
|
|
37
|
+
verified_at: 2026-04-06
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
# Body
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(tmp_path: Path, text: str) -> Path:
|
|
45
|
+
path = tmp_path / "SKILL.md"
|
|
46
|
+
path.write_text(text, encoding="utf-8")
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_parse_frontmatter_keeps_every_source(tmp_path):
|
|
51
|
+
"""All three declared sources survive, not just the last one."""
|
|
52
|
+
fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
|
|
53
|
+
|
|
54
|
+
titles = [s["title"] for s in fm["sources"]]
|
|
55
|
+
assert titles == ["六祖大师法宝坛经", "金刚般若波罗蜜经", "维摩诘所说经"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parse_frontmatter_keeps_continuation_keys(tmp_path):
|
|
59
|
+
"""cbeta_id and fojin_text_id sit on continuation lines and must survive.
|
|
60
|
+
|
|
61
|
+
Every citation check downstream keys off cbeta_id; dropping it silently
|
|
62
|
+
empties the data the sources[] rules are supposed to inspect.
|
|
63
|
+
"""
|
|
64
|
+
fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
|
|
65
|
+
|
|
66
|
+
assert [s.get("cbeta_id") for s in fm["sources"]] == [
|
|
67
|
+
"T48n2008",
|
|
68
|
+
"T08n0235",
|
|
69
|
+
"T14n0475",
|
|
70
|
+
]
|
|
71
|
+
assert fm["sources"][0].get("fojin_text_id") == 58
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_parse_frontmatter_reads_scalar_keys(tmp_path):
|
|
75
|
+
"""Scalar keys around the list keep working."""
|
|
76
|
+
fm, _, _ = parse_frontmatter(_write(tmp_path, FRONTMATTER))
|
|
77
|
+
|
|
78
|
+
assert fm["name"] == "master-test"
|
|
79
|
+
assert fm["lineage"] == "测试宗"
|
|
80
|
+
assert fm["citation_format"] == "【《{title}》{section},{cbeta_id}】"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_parse_frontmatter_surfaces_a_malformed_source_before_the_last(tmp_path):
|
|
84
|
+
"""A source with neither title nor cbeta_id must reach lint_master.
|
|
85
|
+
|
|
86
|
+
lint_master flags `sources[i] missing 'title' or 'cbeta_id'`, but only
|
|
87
|
+
ever saw the final entry — a malformed one anywhere earlier was invisible.
|
|
88
|
+
"""
|
|
89
|
+
text = FRONTMATTER.replace(
|
|
90
|
+
" - title: 六祖大师法宝坛经\n cbeta_id: T48n2008\n fojin_text_id: 58\n",
|
|
91
|
+
" - fojin_text_id: 58\n note: no title and no cbeta_id\n",
|
|
92
|
+
)
|
|
93
|
+
fm, _, _ = parse_frontmatter(_write(tmp_path, text))
|
|
94
|
+
|
|
95
|
+
assert len(fm["sources"]) == 3
|
|
96
|
+
bad = fm["sources"][0]
|
|
97
|
+
assert "title" not in bad and "cbeta_id" not in bad
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_parse_frontmatter_without_frontmatter(tmp_path):
|
|
101
|
+
"""A file with no frontmatter yields an empty dict, not a crash."""
|
|
102
|
+
fm, body, _ = parse_frontmatter(_write(tmp_path, "# Just a body\n"))
|
|
103
|
+
|
|
104
|
+
assert fm == {}
|
|
105
|
+
assert "Just a body" in body
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_parse_frontmatter_rejects_invalid_yaml(tmp_path):
|
|
109
|
+
"""Malformed frontmatter raises instead of being silently mis-parsed.
|
|
110
|
+
|
|
111
|
+
The old parser accepted anything, which let two skills ship a description
|
|
112
|
+
holding a bare `: ` — YAML reads that as a nested mapping and rejects the
|
|
113
|
+
whole block. Clients that parse frontmatter strictly see no frontmatter
|
|
114
|
+
at all, so this must fail loudly here.
|
|
115
|
+
"""
|
|
116
|
+
text = "---\nname: master-test\ndescription: keyed on 时序: staged plan\n---\n"
|
|
117
|
+
try:
|
|
118
|
+
parse_frontmatter(_write(tmp_path, text))
|
|
119
|
+
except ValueError as exc:
|
|
120
|
+
assert "invalid YAML frontmatter" in str(exc)
|
|
121
|
+
else:
|
|
122
|
+
raise AssertionError("invalid YAML frontmatter was accepted")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_curriculum_sources_subcheck_is_wired_in():
|
|
126
|
+
"""The curriculum gate must be reachable from validate.py.
|
|
127
|
+
|
|
128
|
+
master-curriculum/SKILL.md claims CI enforces it, but the script was in no
|
|
129
|
+
workflow, no npm script and no sub-check — only unit tests over synthetic
|
|
130
|
+
trees. It passed against the real references/ by luck, never by check.
|
|
131
|
+
"""
|
|
132
|
+
assert hasattr(validate_module, "_run_curriculum_sources_subcheck")
|
|
133
|
+
assert validate_module._run_curriculum_sources_subcheck() == []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_every_prebuilt_skill_has_parseable_frontmatter():
|
|
137
|
+
"""Every shipped SKILL.md must parse — this is the case that would have
|
|
138
|
+
caught master-curriculum and master-debate before they shipped."""
|
|
139
|
+
prebuilt = Path(__file__).resolve().parents[2] / "prebuilt"
|
|
140
|
+
skills = sorted(prebuilt.glob("*/SKILL.md"))
|
|
141
|
+
assert skills, f"no SKILL.md found under {prebuilt}"
|
|
142
|
+
|
|
143
|
+
for skill in skills:
|
|
144
|
+
fm, _, _ = parse_frontmatter(skill)
|
|
145
|
+
assert fm.get("name"), f"{skill.parent.name}: frontmatter has no name"
|
|
@@ -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-masters" / "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
|