master-skill 0.10.1 → 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/GEMINI.md +1 -1
- package/README.md +52 -297
- package/README_EN.md +52 -278
- package/bin/cli.mjs +237 -2
- package/gemini-extension.json +1 -1
- package/hooks/session-start +4 -1
- package/package.json +3 -2
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- package/prebuilt/master-nagarjuna/meta.json +19 -4
- package/prebuilt/master-tsongkhapa/meta.json +26 -5
- package/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/test-fidelity.py +320 -49
- 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 +2 -2
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +1 -1
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +21 -2
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/skill-catalog.json +83 -20
- /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
package/routing.json
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"_comment": "Machine-readable routing table. Persona keywords are NOT duplicated here — they live in each prebuilt/<slug>/meta.json search_scope.keywords and are read at match time. This file holds only what has no other machine-readable home: teaching-mode short-circuit order, the topic->pairing fallback, and the scoring constants. scripts/validate-routing.py enforces that every keyword set is pairwise disjoint (no string appears twice, and no string is a substring of another row's string), so a match can never depend on iteration order.",
|
|
4
|
+
"weights": {
|
|
5
|
+
"keyword_hit": 3
|
|
6
|
+
},
|
|
7
|
+
"_min_keyword_length_comment": "Persona keywords shorter than this are ignored when scoring. Seven single-character doctrinal atoms (空 戒 定 慧 苦 禅 业) live in meta.json search_scope.keywords, and matching by containment made them fire on ordinary Chinese: 「有空吗」 scored Kumārajīva and Nāgārjuna, 「我戒烟了」 scored Buddhaghosa. Every one of them also appears inside a longer keyword (空性 / 持戒 / 戒定慧 / 毕竟空 …), so dropping them costs no real recall; queries that only carry the bare atom fall through to topic_pairings, which is what that layer is for.",
|
|
8
|
+
"min_keyword_length": 2,
|
|
9
|
+
"mode_rules": [
|
|
10
|
+
{
|
|
11
|
+
"mode": "master-curriculum",
|
|
12
|
+
"order": 1,
|
|
13
|
+
"note": "纵向 / 时序:请求学修路径,不是比较",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"学习计划",
|
|
16
|
+
"学修次第",
|
|
17
|
+
"入门",
|
|
18
|
+
"先学什么",
|
|
19
|
+
"从哪开始",
|
|
20
|
+
"开始学",
|
|
21
|
+
"应该读",
|
|
22
|
+
"下一步读什么",
|
|
23
|
+
"路径推荐",
|
|
24
|
+
"按什么顺序",
|
|
25
|
+
"curriculum",
|
|
26
|
+
"roadmap"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"mode": "master-debate",
|
|
31
|
+
"order": 2,
|
|
32
|
+
"note": "横向 / 对立:4 轮立论-反驳-回应-综合",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"辩论",
|
|
35
|
+
"各执一词",
|
|
36
|
+
"谁更对",
|
|
37
|
+
"高下",
|
|
38
|
+
"之争",
|
|
39
|
+
"之辩",
|
|
40
|
+
"分判",
|
|
41
|
+
"debate"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"mode": "compare-masters",
|
|
46
|
+
"order": 3,
|
|
47
|
+
"note": "横向 / 并列:单轮多祖师平铺对比",
|
|
48
|
+
"keywords": [
|
|
49
|
+
"对比",
|
|
50
|
+
"比较",
|
|
51
|
+
"不同",
|
|
52
|
+
"各派怎么看",
|
|
53
|
+
"各位祖师",
|
|
54
|
+
"多个角度",
|
|
55
|
+
"异同",
|
|
56
|
+
"compare"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"_situations_comment": "Vernacular felt-state routing, consulted after persona keyword scoring and before topic_pairings. search_scope.keywords are doctrinal retrieval terms; a beginner does not type 四念处, they type 坐不住. Empirically, 4 of the 11 rows in the README 「你的状况」 table resolved to default_pairing because no doctrinal keyword matched — those 4 are the rows below. The other 7 already route correctly off persona keywords and are deliberately NOT duplicated here: this layer holds only what the keyword layer cannot reach.",
|
|
61
|
+
"situations": [
|
|
62
|
+
{
|
|
63
|
+
"id": "scattered-mind",
|
|
64
|
+
"keywords": ["妄念", "杂念", "坐不住", "静不下来", "定不下来", "心乱"],
|
|
65
|
+
"masters": ["master-xuyun", "master-zhiyi", "master-ajahn-chah"],
|
|
66
|
+
"note": "参话头 / 止观 / 正念观察"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "texts-opaque",
|
|
70
|
+
"keywords": ["看不懂", "读不懂", "理不清", "没有逻辑"],
|
|
71
|
+
"masters": ["master-xuanzang"],
|
|
72
|
+
"note": "唯识严密分析"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "no-traction",
|
|
76
|
+
"keywords": ["无力感", "使不上力", "没有进步", "学佛很久", "提不起劲"],
|
|
77
|
+
"masters": ["master-yinguang"],
|
|
78
|
+
"note": "老实念佛"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "plainest-practice",
|
|
82
|
+
"keywords": ["最朴素", "朴素", "最简单的修法"],
|
|
83
|
+
"masters": ["master-ajahn-chah"],
|
|
84
|
+
"note": "南传森林禅 · 出入息念"
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
"topic_pairings": [
|
|
88
|
+
{
|
|
89
|
+
"id": "pureland",
|
|
90
|
+
"keywords": ["念佛", "往生", "净土"],
|
|
91
|
+
"masters": ["master-yinguang", "master-ouyi"],
|
|
92
|
+
"note": "净土专精 + 跨宗派"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "chan-practice",
|
|
96
|
+
"keywords": ["参禅", "话头", "开悟"],
|
|
97
|
+
"masters": ["master-huineng", "master-xuyun"],
|
|
98
|
+
"note": "古今禅宗对比"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"id": "yogacara-vs-madhyamaka",
|
|
102
|
+
"keywords": ["唯识", "空有", "性相", "法相"],
|
|
103
|
+
"masters": ["master-xuanzang", "master-kumarajiva"],
|
|
104
|
+
"note": "唯识 vs 中观"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"id": "panjiao",
|
|
108
|
+
"keywords": ["判教", "圆融", "止观"],
|
|
109
|
+
"masters": ["master-zhiyi", "master-fazang"],
|
|
110
|
+
"note": "天台 vs 华严"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "integrated-path",
|
|
114
|
+
"keywords": ["修行次第", "综合法门"],
|
|
115
|
+
"masters": ["master-ouyi", "master-yinguang"],
|
|
116
|
+
"note": "综合 vs 专修"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "vinaya",
|
|
120
|
+
"keywords": ["戒律", "持戒", "律仪", "行持"],
|
|
121
|
+
"masters": ["master-xuyun", "master-atisha", "master-buddhaghosa"],
|
|
122
|
+
"note": "汉传禅门律 vs 印藏菩萨律 vs 上座部律藏注释(合并原「戒律/行持/日常」与「戒律/持戒/律仪」两行,去除键冲突)"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"id": "emptiness",
|
|
126
|
+
"keywords": ["般若", "空性", "中观", "缘起性空", "应成", "毕竟空"],
|
|
127
|
+
"masters": ["master-kumarajiva", "master-tsongkhapa", "master-huineng"],
|
|
128
|
+
"note": "早期中观译师 · 应成中观精确分判 · 禅宗直指(合并原「般若/空性」与「中观/缘起性空/应成」两行)"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "lamrim",
|
|
132
|
+
"keywords": ["道次第", "三士道", "下士道", "中士道", "上士道", "lam rim"],
|
|
133
|
+
"masters": ["master-atisha", "master-tsongkhapa"],
|
|
134
|
+
"note": "印藏桥梁源头 vs 格鲁派系统化"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"id": "consciousness",
|
|
138
|
+
"keywords": ["心识", "阿赖耶", "心所", "末那"],
|
|
139
|
+
"masters": ["master-xuanzang", "master-buddhaghosa", "master-huineng"],
|
|
140
|
+
"note": "唯识 vs 上座部阿毗达摩 vs 禅宗直指"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"id": "ascetic",
|
|
144
|
+
"keywords": ["苦行", "闭关", "山中修行", "头陀"],
|
|
145
|
+
"masters": ["master-xuyun", "master-milarepa"],
|
|
146
|
+
"note": "汉传禅门头陀 vs 藏传瑜伽士传统"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"id": "mindfulness",
|
|
150
|
+
"keywords": ["正念", "观心", "觉知"],
|
|
151
|
+
"masters": ["master-huineng", "master-ajahn-chah", "master-mahasi-sayadaw"],
|
|
152
|
+
"note": "禅宗自性 vs 南传 sati 朴素 vs 缅甸标记法"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "meditation-objects",
|
|
156
|
+
"keywords": ["禅修方法", "业处", "所缘"],
|
|
157
|
+
"masters": ["master-buddhaghosa", "master-mahasi-sayadaw", "master-ajahn-chah"],
|
|
158
|
+
"note": "论藏四十种业处 vs 标记法腹部起伏 vs 朴素观心"
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"id": "insight-stages",
|
|
162
|
+
"keywords": ["七清净", "十六观智", "观智"],
|
|
163
|
+
"masters": ["master-buddhaghosa", "master-mahasi-sayadaw"],
|
|
164
|
+
"note": "《清净道论》原典 vs 现代缅甸应用(原行含「道次第」,已移除以避免与 lamrim 冲突)"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"id": "renunciation",
|
|
168
|
+
"keywords": ["出离心", "暇满", "无常"],
|
|
169
|
+
"masters": ["master-yinguang", "master-atisha", "master-ajahn-chah"],
|
|
170
|
+
"note": "净土 · 噶当 · 上座部跨传统出离观对比"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"id": "bodhicitta",
|
|
174
|
+
"keywords": ["菩提心", "慈悲", "自他相换"],
|
|
175
|
+
"masters": ["master-atisha", "master-ouyi"],
|
|
176
|
+
"note": "印藏自他相换 vs 跨宗派融通"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"id": "teacher",
|
|
180
|
+
"keywords": ["上师", "善知识", "依止"],
|
|
181
|
+
"masters": ["master-xuyun", "master-atisha", "master-tsongkhapa"],
|
|
182
|
+
"note": "汉传善知识 vs 噶当依止论 vs 格鲁视师如佛"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"id": "scholastic",
|
|
186
|
+
"keywords": ["论师风格", "经院严密", "因明"],
|
|
187
|
+
"masters": ["master-xuanzang", "master-tsongkhapa", "master-buddhaghosa"],
|
|
188
|
+
"note": "唯识 · 应成中观 · 上座部三大论师传统"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"id": "four-traditions",
|
|
192
|
+
"keywords": ["四大传统", "四方对照"],
|
|
193
|
+
"masters": [
|
|
194
|
+
"master-nagarjuna",
|
|
195
|
+
"master-huineng",
|
|
196
|
+
"master-tsongkhapa",
|
|
197
|
+
"master-buddhaghosa"
|
|
198
|
+
],
|
|
199
|
+
"note": "印度中观 · 禅 · 应成中观 · 上座部论藏,四方系统对照"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
"id": "cross-tradition-meditation",
|
|
203
|
+
"keywords": ["跨传统禅修", "大手印"],
|
|
204
|
+
"masters": ["master-huineng", "master-milarepa", "master-ajahn-chah"],
|
|
205
|
+
"note": "禅 · 大手印 · 森林禅,跨传统禅修法"
|
|
206
|
+
}
|
|
207
|
+
],
|
|
208
|
+
"default_pairing": ["master-kumarajiva", "master-yinguang"]
|
|
209
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Assert that this repo's gates actually examined something.
|
|
3
|
+
|
|
4
|
+
Three shipped defects were the same shape — a gate examined an empty set and
|
|
5
|
+
reported success:
|
|
6
|
+
|
|
7
|
+
- `pytest.ini` listed `testpaths = tests` while CI passed `scripts/tests/`
|
|
8
|
+
explicitly, so neither suite ever ran the other's cases.
|
|
9
|
+
- `tests/test_voice_rules.py` globbed `prebuilt/<slug>/voice.md` when
|
|
10
|
+
voice.md lives under `references/`. The empty glob parametrized every case
|
|
11
|
+
over an empty set: nothing asserted, reported green.
|
|
12
|
+
- The fidelity smoke — a branch-protection-required check — writes
|
|
13
|
+
`{"skipped": true, "reason": "no_api_key"}` and exits 0 when the secret is
|
|
14
|
+
missing, which it always has been.
|
|
15
|
+
|
|
16
|
+
None of those was a wrong assertion. Each was an assertion that never ran, and
|
|
17
|
+
a passing check is indistinguishable from a check that did nothing unless
|
|
18
|
+
something asserts otherwise. That is this script's whole job.
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 scripts/check-gate-liveness.py # check this repo
|
|
22
|
+
python3 scripts/check-gate-liveness.py --json # machine-readable
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
# A verdict — as opposed to a skip, an error, or a dry run.
|
|
35
|
+
GRADED_STATUSES = {"PASS", "FAIL"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_every_test_file_collects(
|
|
39
|
+
test_files: list[str], collected_counts: dict[str, int]
|
|
40
|
+
) -> list[str]:
|
|
41
|
+
"""Every test file must contribute at least one collected test.
|
|
42
|
+
|
|
43
|
+
A file that imports cleanly and yields nothing is the voice_rules failure:
|
|
44
|
+
pytest reports success because there was nothing to fail.
|
|
45
|
+
"""
|
|
46
|
+
problems = []
|
|
47
|
+
for path in sorted(test_files):
|
|
48
|
+
if collected_counts.get(path, 0) < 1:
|
|
49
|
+
problems.append(
|
|
50
|
+
f"{path} collected 0 tests — it asserts nothing but reports green "
|
|
51
|
+
"(empty glob or empty parametrize?)"
|
|
52
|
+
)
|
|
53
|
+
return problems
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_testpaths_cover_suites(
|
|
57
|
+
testpaths: list[str], test_dirs: list[str]
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
"""Every directory holding tests must be reachable from a bare `pytest`."""
|
|
60
|
+
covered = set(testpaths)
|
|
61
|
+
return [
|
|
62
|
+
f"{d} holds tests but is not in pytest.ini testpaths — a bare `pytest` skips it"
|
|
63
|
+
for d in sorted(test_dirs)
|
|
64
|
+
if d not in covered
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_graded_suites_graded_something(suites: list[dict]) -> list[str]:
|
|
69
|
+
"""A graded fidelity suite that produced no verdict must not read as a pass.
|
|
70
|
+
|
|
71
|
+
Dry runs are exempt: grading nothing is what a dry run is for.
|
|
72
|
+
"""
|
|
73
|
+
problems = []
|
|
74
|
+
for suite in suites:
|
|
75
|
+
if suite.get("mode") == "dry_run":
|
|
76
|
+
continue
|
|
77
|
+
verdicts = [
|
|
78
|
+
r for r in suite.get("results", [])
|
|
79
|
+
if str(r.get("status", "")).upper() in GRADED_STATUSES
|
|
80
|
+
]
|
|
81
|
+
if not verdicts:
|
|
82
|
+
problems.append(
|
|
83
|
+
f"{suite.get('master', '?')}: graded suite produced 0 verdicts "
|
|
84
|
+
"— it graded nothing (missing API key, or every call errored)"
|
|
85
|
+
)
|
|
86
|
+
return problems
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_catalog_matches_filesystem(
|
|
90
|
+
catalog: dict, prebuilt_dir: Path, root: Path
|
|
91
|
+
) -> list[str]:
|
|
92
|
+
"""The catalog and `prebuilt/` must name the same set of skills."""
|
|
93
|
+
entries = catalog.get("skills", [])
|
|
94
|
+
if not entries:
|
|
95
|
+
problems = ["skill-catalog.json lists no skills — an empty catalog validates vacuously"]
|
|
96
|
+
return problems
|
|
97
|
+
|
|
98
|
+
problems = []
|
|
99
|
+
catalog_sources = set()
|
|
100
|
+
for entry in entries:
|
|
101
|
+
source = entry.get("source", "")
|
|
102
|
+
catalog_sources.add(source)
|
|
103
|
+
if source.startswith("prebuilt/") and not (root / source).is_dir():
|
|
104
|
+
problems.append(
|
|
105
|
+
f"{entry.get('name', '?')}: catalog points at {source}, which does not exist"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if prebuilt_dir.is_dir():
|
|
109
|
+
for d in sorted(p for p in prebuilt_dir.iterdir() if p.is_dir()):
|
|
110
|
+
rel = f"prebuilt/{d.name}"
|
|
111
|
+
if rel not in catalog_sources:
|
|
112
|
+
problems.append(
|
|
113
|
+
f"{d.name}: directory exists under prebuilt/ but no catalog entry "
|
|
114
|
+
"claims it — it ships to nobody and no gate examines it"
|
|
115
|
+
)
|
|
116
|
+
return problems
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_every_skill_has_fixtures(prebuilt_dir: Path) -> list[str]:
|
|
120
|
+
"""Every prebuilt skill must carry at least one fidelity fixture."""
|
|
121
|
+
if not prebuilt_dir.is_dir():
|
|
122
|
+
return [f"{prebuilt_dir} does not exist — nothing to examine"]
|
|
123
|
+
|
|
124
|
+
problems = []
|
|
125
|
+
for d in sorted(p for p in prebuilt_dir.iterdir() if p.is_dir()):
|
|
126
|
+
fixtures = d / "tests" / "fidelity.jsonl"
|
|
127
|
+
if not fixtures.exists():
|
|
128
|
+
problems.append(f"{d.name}: no tests/fidelity.jsonl — nothing grades this skill")
|
|
129
|
+
continue
|
|
130
|
+
lines = [ln for ln in fixtures.read_text(encoding="utf-8").splitlines() if ln.strip()]
|
|
131
|
+
if not lines:
|
|
132
|
+
problems.append(f"{d.name}: tests/fidelity.jsonl is empty — it grades 0 cases")
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Repo-level wiring
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover_test_files(root: Path) -> list[str]:
|
|
142
|
+
return sorted(
|
|
143
|
+
str(p.relative_to(root))
|
|
144
|
+
for d in ("tests", "scripts/tests")
|
|
145
|
+
for p in (root / d).glob("test_*.py")
|
|
146
|
+
if (root / d).is_dir()
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def discover_test_dirs(root: Path) -> list[str]:
|
|
151
|
+
return sorted(
|
|
152
|
+
d for d in ("tests", "scripts/tests")
|
|
153
|
+
if (root / d).is_dir() and any((root / d).glob("test_*.py"))
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def read_testpaths(root: Path) -> list[str]:
|
|
158
|
+
ini = root / "pytest.ini"
|
|
159
|
+
if not ini.exists():
|
|
160
|
+
return []
|
|
161
|
+
for line in ini.read_text(encoding="utf-8").splitlines():
|
|
162
|
+
if line.strip().startswith("testpaths"):
|
|
163
|
+
return line.split("=", 1)[1].split()
|
|
164
|
+
return []
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def collect_counts(root: Path) -> dict[str, int]:
|
|
168
|
+
"""Ask pytest what it actually collects, per file."""
|
|
169
|
+
proc = subprocess.run(
|
|
170
|
+
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
|
|
171
|
+
cwd=root, capture_output=True, text=True,
|
|
172
|
+
)
|
|
173
|
+
counts: dict[str, int] = {}
|
|
174
|
+
for line in proc.stdout.splitlines():
|
|
175
|
+
match = re.match(r"^([\w./-]+\.py)::", line.strip())
|
|
176
|
+
if match:
|
|
177
|
+
counts[match.group(1)] = counts.get(match.group(1), 0) + 1
|
|
178
|
+
return counts
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_all(root: Path) -> list[str]:
|
|
182
|
+
problems: list[str] = []
|
|
183
|
+
|
|
184
|
+
test_files = discover_test_files(root)
|
|
185
|
+
if not test_files:
|
|
186
|
+
return ["no test files found at all — this check would pass vacuously"]
|
|
187
|
+
|
|
188
|
+
problems += check_every_test_file_collects(test_files, collect_counts(root))
|
|
189
|
+
problems += check_testpaths_cover_suites(read_testpaths(root), discover_test_dirs(root))
|
|
190
|
+
|
|
191
|
+
catalog_path = root / "skill-catalog.json"
|
|
192
|
+
if catalog_path.exists():
|
|
193
|
+
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
194
|
+
problems += check_catalog_matches_filesystem(catalog, root / "prebuilt", root)
|
|
195
|
+
|
|
196
|
+
problems += check_every_skill_has_fixtures(root / "prebuilt")
|
|
197
|
+
return problems
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def main() -> int:
|
|
201
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
202
|
+
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
|
|
203
|
+
parser.add_argument("--json", action="store_true", help="machine-readable output")
|
|
204
|
+
args = parser.parse_args()
|
|
205
|
+
|
|
206
|
+
problems = run_all(args.root)
|
|
207
|
+
|
|
208
|
+
if args.json:
|
|
209
|
+
print(json.dumps({"problems": problems, "ok": not problems}, ensure_ascii=False, indent=2))
|
|
210
|
+
elif problems:
|
|
211
|
+
print(f"✗ {len(problems)} gate-liveness problem(s):\n")
|
|
212
|
+
for p in problems:
|
|
213
|
+
print(f" - {p}")
|
|
214
|
+
print("\nA gate that examines nothing reports the same green as one that passes.")
|
|
215
|
+
else:
|
|
216
|
+
print("✓ gate liveness ok — every gate examined a non-empty set")
|
|
217
|
+
|
|
218
|
+
return 1 if problems else 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|