Skip to content

Commit 56196ef

Browse files
authored
Merge pull request #304 from xr843/refactor/shared-skill-io
重构:脚本共享路径与读取函数收进 _skill_io,npm 包不再发布 CI 专用脚本
2 parents 2873ac8 + 6ba39c5 commit 56196ef

32 files changed

Lines changed: 587 additions & 232 deletions

‎CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ Sections marked **Ethics** track changes to `ETHICS.md`, content licensing, or b
3838
vs 法相, armed both ways in cross_critique).
3939
- These change persona inputs for xuyun and kumarajiva; their fidelity numbers need a
4040
fresh measured run.
41+
- `scripts/_skill_io.py` holds what twenty-odd scripts each wrote out: the
42+
repo / `prebuilt/` paths, SKILL.md frontmatter reading, the fidelity-fixture
43+
loader, JSON reading, and the `## ` section splitter of `cite.py` / `query.py`.
44+
The two frontmatter readers stay two (line-fenced for `validate.py`, split on
45+
`---` for the `sources:` readers) because they disagree on malformed input;
46+
`scripts/tests/test_skill_io.py` pins both on 14 edge cases and checks they
47+
agree on every prebuilt SKILL.md. tools/ keeps its own readers: the
48+
create-master install copies tools/ without scripts/. No gate output or exit
49+
code changed — every validator was run on the tree and on five broken copies
50+
before and after, and only crash tracebacks differ. `test-fidelity.py`'s
51+
`run_tests` lost its dry-run, plan, SDK-client and audit-input blocks to named
52+
helpers.
53+
- The npm package no longer ships the 18 CI-only scripts (`run-gates.py`,
54+
`check-gate-liveness.py`, `regrade-report.py`, the content gates nothing in the
55+
package invokes, ...): 246 → 229 files, 562 KB → 510 KB. Everything installed
56+
skills, the README or a shipped script references still ships, and a CLI test
57+
fails if a shipped script imports a sibling that does not.
4158

4259
- Paid fidelity evaluations no longer run on a schedule. The Monday crons in
4360
`validate-and-test.yml` and `persona-fidelity.yml` ran without

‎package.json‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@
8484
"README.md",
8585
"README_EN.md",
8686
"!scripts/tests",
87+
"!scripts/check-audit-ignores.py",
88+
"!scripts/check-eval-sdk-surface.py",
89+
"!scripts/check-gate-liveness.py",
90+
"!scripts/check-pe-subsystem.py",
91+
"!scripts/check-release-fidelity.py",
92+
"!scripts/reaudit-report.py",
93+
"!scripts/regrade-report.py",
94+
"!scripts/run-gates.py",
95+
"!scripts/select-fidelity-smoke.py",
96+
"!scripts/smoke-eval-sdk.py",
97+
"!scripts/validate-citation-references.py",
98+
"!scripts/validate-citation-templates.py",
99+
"!scripts/validate-cross-critique.py",
100+
"!scripts/validate-fixture-terms.py",
101+
"!scripts/validate-quote-attribution.py",
102+
"!scripts/validate-section-references.py",
103+
"!scripts/validate-self-audit-sources.py",
104+
"!scripts/verify-adjudication.py",
87105
"!hooks/tests",
88106
"!**/__pycache__",
89107
"!**/*.pyc"

