@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.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/CHANGELOG.md +951 -0
- package/README.md +89 -21
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +23 -7
- package/package.json +7 -3
- package/schemas/film.schema.json +235 -4
- package/skills/video-studio/SKILL.md +299 -27
- package/skills/video-studio/assets/film.json +7 -14
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +115 -10
- package/skills/video-studio/assets/web/engine.js +81 -3
- package/skills/video-studio/assets/web/film.js +249 -101
- package/skills/video-studio/assets/web/index.html +9 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +159 -0
- package/skills/video-studio/references/audio.md +67 -5
- package/skills/video-studio/references/change-impact.md +2 -2
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/style-packs.md +7 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +16 -1
- package/skills/video-studio/scripts/filmcheck.py +202 -12
- package/skills/video-studio/scripts/filmkit.py +792 -56
- package/skills/video-studio/scripts/gates.py +161 -14
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +154 -23
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +1134 -78
- package/skills/video-studio/scripts/scorekit.py +305 -76
- package/skills/video-studio/scripts/selfcheck.py +576 -2
- package/skills/video-studio/scripts/sfxkit.py +262 -44
- package/skills/video-studio/scripts/stagewatch.py +159 -36
- package/skills/video-studio/scripts/stylekit.py +137 -18
- package/skills/video-studio/scripts/voicekit.py +23 -7
- package/skills/video-studio/scripts/webengine.py +1186 -85
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +310 -59
- package/tools/estimate.py +251 -61
- package/tools/golden_frames.py +664 -0
- package/tools/selftest_noscipy.py +70 -0
package/tools/ci_check.py
CHANGED
|
@@ -6,19 +6,28 @@
|
|
|
6
6
|
它检查的是**这个仓库作为"可分发的技能插件"的完整性**,而不是视频能不能出片
|
|
7
7
|
(出片要靠 selfcheck.py + 真跑一遍 render.py,见 SKILL.md §14):
|
|
8
8
|
|
|
9
|
-
1
|
|
10
|
-
2
|
|
11
|
-
3
|
|
12
|
-
|
|
9
|
+
1 语法:scripts/ + assets/ + tools/ 下的 .py 全部能编译(SyntaxWarning 也算失败)
|
|
10
|
+
2 JS 语法:根目录与 assets/web/ 的 .js 通过 node --check
|
|
11
|
+
3 JSON:所有 .json 能被解析(locale / marketplace-entry / assets / package.json),
|
|
12
|
+
且 package.json 的 version 与 README 徽章一致
|
|
13
|
+
4 技能清单:SKILL.md 的 frontmatter 有 name/description,name 与目录名一致
|
|
14
|
+
5 依赖白名单:scripts/*.py 与 assets/*.py 只允许 stdlib + numpy/PIL/cv2/scipy/edge_tts/playwright
|
|
13
15
|
(技能是"整目录拷走就能跑"的,偷偷引入一个三方依赖会在用户机器上炸)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
8
|
|
18
|
-
9
|
|
19
|
-
10
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
6 打包卫生:npm pack 的内容里不能有 __pycache__ / *.pyc / 临时目录;必需文件必须在包里
|
|
17
|
+
7 文档路径:SKILL.md / references / README 里引用的 scripts/xxx.py、assets/xxx,
|
|
18
|
+
以及正文里的相对 Markdown 链接与图片,必须真的存在
|
|
19
|
+
8 Python 3.9 语法兼容(THIRD_PARTY_NOTICES 写的是 3.9+;这里只验语法,运行期 API 仍要真机实测)
|
|
20
|
+
9 Markdown 围栏成对(不平衡会让整篇渲染成代码块;按围栏状态机判,不是数出现次数)
|
|
21
|
+
10 CI workflow:step 名的裸冒号(会让整份 workflow 静默不跑),
|
|
22
|
+
以及 run 里 python <路径>.py 指向的文件必须存在
|
|
23
|
+
11 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
|
|
24
|
+
12 film.json 契约:schemas/film.schema.json + scripts/filmcheck.py 校验模板(规则只留一份)
|
|
25
|
+
|
|
26
|
+
三档结论(与技能自己的门禁账本同口径):PASS = 这次真的查了且通过;FAIL = 查了没过;
|
|
27
|
+
SKIP = **这次没执行**(比如本地没装 node / npm),**不等于通过**。
|
|
28
|
+
|
|
29
|
+
退出码:0 = 无 FAIL;1 = 有 FAIL。加 --strict 时 SKIP 也算失败(本地环境缺工具时用它自证"真的全查了")。
|
|
30
|
+
每一项都打印证据,不打"可能有问题"。
|
|
22
31
|
"""
|
|
23
32
|
from __future__ import annotations
|
|
24
33
|
|
|
@@ -35,50 +44,91 @@ ROOT = Path(__file__).resolve().parent.parent
|
|
|
35
44
|
SKILL = ROOT / "skills" / "video-studio"
|
|
36
45
|
SCRIPTS = SKILL / "scripts"
|
|
37
46
|
ASSETS = SKILL / "assets"
|
|
47
|
+
TOOLS = ROOT / "tools"
|
|
38
48
|
REFS = SKILL / "references"
|
|
39
49
|
|
|
40
50
|
# 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
|
|
41
|
-
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright"
|
|
51
|
+
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright",
|
|
52
|
+
# v0.8:ASR 回读门禁(可选,缺了 qaasr 自动降级到 ffmpeg whisper / skipped)
|
|
53
|
+
"faster_whisper"}
|
|
42
54
|
# 同目录同级模块(拷走整个目录后仍能互相 import)
|
|
43
55
|
LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "render", "selfcheck",
|
|
44
56
|
"stylekit", "voicekit", "scenes", "scenes_demo",
|
|
45
57
|
# v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
|
|
46
|
-
"gates", "stagewatch", "filmcheck"
|
|
58
|
+
"gates", "stagewatch", "filmcheck",
|
|
59
|
+
# v0.8.0:渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
|
|
60
|
+
"preflight", "qavmaf", "qaasr"}
|
|
47
61
|
MAX_FILE_BYTES = 400 * 1024
|
|
62
|
+
# 体积检查忽略的目录后缀:.team 是本地调研/自测工作区(gitignore),不随包发布;
|
|
63
|
+
# 媒体文件本来就该大(样例片/参考视频),不该拿"能不能塞进上下文"的尺子量。
|
|
64
|
+
SIZE_SKIP_DIRS = {".git", "node_modules", ".team", "work", "out", "__pycache__", ".venv", "venv"}
|
|
65
|
+
SIZE_SKIP_EXT = {".mp4", ".mp3", ".wav", ".mov", ".webm", ".gif", ".webp", ".zip", ".tgz"}
|
|
48
66
|
|
|
49
|
-
_results: list[tuple[
|
|
67
|
+
_results: list[tuple[str, str, str]] = []
|
|
68
|
+
|
|
69
|
+
# 「跳过」的前缀约定:检查函数返回这些开头的字符串表示**这次没有真的查**。
|
|
70
|
+
# 以前这里只有 bool 两态,"跳过(没装 node)"会被记成 PASS——正是本仓库反复声明的
|
|
71
|
+
# "skipped != 通过",却在仓库级体检自己的汇总里破了例(本地没装 node 的人会看到
|
|
72
|
+
# PASS 12/12,其实第 2、6 项根本没跑)。现在三档:PASS / FAIL / SKIP。
|
|
73
|
+
SKIP_PREFIXES = ("跳过", "skip")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_skip(detail: str) -> bool:
|
|
77
|
+
head = str(detail or "").strip().lower()
|
|
78
|
+
return any(head.startswith(p) for p in SKIP_PREFIXES)
|
|
50
79
|
|
|
51
80
|
|
|
52
81
|
def check(name: str, fn):
|
|
53
|
-
"""跑一项检查;返回
|
|
82
|
+
"""跑一项检查;返回 PASS / FAIL / SKIP 三态,detail 保留证据或原因。"""
|
|
54
83
|
try:
|
|
55
84
|
detail = fn() or ""
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
if _is_skip(detail):
|
|
86
|
+
_results.append(("SKIP", name, str(detail)))
|
|
87
|
+
return "SKIP"
|
|
88
|
+
_results.append(("PASS", name, str(detail)))
|
|
89
|
+
return "PASS"
|
|
58
90
|
except AssertionError as e:
|
|
59
|
-
_results.append((
|
|
60
|
-
return
|
|
91
|
+
_results.append(("FAIL", name, str(e)))
|
|
92
|
+
return "FAIL"
|
|
61
93
|
except Exception as e: # noqa: BLE001
|
|
62
|
-
_results.append((
|
|
63
|
-
return
|
|
94
|
+
_results.append(("FAIL", name, "%s: %s" % (type(e).__name__, e)))
|
|
95
|
+
return "FAIL"
|
|
64
96
|
|
|
65
97
|
|
|
66
98
|
# ---------------------------------------------------------------- 1 语法
|
|
67
99
|
def _py_files() -> list[Path]:
|
|
68
|
-
|
|
100
|
+
"""技能脚本 + 技能资产里的 .py + 仓库工具脚本。
|
|
101
|
+
|
|
102
|
+
tools/ 以前不在扫描范围里,于是 ci_check.py 自己的 invalid escape sequence
|
|
103
|
+
(Python 3.12 的 SyntaxWarning)一路带到发布,没人发现——"语法检查"只查了半边。
|
|
104
|
+
"""
|
|
105
|
+
out = sorted(SCRIPTS.glob("*.py")) + sorted(ASSETS.glob("*.py")) + sorted(TOOLS.glob("*.py"))
|
|
69
106
|
return [p for p in out if p.name != "__init__.py"]
|
|
70
107
|
|
|
71
108
|
|
|
72
109
|
def t_syntax_py():
|
|
110
|
+
"""编译所有 .py;SyntaxWarning 也算失败。
|
|
111
|
+
|
|
112
|
+
为什么要警告转错误:invalid escape sequence(反斜杠 + 普通字母,比如 Python 里的"\\d")在 3.12 起是 SyntaxWarning,
|
|
113
|
+
只打印不失败——脚本照跑,但 warning 会出现在每个用户的第一次导入里,而且掩盖真问题。
|
|
114
|
+
"""
|
|
115
|
+
import warnings
|
|
116
|
+
|
|
73
117
|
bad = []
|
|
74
118
|
for p in _py_files():
|
|
75
119
|
src = p.read_text(encoding="utf-8")
|
|
76
120
|
try:
|
|
77
|
-
|
|
121
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
122
|
+
warnings.simplefilter("always", SyntaxWarning)
|
|
123
|
+
compile(src, str(p), "exec")
|
|
124
|
+
warns = [w for w in caught if issubclass(w.category, SyntaxWarning)]
|
|
125
|
+
if warns:
|
|
126
|
+
bad.append("%s:%s SyntaxWarning: %s"
|
|
127
|
+
% (p.name, warns[0].lineno, warns[0].message))
|
|
78
128
|
except SyntaxError as e:
|
|
79
129
|
bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
|
|
80
130
|
assert not bad, ";".join(bad)
|
|
81
|
-
return "%d 个 .py
|
|
131
|
+
return "%d 个 .py 全部可编译(含 tools/,SyntaxWarning 记失败)" % len(_py_files())
|
|
82
132
|
|
|
83
133
|
|
|
84
134
|
def t_syntax_js():
|
|
@@ -107,7 +157,26 @@ def t_json():
|
|
|
107
157
|
json.loads(p.read_text(encoding="utf-8"))
|
|
108
158
|
except Exception as e: # noqa: BLE001
|
|
109
159
|
raise AssertionError("%s: %s" % (p.relative_to(ROOT), e))
|
|
110
|
-
|
|
160
|
+
# 版本号三处必须一致:package.json / README 徽章 / marketplace-entry 描述里提到的 vX.Y。
|
|
161
|
+
# 这条以前没人查,于是 README 的徽章停在 0.6.0、包已经是 0.7.1 也没人发现(真发生过)。
|
|
162
|
+
ver = json.loads((ROOT / "package.json").read_text(encoding="utf-8")).get("version", "")
|
|
163
|
+
assert re.fullmatch(r"\d+\.\d+\.\d+", ver), "package.json version 不像 semver:%r" % ver
|
|
164
|
+
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
165
|
+
badges = re.findall(r"badge/version-(\d+\.\d+\.\d+)-", readme)
|
|
166
|
+
assert badges, "README 里找不到 version 徽章(badge/version-x.y.z)"
|
|
167
|
+
bad_badges = [b for b in badges if b != ver]
|
|
168
|
+
assert not bad_badges, ("README 徽章版本 %s 与 package.json version %s 不一致"
|
|
169
|
+
% ("/".join(bad_badges), ver))
|
|
170
|
+
# CHANGELOG 的第一条标题也必须是当前版本:以前只查 README 徽章,于是"发了新版本但
|
|
171
|
+
# CHANGELOG 顶部还是上一版"没人发现(历史真发生过,见 v0.7.2 的修复记录)。
|
|
172
|
+
changelog = ROOT / "CHANGELOG.md"
|
|
173
|
+
assert changelog.is_file(), "缺 CHANGELOG.md"
|
|
174
|
+
m = re.search(r"^##\s+(\d+\.\d+\.\d+)", changelog.read_text(encoding="utf-8"), re.M)
|
|
175
|
+
assert m, "CHANGELOG.md 里找不到 \"## X.Y.Z\" 形式的版本标题"
|
|
176
|
+
assert m.group(1) == ver, ("CHANGELOG 顶部版本 %s 与 package.json version %s 不一致"
|
|
177
|
+
"(发版时两处必须同时改)" % (m.group(1), ver))
|
|
178
|
+
return ("%d 个 .json 全部可解析;version=%s 与 README 徽章 / CHANGELOG 顶部一致"
|
|
179
|
+
% (len(files), ver))
|
|
111
180
|
|
|
112
181
|
|
|
113
182
|
# ---------------------------------------------------------------- 3 技能清单
|
|
@@ -163,18 +232,55 @@ def t_pack():
|
|
|
163
232
|
files = [f["path"] for f in meta.get("files", [])]
|
|
164
233
|
# 垃圾文件可能出现在**包内任意位置**(实测踩过:队友的临时目录建在 skills/ 下,
|
|
165
234
|
# 于是 skills/tmp_scratch_x/webtest/*.mp4 被一起打进包,体量翻倍)。
|
|
166
|
-
junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz"
|
|
235
|
+
junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz",
|
|
236
|
+
# 二进制/可执行文件:技能包里不该有,出现就是某个进程把缓存写进了源码树。
|
|
237
|
+
# 实测踩过:输入法按 %SystemDrive%\ProgramData\...\*.bin 往 cwd 落缓存,
|
|
238
|
+
# 当时 cwd 正好是 skills/video-studio/scripts/,两个 172 KB 的 .bin 进了包,
|
|
239
|
+
# 而旧的 junk 规则只看 .pyc/.log/.mp4 这些后缀,检查照样 PASS。
|
|
240
|
+
".bin", ".dll", ".exe", ".so", ".dylib", ".pyd", ".db", ".sqlite")
|
|
167
241
|
junk_parts = ("__pycache__", "tmp_scratch_", "node_modules", ".pytest_cache", ".cordis")
|
|
168
242
|
junk = [f for f in files
|
|
169
243
|
if f.endswith(junk_suffix) or any(p in f for p in junk_parts)
|
|
170
|
-
or f.startswith(("work/", "out/", ".team/"))
|
|
244
|
+
or f.startswith(("work/", "out/", ".team/"))
|
|
245
|
+
# 路径里带 % 段 = 某个环境变量没被展开(Windows 上的典型坏路径)。
|
|
246
|
+
# 发布包里不可能有合法的含 % 路径段,直接判垃圾,而不是去猜是哪个变量。
|
|
247
|
+
or any("%" in part for part in f.split("/"))]
|
|
171
248
|
assert not junk, ("包内不该有这些文件(临时产物/日志/媒体,属于打包污染):%s"
|
|
172
249
|
% ", ".join(junk[:6]))
|
|
173
|
-
|
|
174
|
-
|
|
250
|
+
# 根文件清单仍然手写:它们少一个插件就装不起来,而且数量少、变动少。
|
|
251
|
+
must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json", "README.md"]
|
|
175
252
|
missing = [m for m in must if m not in files]
|
|
176
253
|
assert not missing, "包内缺文件:%s" % ", ".join(missing)
|
|
177
|
-
|
|
254
|
+
# 目录部分改成**派生式**:skills/ schemas/ locale/ tools/ docs/ 下磁盘上有的(除缓存/字节码)
|
|
255
|
+
# 必须全部进包。以前这里是手写 must 列表,于是 v0.6 新增的 gates.py / stagewatch.py、
|
|
256
|
+
# v0.8 新增的 preflight.py / qavmaf.py / qaasr.py / tools/golden_frames.py 都不在列表里——
|
|
257
|
+
# "必需文件必须在包里"这条检查对**新增文件天然免疫**,等于只保护了第一版写下的那些路径。
|
|
258
|
+
shipped_dirs = ("skills", "schemas", "locale", "tools", "docs")
|
|
259
|
+
on_disk = []
|
|
260
|
+
for d in shipped_dirs:
|
|
261
|
+
base = ROOT / d
|
|
262
|
+
if not base.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
for p in sorted(base.rglob("*")):
|
|
265
|
+
if not p.is_file():
|
|
266
|
+
continue
|
|
267
|
+
if "__pycache__" in p.parts or p.suffix == ".pyc":
|
|
268
|
+
continue
|
|
269
|
+
rel = p.relative_to(ROOT).as_posix()
|
|
270
|
+
# 命中 junk 的文件不参与"必须进包"的比对:它们本来就不该进包,
|
|
271
|
+
# 而"磁盘上有就必须进包"会把垃圾反向当成必需文件(实测:给 package.json 加
|
|
272
|
+
# "!skills/**/*.bin" 排除规则时,这条派生检查反过来 FAIL 了)。
|
|
273
|
+
if rel.endswith(junk_suffix) or any(k in rel for k in junk_parts) \
|
|
274
|
+
or any("%" in part for part in rel.split("/")):
|
|
275
|
+
continue
|
|
276
|
+
on_disk.append(rel)
|
|
277
|
+
packed_set = {f.replace("\\", "/") for f in files}
|
|
278
|
+
not_packed = [p for p in on_disk if p not in packed_set]
|
|
279
|
+
assert not not_packed, (
|
|
280
|
+
"这些文件在仓库里但没进包(package.json 的 files 漏了?):%s"
|
|
281
|
+
% ", ".join(not_packed[:8]))
|
|
282
|
+
return "%d 个文件 / %.0f KB;%d 个受管目录文件全部进包" % (
|
|
283
|
+
len(files), meta.get("size", 0) / 1024.0, len(on_disk))
|
|
178
284
|
|
|
179
285
|
|
|
180
286
|
# ---------------------------------------------------------------- 6 文档引用
|
|
@@ -191,16 +297,63 @@ def t_doc_refs():
|
|
|
191
297
|
cands = [SKILL / rel, SKILL / "references" / rel, ROOT / rel]
|
|
192
298
|
if not any(c.exists() for c in cands):
|
|
193
299
|
missing.append("%s 里引用 %s(不存在)" % (md.name, rel))
|
|
300
|
+
# 正文里的相对 Markdown 链接 / 图片是另一类:上面的正则只覆盖 scripts|assets|references 前缀,
|
|
301
|
+
# [说明](references/foo.md) 之外的写法(比如 [x](../schemas/film.schema.json))会漏掉。
|
|
302
|
+
link_bad = []
|
|
303
|
+
for md in md_files:
|
|
304
|
+
if not md.is_file():
|
|
305
|
+
continue
|
|
306
|
+
txt = md.read_text(encoding="utf-8")
|
|
307
|
+
for m in re.finditer(r"!?\[([^\]]*)\]\(([^)\s]+)\)", txt):
|
|
308
|
+
target = m.group(2)
|
|
309
|
+
if re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|#)", target):
|
|
310
|
+
continue # 外链 / 纯锚点
|
|
311
|
+
rel = target.split("#", 1)[0]
|
|
312
|
+
if not rel:
|
|
313
|
+
continue
|
|
314
|
+
cands = [md.parent / rel, ROOT / rel, SKILL / rel]
|
|
315
|
+
if not any(c.exists() for c in cands):
|
|
316
|
+
link_bad.append("%s 里链接 %s(不存在)" % (md.name, target))
|
|
317
|
+
# README 的效果图是 HTML <img src="docs/...">,不是 Markdown 语法,单独查一遍。
|
|
318
|
+
for m in re.finditer(r"<img[^>]*\ssrc=[\"']([^\"']+)[\"']", txt):
|
|
319
|
+
target = m.group(1)
|
|
320
|
+
if re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//)", target):
|
|
321
|
+
continue
|
|
322
|
+
rel = target.split("?", 1)[0].split("#", 1)[0]
|
|
323
|
+
cands = [md.parent / rel, ROOT / rel]
|
|
324
|
+
if rel and not any(c.exists() for c in cands):
|
|
325
|
+
link_bad.append("%s 里 <img src> 指向 %s(不存在)" % (md.name, target))
|
|
326
|
+
# 第三种:带占位符的路径(<SKILL_DIR>/... 与 <REPO>/...)。ci_check 之前只认 scripts/|assets/|references/
|
|
327
|
+
# 前缀,SKILL.md 里 <SKILL_DIR>/../../../tools/estimate.py 这种写错两级相对层级的命令一直没人查——
|
|
328
|
+
# 实测它指向仓库上一级,是条**跑不通的命令**。语义:<SKILL_DIR> = skills/video-studio/,
|
|
329
|
+
# <REPO> = 包根(仓库根)。注意**不能**把它们当成"当前工作目录":quickstart 把技能拷进 myfilm/ 后,
|
|
330
|
+
# 那里的 skills 与 tools 都不存在,但文档里的占位符指的是包内位置,仍然应当成立。
|
|
331
|
+
ph_bad = []
|
|
332
|
+
ph_re = re.compile(r"(<SKILL_DIR>|<REPO>)/([A-Za-z0-9_.\-/{}*]+)")
|
|
333
|
+
for md in md_files:
|
|
334
|
+
if not md.is_file():
|
|
335
|
+
continue
|
|
336
|
+
for ln_no, line in enumerate(md.read_text(encoding="utf-8").splitlines(), 1):
|
|
337
|
+
for m in ph_re.finditer(line):
|
|
338
|
+
base = SKILL if m.group(1) == "<SKILL_DIR>" else ROOT
|
|
339
|
+
rel = m.group(2).rstrip(".,;:")
|
|
340
|
+
if any(ch in rel for ch in "{}*"):
|
|
341
|
+
rel = rel.split("{")[0].split("*")[0].rsplit("/", 1)[0] if ("{" in rel or "*" in rel) else rel
|
|
342
|
+
if not (base / rel).exists():
|
|
343
|
+
ph_bad.append("%s:%d 的 %s/%s 不存在" % (md.name, ln_no, m.group(1), rel))
|
|
344
|
+
assert not ph_bad, ";".join(sorted(set(ph_bad))[:8])
|
|
345
|
+
assert not link_bad, ";".join(sorted(set(link_bad))[:8])
|
|
194
346
|
assert not missing, ";".join(sorted(set(missing))[:8])
|
|
195
|
-
return "%d
|
|
347
|
+
return "%d 篇文档的路径引用 + 正文相对链接全部存在" % sum(1 for m in md_files if m.is_file())
|
|
196
348
|
|
|
197
349
|
|
|
198
350
|
# ---------------------------------------------------------------- 7 体积
|
|
199
351
|
def t_size():
|
|
200
352
|
big = []
|
|
201
353
|
for p in [p for p in ROOT.rglob("*") if p.is_file()
|
|
202
|
-
and
|
|
354
|
+
and not (set(p.parts) & SIZE_SKIP_DIRS)
|
|
203
355
|
and not str(p.relative_to(ROOT)).startswith("tmp_scratch_")
|
|
356
|
+
and p.suffix.lower() not in SIZE_SKIP_EXT
|
|
204
357
|
and p.suffix != ".png"]:
|
|
205
358
|
n = p.stat().st_size
|
|
206
359
|
if n > MAX_FILE_BYTES:
|
|
@@ -211,43 +364,118 @@ def t_size():
|
|
|
211
364
|
|
|
212
365
|
|
|
213
366
|
# ---------------------------------------------------------------- 8 兼容 Python 3.9(README/THIRD_PARTY 的承诺)
|
|
367
|
+
# 3.10+ 才有、但**语法上**能通过 3.9 解析的 API —— ast.parse(feature_version=(3,9)) 看不见它们。
|
|
368
|
+
# 这正是"门禁看着干净、其实没在查"的典型形态:zip(..., strict=True) 在 3.9 上是 TypeError
|
|
369
|
+
# (zip() takes no keyword arguments),而语法检查一路绿灯。
|
|
370
|
+
# 只查**确定**不兼容且容易误用的这几个;不要按 BinOp|BitOr 一律报错(numpy 位或会刷假失败)。
|
|
371
|
+
def _py39_runtime_violations(tree):
|
|
372
|
+
"""扫 3.10+ 运行期 API。返回 [(行号, 说明)]。"""
|
|
373
|
+
out = []
|
|
374
|
+
has_future_annotations = False
|
|
375
|
+
for node in tree.body:
|
|
376
|
+
if isinstance(node, ast.ImportFrom) and node.module == "__future__":
|
|
377
|
+
if any(a.name == "annotations" for a in node.names):
|
|
378
|
+
has_future_annotations = True
|
|
379
|
+
ann_nodes = set()
|
|
380
|
+
for node in ast.walk(tree):
|
|
381
|
+
# 收集"注解位置"的节点:只有这里的 X | Y 才是类型联合,函数体里的是 numpy 位或
|
|
382
|
+
for attr in ("annotation", "returns"):
|
|
383
|
+
sub = getattr(node, attr, None)
|
|
384
|
+
if sub is not None:
|
|
385
|
+
for x in ast.walk(sub):
|
|
386
|
+
ann_nodes.add(id(x))
|
|
387
|
+
for node in ast.walk(tree):
|
|
388
|
+
if isinstance(node, ast.Call):
|
|
389
|
+
fn = node.func
|
|
390
|
+
name = getattr(fn, "attr", None) or getattr(fn, "id", None)
|
|
391
|
+
if name == "zip":
|
|
392
|
+
for kw in node.keywords:
|
|
393
|
+
if kw.arg == "strict":
|
|
394
|
+
out.append((node.lineno, "zip(..., strict=...) 需要 Python 3.10+"))
|
|
395
|
+
if name == "bit_count":
|
|
396
|
+
out.append((node.lineno, "int.bit_count() 需要 Python 3.10+"))
|
|
397
|
+
if name == "dataclass":
|
|
398
|
+
for kw in node.keywords:
|
|
399
|
+
if kw.arg in ("slots", "kw_only"):
|
|
400
|
+
out.append((node.lineno,
|
|
401
|
+
"dataclass(slots=/kw_only=) 需要 Python 3.10+"))
|
|
402
|
+
elif isinstance(node, ast.Import):
|
|
403
|
+
for a in node.names:
|
|
404
|
+
if a.name.split(".")[0] == "tomllib":
|
|
405
|
+
out.append((node.lineno, "tomllib 需要 Python 3.11+"))
|
|
406
|
+
elif isinstance(node, ast.ImportFrom):
|
|
407
|
+
if (node.module or "").split(".")[0] == "tomllib":
|
|
408
|
+
out.append((node.lineno, "tomllib 需要 Python 3.11+"))
|
|
409
|
+
elif isinstance(node, ast.TryStar):
|
|
410
|
+
out.append((node.lineno, "except* 需要 Python 3.11+"))
|
|
411
|
+
elif isinstance(node, ast.Match):
|
|
412
|
+
out.append((node.lineno, "match/case 需要 Python 3.10+"))
|
|
413
|
+
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
414
|
+
if not has_future_annotations and id(node) in ann_nodes:
|
|
415
|
+
out.append((node.lineno,
|
|
416
|
+
"X | Y 类型注解需要 3.10+(用 Union[X, Y] 或加 "
|
|
417
|
+
"from __future__ import annotations)"))
|
|
418
|
+
return out
|
|
419
|
+
|
|
420
|
+
|
|
214
421
|
def t_py39():
|
|
215
422
|
bad = []
|
|
216
423
|
for p in _py_files():
|
|
217
424
|
src = p.read_text(encoding="utf-8")
|
|
218
425
|
try:
|
|
219
|
-
ast.parse(src, filename=str(p), feature_version=(3, 9))
|
|
426
|
+
tree = ast.parse(src, filename=str(p), feature_version=(3, 9))
|
|
220
427
|
except SyntaxError as e:
|
|
221
428
|
bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
|
|
222
|
-
|
|
223
|
-
|
|
429
|
+
continue
|
|
430
|
+
for lineno, why in _py39_runtime_violations(tree):
|
|
431
|
+
bad.append("%s:%s %s" % (p.name, lineno, why))
|
|
432
|
+
assert not bad, ";".join(bad[:6])
|
|
433
|
+
return ("%d 个 .py 通过 3.9 语法解析 + 3.10+ 运行期 API 扫描"
|
|
434
|
+
"(真 3.9 运行仍需实测)" % len(_py_files()))
|
|
224
435
|
|
|
225
436
|
|
|
226
437
|
|
|
227
438
|
# ---------------------------------------------------------------- 9 Markdown 围栏平衡
|
|
228
439
|
def t_md_fences():
|
|
229
|
-
"""每篇 .md
|
|
440
|
+
"""每篇 .md 的代码围栏必须成对(按围栏状态机判,不是数出现次数)。
|
|
441
|
+
|
|
442
|
+
v0.7.1 之前这里写的是 startswith("\\`\\`\\`")——那是一个无效转义,Python 把反斜杠原样保留,
|
|
443
|
+
于是永远匹配不到真实的 ``` 行,检查恒过("看着在查,其实查不出",正是本仓库最怕的形态)。
|
|
444
|
+
现在改成状态机:开围栏记下字符与长度,闭合围栏必须同字符、长度不小于开口、且不带信息串。
|
|
445
|
+
"""
|
|
230
446
|
bad = []
|
|
231
447
|
files = [ROOT / "README.md", ROOT / "CHANGELOG.md", ROOT / "THIRD_PARTY_NOTICES.md",
|
|
232
448
|
SKILL / "SKILL.md"] + sorted(REFS.glob("*.md"))
|
|
449
|
+
fence_re = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
233
450
|
for p in files:
|
|
234
451
|
if not p.is_file():
|
|
235
452
|
continue
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
453
|
+
opened = None
|
|
454
|
+
close_line = None
|
|
455
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
456
|
+
m = fence_re.match(line)
|
|
457
|
+
if not m:
|
|
458
|
+
continue
|
|
459
|
+
fence, info = m.group(1), m.group(2).strip()
|
|
460
|
+
if opened is None:
|
|
461
|
+
opened, close_line = (fence[0], len(fence)), i
|
|
462
|
+
elif fence[0] == opened[0] and len(fence) >= opened[1] and not info:
|
|
463
|
+
opened = None
|
|
464
|
+
if opened is not None:
|
|
465
|
+
bad.append("%s: 第 %d 行开的 %s 围栏没有闭合" % (p.name, close_line, opened[0] * 3))
|
|
240
466
|
assert not bad, ";".join(bad)
|
|
241
|
-
return "%d 篇 Markdown
|
|
467
|
+
return "%d 篇 Markdown 围栏都成对(状态机判定)" % len([p for p in files if p.is_file()])
|
|
242
468
|
|
|
243
469
|
|
|
244
470
|
# ---------------------------------------------------------------- 10 CI workflow 基本语法
|
|
245
471
|
def t_ci_workflow():
|
|
246
|
-
"""GitHub Actions workflow
|
|
472
|
+
"""GitHub Actions workflow 的两个高频错误。
|
|
247
473
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
474
|
+
1) 「裸冒号」:「- name: selftest: xxx」里第二个冒号会让整份 YAML 解析失败,而 GitHub 上的表现是
|
|
475
|
+
「workflow 根本没跑」——没有任何报错提示,最容易被忽略(本项目真踩过)。
|
|
476
|
+
2) 「run 里指向不存在的脚本」:`run: python tools/xxx.py` 写错路径时,本地 ci_check 看不出来,
|
|
477
|
+
要等 CI 跑红——而那一步本来就该秒级失败,所以在这里先查一遍文件是否存在。
|
|
478
|
+
不引第三方 YAML 库(CI 里不一定装了 pyyaml),只查这两类高频错误。
|
|
251
479
|
"""
|
|
252
480
|
wf = ROOT / ".github" / "workflows"
|
|
253
481
|
if not wf.is_dir():
|
|
@@ -257,16 +485,26 @@ def t_ci_workflow():
|
|
|
257
485
|
for f in files:
|
|
258
486
|
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
|
|
259
487
|
m = re.match(r"^(\s*)-\s+name:\s*(.+)$", line)
|
|
260
|
-
if
|
|
488
|
+
if m:
|
|
489
|
+
val = m.group(2).strip()
|
|
490
|
+
if val[:1] not in ("'", '"') and ": " in val:
|
|
491
|
+
bad.append("%s:%d 的 step 名含未加引号的冒号(YAML 会解析失败,整份 workflow 不跑)"
|
|
492
|
+
% (f.name, i))
|
|
261
493
|
continue
|
|
262
|
-
|
|
263
|
-
|
|
494
|
+
# run 块里的 python <相对路径>.py:工作目录可能是仓库根,也可能是技能 scripts 目录。
|
|
495
|
+
# 只看「行首是 python」的行(含 `run: |` 块里的缩进行),不做 YAML 解析,避免把注释/名称
|
|
496
|
+
# 里的 python 字样当命令(误报比漏报更烦人)。
|
|
497
|
+
if not re.match(r"^\s*(?:-\s+)?(?:run:\s*)?python[0-9.]*\s+", line):
|
|
264
498
|
continue
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
499
|
+
for pm in re.finditer(r"python[0-9.]*\s+(?!-)([A-Za-z0-9_./-]+\.py)", line):
|
|
500
|
+
rel = pm.group(1)
|
|
501
|
+
if rel.startswith("/") or ":" in rel:
|
|
502
|
+
continue # 绝对路径 / URL,不归本检查管
|
|
503
|
+
cands = [ROOT / rel, SCRIPTS / rel, SKILL / rel]
|
|
504
|
+
if not any(c.exists() for c in cands):
|
|
505
|
+
bad.append("%s:%d 里 python 调用的 %s 不存在" % (f.name, i, rel))
|
|
268
506
|
assert not bad, ";".join(bad[:4])
|
|
269
|
-
return "%d 个 workflow
|
|
507
|
+
return "%d 个 workflow:step 名无裸冒号、run 里的 python 脚本都存在" % len(files)
|
|
270
508
|
|
|
271
509
|
# ---------------------------------------------------------------- 12 film.json 契约
|
|
272
510
|
def t_film_contract():
|
|
@@ -299,6 +537,8 @@ def t_film_contract():
|
|
|
299
537
|
def main(argv=None) -> int:
|
|
300
538
|
ap = argparse.ArgumentParser(description="仓库级体检(不需要 ffmpeg / 字体 / 网络)")
|
|
301
539
|
ap.add_argument("-v", "--verbose", action="store_true", help="打印每一节的详细证据")
|
|
540
|
+
ap.add_argument("--strict", action="store_true",
|
|
541
|
+
help="SKIP 也算失败(本地缺 node/npm 时想确认\"真的全查了\"就用它)")
|
|
302
542
|
a = ap.parse_args(argv)
|
|
303
543
|
try:
|
|
304
544
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
@@ -322,16 +562,27 @@ def main(argv=None) -> int:
|
|
|
322
562
|
print("ci_check %s" % ROOT)
|
|
323
563
|
print("-" * 66)
|
|
324
564
|
for name, fn in checks:
|
|
325
|
-
|
|
326
|
-
for
|
|
327
|
-
|
|
328
|
-
|
|
565
|
+
check(name, fn)
|
|
566
|
+
for status, name, detail in _results:
|
|
567
|
+
# PASS 的 detail 只在 -v 下打印(它通常只是"N 个文件全部可编译");FAIL/SKIP 一律打印原因
|
|
568
|
+
print(" %s %-24s %s" % (status, name, detail if (status != "PASS" or a.verbose) else ""))
|
|
569
|
+
fails = [r for r in _results if r[0] == "FAIL"]
|
|
570
|
+
skips = [r for r in _results if r[0] == "SKIP"]
|
|
571
|
+
n_pass = len(_results) - len(fails) - len(skips)
|
|
329
572
|
print("-" * 66)
|
|
330
|
-
print("PASS %d / FAIL %d" % (
|
|
573
|
+
print("PASS %d / FAIL %d / SKIP %d" % (n_pass, len(fails), len(skips)))
|
|
331
574
|
if fails:
|
|
332
575
|
print("\n失败项:")
|
|
333
|
-
for
|
|
576
|
+
for _status, name, detail in fails:
|
|
577
|
+
print(" - %s:%s" % (name, detail))
|
|
578
|
+
if skips:
|
|
579
|
+
print("\n跳过项(**不等于通过**):")
|
|
580
|
+
for _status, name, detail in skips:
|
|
334
581
|
print(" - %s:%s" % (name, detail))
|
|
582
|
+
# 退出码口径:默认只把 FAIL 当失败(CI 里 node/npm 一定在,SKIP 不该出现);
|
|
583
|
+
# --strict 把 SKIP 也算失败——本地没装 node 的人想确认"真的全查了"就用它。
|
|
584
|
+
if a.strict and skips:
|
|
585
|
+
return 1
|
|
335
586
|
return 1 if fails else 0
|
|
336
587
|
|
|
337
588
|
|