@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +89 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +23 -7
  5. package/package.json +7 -3
  6. package/schemas/film.schema.json +235 -4
  7. package/skills/video-studio/SKILL.md +299 -27
  8. package/skills/video-studio/assets/film.json +7 -14
  9. package/skills/video-studio/assets/golden_frames.json +65 -0
  10. package/skills/video-studio/assets/scenes.py +115 -10
  11. package/skills/video-studio/assets/web/engine.js +81 -3
  12. package/skills/video-studio/assets/web/film.js +249 -101
  13. package/skills/video-studio/assets/web/index.html +9 -2
  14. package/skills/video-studio/assets/web/qa_text.js +36 -8
  15. package/skills/video-studio/references/audio-variation.md +159 -0
  16. package/skills/video-studio/references/audio.md +67 -5
  17. package/skills/video-studio/references/change-impact.md +2 -2
  18. package/skills/video-studio/references/quality-gates.md +25 -3
  19. package/skills/video-studio/references/style-packs.md +7 -3
  20. package/skills/video-studio/references/v08-quality.md +256 -0
  21. package/skills/video-studio/references/web-engine.md +19 -2
  22. package/skills/video-studio/references/word-timeline.md +147 -0
  23. package/skills/video-studio/requirements.txt +16 -1
  24. package/skills/video-studio/scripts/filmcheck.py +202 -12
  25. package/skills/video-studio/scripts/filmkit.py +792 -56
  26. package/skills/video-studio/scripts/gates.py +161 -14
  27. package/skills/video-studio/scripts/preflight.py +774 -0
  28. package/skills/video-studio/scripts/qa_frames.py +154 -23
  29. package/skills/video-studio/scripts/qaasr.py +1563 -0
  30. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  31. package/skills/video-studio/scripts/render.py +1134 -78
  32. package/skills/video-studio/scripts/scorekit.py +305 -76
  33. package/skills/video-studio/scripts/selfcheck.py +576 -2
  34. package/skills/video-studio/scripts/sfxkit.py +262 -44
  35. package/skills/video-studio/scripts/stagewatch.py +159 -36
  36. package/skills/video-studio/scripts/stylekit.py +137 -18
  37. package/skills/video-studio/scripts/voicekit.py +23 -7
  38. package/skills/video-studio/scripts/webengine.py +1186 -85
  39. package/tools/audio_similarity.py +279 -0
  40. package/tools/ci_check.py +310 -59
  41. package/tools/estimate.py +251 -61
  42. package/tools/golden_frames.py +664 -0
  43. 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 语法:所有 .py 能编译、index.js 能通过 node --check
10
- 2 JSON:所有 .json 能被解析(locale / marketplace-entry / assets / package.json)
11
- 3 技能清单:SKILL.md 的 frontmatter 有 name/description,name 与目录名一致
12
- 4 依赖白名单:scripts/*.py 与 assets/*.py 只允许 stdlib + numpy/PIL/cv2/scipy/edge_tts/playwright
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
- 5 打包卫生:npm pack 的内容里不能有 __pycache__ / *.pyc / 临时目录
15
- 6 文档引用:SKILL.md / references 里引用的 scripts/xxx.py、assets/xxx 必须真的存在
16
- 7 Python 3.9 语法兼容(THIRD_PARTY_NOTICES 写的是 3.9+;这里只验语法,运行期 API 仍要真机实测)
17
- 8 Markdown 围栏成对(不平衡会让整篇渲染成代码块)
18
- 9 GitHub Actions workflow 里 step 名的裸冒号(会让整份 workflow 静默不跑)
19
- 10 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
20
-
21
- 退出码:0 全过;1 有 FAIL。每一项都打印证据,不打"可能有问题"。
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[bool, str, str]] = []
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
- """跑一项检查;返回 (ok, name, detail)。任何异常都算 FAIL 并保留消息。"""
82
+ """跑一项检查;返回 PASS / FAIL / SKIP 三态,detail 保留证据或原因。"""
54
83
  try:
55
84
  detail = fn() or ""
56
- _results.append((True, name, str(detail)))
57
- return True
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((False, name, str(e)))
60
- return False
91
+ _results.append(("FAIL", name, str(e)))
92
+ return "FAIL"
61
93
  except Exception as e: # noqa: BLE001
62
- _results.append((False, name, "%s: %s" % (type(e).__name__, e)))
63
- return False
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
- out = sorted(SCRIPTS.glob("*.py")) + sorted(ASSETS.glob("*.py"))
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
- compile(src, str(p), "exec")
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 全部可编译" % len(_py_files())
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
- return "%d 个 .json 全部可解析" % len(files)
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
- must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json",
174
- "skills/video-studio/SKILL.md", "skills/video-studio/scripts/filmkit.py"]
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
- return "%d 个文件 / %.0f KB" % (len(files), meta.get("size", 0) / 1024.0)
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 篇文档的路径引用全部存在" % sum(1 for m in md_files if m.is_file())
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 ".git" not in p.parts and "node_modules" not in p.parts
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
- assert not bad, ";".join(bad)
223
- return "%d 个 .py 通过 3.9 语法解析(运行期兼容仍需真 3.9 实测)" % len(_py_files())
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
- n = sum(1 for line in p.read_text(encoding="utf-8").splitlines()
237
- if line.lstrip().startswith("\`\`\`"))
238
- if n % 2:
239
- bad.append("%s: %d 个围栏(奇数)" % (p.name, n))
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 围栏都成对" % len([p for p in files if p.is_file()])
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
- 「- name: selftest: xxx」里第二个冒号会让整份 YAML 解析失败,而 GitHub 上的表现是
249
- 「workflow 根本没跑」——没有任何报错提示,最容易被忽略(本项目真踩过)。
250
- 不引第三方 YAML 库(CI 里不一定装了 pyyaml),只查这一类高频错误。
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 not m:
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
- val = m.group(2).strip()
263
- if val[:1] in ("'", '"'):
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
- if ": " in val:
266
- bad.append("%s:%d 的 step 名含未加引号的冒号(YAML 会解析失败,整份 workflow 不跑)"
267
- % (f.name, i))
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 的 step 名没有裸冒号" % len(files)
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
- ok = check(name, fn)
326
- for ok, name, detail in _results:
327
- print(" %s %-24s %s" % ("PASS" if ok else "FAIL", name, detail if (ok or a.verbose) else ""))
328
- fails = [r for r in _results if not r[0]]
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" % (len(_results) - len(fails), len(fails)))
573
+ print("PASS %d / FAIL %d / SKIP %d" % (n_pass, len(fails), len(skips)))
331
574
  if fails:
332
575
  print("\n失败项:")
333
- for ok, name, detail in fails:
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