‎scripts/_skill_io.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Shared repo paths and file readers for the scripts under scripts/.
2+
3+
Twenty-odd scripts each spelled `Path(__file__).resolve().parent.parent`, and
4+
the same few readers — SKILL.md frontmatter, the fidelity fixtures, a JSON file
5+
that may be missing — were written out again wherever they were needed. This
6+
module is the one copy. It is stdlib-only at import time (pyyaml is imported
7+
only when frontmatter is parsed) and Python 3.9 compatible: the generator
8+
compatibility job compiles every script under 3.9.
9+
10+
Scripts import it as a sibling (`from _skill_io import ROOT`): running
11+
`python3 scripts/x.py` puts scripts/ on sys.path, and the pytest conftests do
12+
the same for in-process tests.
13+
14+
tools/ does not import it, deliberately. The create-master install copies
15+
tools/ without scripts/ (skill-catalog.json `bundle_paths`), so tools/ must
16+
stay self-contained; the dependency runs only scripts/ -> tools/. That is why
17+
tools/verify_sources.py and tools/master_builder.py keep their own
18+
`text.split("---", 2)` readers.
19+
20+
Two frontmatter readers, on purpose. They disagree on malformed input and each
21+
caller's behaviour is kept exactly (scripts/tests/test_skill_io.py pins both):
22+
23+
* `split_frontmatter` / `load_frontmatter` split on the first two `---`
24+
substrings anywhere in the text. Leading whitespace before the opening
25+
`---` is accepted; a `---` inside a value (`name: a---b`) ends the block
26+
early. Same semantics as tools/verify_sources.py's two readers.
27+
* `parse_frontmatter_lines` (validate.py) needs the opening and closing
28+
fences as whole lines, so `a---b` is read whole and a leading blank line
29+
means no frontmatter; a non-mapping or invalid block raises ValueError.
30+
31+
On every prebuilt SKILL.md the two return the same mapping.
32+
"""
33+
from __future__ import annotations
34+
35+
import json
36+
import re
37+
from pathlib import Path
38+
from typing import Any, Dict, List, Optional, Tuple
39+
40+
SCRIPTS_DIR = Path(__file__).resolve().parent
41+
ROOT = SCRIPTS_DIR.parent
42+
PREBUILT_DIR = ROOT / "prebuilt"
43+
44+
45+
# --- SKILL.md frontmatter -------------------------------------------------
46+
47+
48+
def split_frontmatter(text: str) -> Optional[str]:
49+
"""Raw YAML between the first two `---`, or None when there is no block.
50+
51+
No block means: fewer than two `---` in the text, or anything other than
52+
whitespace before the first one.
53+
"""
54+
parts = text.split("---", 2)
55+
if len(parts) < 3 or parts[0].strip():
56+
return None
57+
return parts[1]
58+
59+
60+
def load_frontmatter(text: str) -> Any:
61+
"""`split_frontmatter` parsed with yaml.safe_load; {} when absent or empty.
62+
63+
YAML errors propagate. A block that parses to a non-mapping is returned
64+
as parsed, not coerced — callers that call `.get` on it fail as before.
65+
"""
66+
raw = split_frontmatter(text)
67+
if raw is None:
68+
return {}
69+
import yaml
70+
71+
return yaml.safe_load(raw) or {}
72+
73+
74+
def parse_frontmatter_lines(text: str, source: object = "<string>") -> Tuple[dict, str, List[str]]:
75+
"""Line-fenced frontmatter: returns (mapping, body, lines).
76+
77+
The first line must be `---` (surrounding whitespace ignored) and the block
78+
ends at the next such line. Without both fences the result is
79+
({}, text, lines). Invalid YAML or a non-mapping block raises ValueError
80+
prefixed with `source`.
81+
"""
82+
lines = text.splitlines()
83+
if not lines or lines[0].strip() != "---":
84+
return {}, text, lines
85+
86+
end = None
87+
for i, line in enumerate(lines[1:], start=1):
88+
if line.strip() == "---":
89+
end = i
90+
break
91+
if end is None:
92+
return {}, text, lines
93+
94+
import yaml
95+
96+
try:
97+
fm = yaml.safe_load("\n".join(lines[1:end])) or {}
98+
except yaml.YAMLError as exc:
99+
raise ValueError(f"{source}: invalid YAML frontmatter — {exc}") from exc
100+
if not isinstance(fm, dict):
101+
raise ValueError(f"{source}: frontmatter is not a mapping")
102+
103+
body = "\n".join(lines[end + 1 :])
104+
return fm, body, lines
105+
106+
107+
# --- sources/*.md sections ------------------------------------------------
108+
109+
110+
def parse_sections(text: str) -> List[Tuple[str, str]]:
111+
"""按 ## 标题分段,返回 [(title, body), ...]"""
112+
sections = []
113+
parts = re.split(r'^## ', text, flags=re.MULTILINE)
114+
for part in parts[1:]:
115+
lines = part.split('\n', 1)
116+
title = lines[0].strip()
117+
body = lines[1] if len(lines) > 1 else ''
118+
sections.append((title, body))
119+
return sections
120+
121+
122+
# --- fixtures and JSON ----------------------------------------------------
123+
124+
125+
def load_fixtures(prebuilt: Path = PREBUILT_DIR) -> Dict[str, List[dict]]:
126+
"""{skill dir name: [fixture, ...]} from every `<prebuilt>/*/tests/fidelity.jsonl`.
127+
128+
Read with the platform default encoding, as both former copies did.
129+
"""
130+
fixtures: Dict[str, List[dict]] = {}
131+
for path in sorted(prebuilt.glob("*/tests/fidelity.jsonl")):
132+
fixtures[path.parent.parent.name] = [
133+
json.loads(line) for line in path.read_text().splitlines() if line.strip()
134+
]
135+
return fixtures
136+
137+
138+
def try_read_json(path: Path) -> Tuple[Any, Optional[Exception]]:
139+
"""(parsed, None), or (None, error) when the file is unreadable or not JSON."""
140+
try:
141+
return json.loads(path.read_text(encoding="utf-8")), None
142+
except (json.JSONDecodeError, OSError) as err:
143+
return None, err

‎scripts/check-audit-ignores.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import sys
2424
from pathlib import Path
2525

26-
ROOT = Path(__file__).resolve().parent.parent
26+
from _skill_io import ROOT
2727
REGISTRY = ROOT / "desktop" / "audit-ignore.json"
2828

2929
# 一条抑制记录至少要说明白这三件事,否则它只是一行静音。

‎scripts/check-eval-sdk-surface.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,10 @@ def _missing_for(name: str, spec: dict) -> list[str]:
105105
def _pinned_versions() -> dict[str, str]:
106106
"""What requirements-eval.txt pins, so the check can say whose surface it read."""
107107
import re
108-
from pathlib import Path
109108

110-
text = (Path(__file__).resolve().parent.parent / "requirements-eval.txt").read_text(
111-
encoding="utf-8"
112-
)
109+
from _skill_io import ROOT
110+
111+
text = (ROOT / "requirements-eval.txt").read_text(encoding="utf-8")
113112
return dict(re.findall(r"^([a-z-]+)==([\d.]+)$", text, re.M))
114113

115114

‎scripts/check-gate-liveness.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636

3737
import yaml
3838

39+
from _skill_io import ROOT
40+
3941
# A verdict — as opposed to a skip, an error, or a dry run.
4042
GRADED_STATUSES = {"PASS", "FAIL"}
4143

@@ -724,7 +726,7 @@ def run_all(root: Path, fidelity_report: Path | None = None) -> list[str]:
724726

725727
def main() -> int:
726728
parser = argparse.ArgumentParser(description=__doc__)
727-
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent.parent)
729+
parser.add_argument("--root", type=Path, default=ROOT)
728730
parser.add_argument("--json", action="store_true", help="machine-readable output")
729731
parser.add_argument(
730732
"--fidelity-report",

‎scripts/check-manifest-versions.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,11 @@
3434
import sys
3535
from pathlib import Path
3636

37-
ROOT = Path(__file__).resolve().parent.parent
37+
from _skill_io import ROOT, try_read_json
3838

3939

4040
def _read_json(p: Path) -> dict | None:
41-
try:
42-
return json.loads(p.read_text(encoding="utf-8"))
43-
except (json.JSONDecodeError, OSError):
44-
return None
41+
return try_read_json(p)[0]
4542

4643

4744
def collect_versions(root: Path = ROOT) -> dict[str, str]:

‎scripts/check-release-fidelity.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from _evaluation_identity import evaluation_identity
3636

3737

38-
ROOT = Path(__file__).resolve().parent.parent
38+
from _skill_io import ROOT
3939
RELEASE_MODEL = "claude-sonnet-4-6"
4040
MIN_PASS_RATES = {"fidelity": 0.90, "boundary": 0.80, "pressure": 0.70}
4141

‎scripts/cite.py‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,14 @@
88
import sys
99

1010
from _masterpaths import resolve_master_dir
11+
from _skill_io import parse_sections
1112

1213
# master feeds into os.path.join(...); restrict to a slug charset so a value
1314
# like "../../etc" can never read files outside prebuilt/. Mirrors the
1415
# isSafeName guard in bin/cli.mjs.
1516
_SAFE_MASTER = re.compile(r"^[A-Za-z0-9_-]+$")
1617

1718

18-
def parse_sections(text):
19-
"""按 ## 标题分段,返回 [(title, body), ...]"""
20-
sections = []
21-
parts = re.split(r'^## ', text, flags=re.MULTILINE)
22-
for part in parts[1:]:
23-
lines = part.split('\n', 1)
24-
title = lines[0].strip()
25-
body = lines[1] if len(lines) > 1 else ''
26-
sections.append((title, body))
27-
return sections
28-
29-
3019
def find_citations(master_dir, text):
3120
sources_dir = os.path.join(master_dir, "sources")
3221
if not os.path.isdir(sources_dir):

‎scripts/persona_prompt.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
sys.path.insert(0, str(Path(__file__).resolve().parent))
77
from _skill_context import load_skill_context
88

9-
ROOT = Path(__file__).resolve().parent.parent
9+
from _skill_io import ROOT
1010

1111

1212
def create_prompt(context: dict) -> list[dict[str, str]]:

0 commit comments

Comments
 (0)