product-planning ③段:闸门脚本下沉进技能包(框架一份、常量现读)

起因:③段只定义 Gate(自检表),不供给执行件 ⇒ 每个目标自建一份
(实测本区 执行会话/*/ui/_tools/ 下 check_skeleton.py 6 份 / 3 个版本,其中一版
 还停在"八页骨架"常量上 ⇒ ②段收成七页后要么 SystemExit、要么静默比错)。

新增 references/stage-delivery/scripts/(跨项目共用一份):
- _common.py        定位(按 docs/pm/*/prd|ui 形态找,⛔ 不写死项目名/文件名)+
                    自检文本解析规则(唯一一份)+ node/harness 自动发现
- check_skeleton.py ②段骨架 ↔ 原型 FROZEN 表逐页逐位比对;节号/页集合/页数现读
- check_js.py       <script> 段 node --check + 模板内 HTML 注释不得含反引号;
                    查不到覆盖面时给 SKIP 并写明(⛔ 不给假 PASS)
- grab_selftest.py  取自检文本(容忍 IPC 5s 超时;复用同一标签)
- run_viewports.py  逐视口抽密度/横向溢出/外壳高度链;--min-rows 默认 6
- mutate_test.py    变异对照框架(规则文件 mutate-rules.json 留在项目侧)
- selftest_gates.py 闸门自身的变异对照(临时副本上造病,要求报红+一档不该报红)

文档:runbook 新增 §5.6(用法只写这一处);SKILL.md 只留指针。

验证(contentm_agent 实测):骨架 PASS 7 页 39 板块;语法 PASS(1720 行);
闸门自身对照 5/5(改名报红并点名 / 注释带反引号报红 / 原型少一块报数量不等 /
节号加后缀仍 PASS ⇒ 既非恒绿也非恒红);命名闸门 2061|0|27。
浏览器类三条(grab/run_viewports/mutate)因 9223 没在跑,只验到参数链与
"没跑就报没跑"(rc=2),未实跑。
This commit is contained in:
admin committed 2026-10-13 17:47:59 +08:00
1 parent 510ba9192c
commit 799ceb749b
9 files changed
+1034

No files matched your search

@@ -296,6 +296,10 @@ Gate-1 方向门(硬闸,不可跳)——按本表逐项自检(字段定
真实渲染后(浏览器打开,非只看代码)肉眼过八项:裁切 / 重叠 / 失真 / 失效控件 / 缺状态 / 响应式破损 / 控制台报错 / 字体回退异常。渲染截图已代劳其中的溢出与响应式破损,其余仍需人眼(截图查不出「点了没反应」)。
> 🔴 **判据的执行件=本段自带脚本**(`references/stage-delivery/scripts/`):骨架逐位比对、JS 语法、
> 逐视口读数、变异对照四条,跨项目共用同一份。**用法只写一处** ⇒ `references/execution-runbook.md` §5.6。
> 脚本按目录形态定位 ②段与原型、节号与页数现读 ⇒ ②段改结构时不需要改脚本。
### 审计报告落盘
`designs/<项目>/3d-审查报告.md`,固定七节:范围 + 挡位 + 阻断项表 + 建议项表 + 状态覆盖核对表 + 结构核对表 + Gate 结论(含「本轮是否跑了脚本」一句)。
@@ -377,6 +377,44 @@
---
### 5.6 闸门脚本(Gate-1/2/3 的执行件,本段自带)
判据由**本段自带的脚本**执行,跨项目共用同一份(`references/stage-delivery/scripts/`)。
各项目自己养一份时,同一条判据会长出多个版本(实测:某项目的骨架闸门已有 3 个版本,
其中一版还停在旧页数常量上 ⇒ 跑起来要么当场报错、要么按错的页数**静默比错**)。
五条脚本(都在 `scripts/` 下):
1. `check_skeleton.py` —— ②段《界面布局》的页面骨架 ↔ 原型 `FROZEN` 表,逐页逐位比对。
跑法:`python scripts/check_skeleton.py --ws <工作区>`
2. `check_js.py` —— 原型 `<script>` 段的语法(`node --check`)+ 模板里的 HTML 注释不得含反引号。
跑法:`python scripts/check_js.py --ws <工作区>`
3. `grab_selftest.py` —— 取原型 `#/selftest` 的自检文本(容忍 IPC 5 s 超时;复用同一个标签)。
跑法:`python scripts/grab_selftest.py --proto <原型> --viewport 1440x900 --out <落盘>`
4. `run_viewports.py` —— 逐视口跑自检,抽密度/横向溢出/外壳高度链的读数表。
跑法:`python scripts/run_viewports.py --ws <工作区> [--min-rows 6] [--list]`
5. `mutate_test.py` —— 变异对照(框架):按规则文件注入假病,要求自检报红。
跑法:`python scripts/mutate_test.py --ws <工作区>`
规则文件放在**原型同目录**的 `mutate-rules.json`(框架跨项目共用,注入点跟着原型走)。
6. `selftest_gates.py` —— **闸门自身的变异对照**:在临时副本上给 ②段与原型各造一处假病,
要求前两条闸门报红,并加一档「不该报红」的对照(节号加后缀仍 PASS)。
跑法:`python scripts/selftest_gates.py --ws <工作区>`
用途:改完闸门或②段结构之后跑一次,证明判据既不是恒绿、也不是恒红。
三条共同约定:
1. 定位一律**按目录形态**(`docs/pm/*/prd/2b-*.md`、`docs/pm/*/ui/*.html`),节号/页集合/页数
**现读文档** ⇒ ②段改结构(页数增减、节号重排)时脚本不用改;`--expect-pages` 只在要额外钉住时才给。
2. 跑不了就报**没跑**(退出码 2)—— 例如 9223 上没有浏览器实例时,逐视口与变异对照会写「没跑」,
⛔ 不写 PASS。
3. 判据没覆盖面时给 **`SKIP` 并写明原因**(例:`<script>` 区里没有 HTML 注释可查)。
⛔ 不写 PASS(扫了 0 个块等于什么都没查),⛔ 也不写 FAIL(把合法状态判成故障)。
自检输出的**骨架**(脚本据此解析,原型要照它打印)在 `scripts/_common.py` 头部;
解析规则只写那一份,逐视口与变异对照都从它取。
---
## 6. 偏离单(本段收尾必须附)
| # | 原要求(文件:小节) | 实际做法 | 原因 | 影响 | 处置 |
@@ -0,0 +1,143 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门脚本的共用件:定位、运行环境发现、自检文本的解析规则。
🔴 **解析规则只在这里写一份** —— `run_viewports.py` 与 `mutate_test.py` 都从这里取,
⛔ 不许各自再写一遍正则(同一条判据两份实现 ⇒ 迟早一处改一处漏)。
## 原型自检输出要按这个骨架打印(③段的契约,⛔ 不是"某个项目的习惯")
原型自检区(`#selftest-out`)对每个列表页要打印**两行中的一行**:
P<n> <页名>:首屏从 <top>px 起,行距 <pitch>px,一屏装得下 <cap> 行 ← 达标时
P<n> <页名>一屏只能装下 <cap> 行(首行顶 <top>px、行距 <pitch>px) ← 不达标时
还要打印这四类全局项:
七页逐页查横向溢出:全为 0(最差 <页>) / 有页面横向溢出:<页> 超出 <px>px
顶栏高 <h>px · 侧栏 <w>×<h>px
纵向滚动只由主区承担:.app 高 <h>px
自检结果:PASS <n> / FAIL <n>
理由:闸门靠**读数**判达标(一屏行数、行距、横向溢出、外壳高度链),
读数只能从自检文本里取;把骨架固定下来,解析规则就不必跟着项目改。
"""
import glob
import os
import re
import shutil
import sys
# Windows 控制台默认不是 utf-8 ⇒ 中文输出会炸在最后一步(退出码看着正常、日志是乱码)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ── 自检文本的解析规则(唯一一份)────────────────────────────────────────────
PAT = {
"summary": r"自检结果:PASS\s*(\d+)\s*/\s*FAIL\s*(\d+)",
"row_ok": r"P(\d+)\s*[^\n::]*[::]\s*首屏从\s*(\d+)\s*px\s*起[,,]\s*行距\s*(\d+)\s*px"
r"[,,]\s*一屏装得下\s*(\d+)\s*行",
"row_bad": r"P(\d+)\s*[^\n]*?一屏只能装下\s*(\d+)\s*行",
"overflow_ok": r"[^\n]*逐页查横向溢出:全为\s*0(最差\s*(\w+))",
"overflow_bad": r"有页面横向溢出:(\w+)\s*超出\s*(\d+)\s*px",
"topbar_nav": r"顶栏高\s*(\d+)\s*px\s*·\s*侧栏\s*(\d+)×(\d+)\s*px",
"shell_ok": r"纵向滚动只由主区承担:\.app\s*高\s*(\d+)\s*px",
"console": r"控制台报错:(.+)",
}
def parse_selftest(text, min_rows=6):
"""把自检文本解析成一行一页的读数表。
@return dict:
· `pass`/`fail`:自检自报的 PASS/FAIL 计数
· `rows`:{页号 int: {"cap": 一屏行数, "top": 首行顶 px, "pitch": 行距 px, "ok": cap>=min_rows}}
· `overflow_ok`/`shell_ok`:两条全局判据
· `topbar`/`nav_w`/`nav_h`:几何读数
· `console`:控制台报错摘要("?" 表示自检没打印这一项)
"""
text = text or ""
d = {"rows": {}, "overflow_ok": None, "shell_ok": None, "console": "?"}
m = re.search(PAT["summary"], text)
if m:
d["pass"], d["fail"] = int(m.group(1)), int(m.group(2))
for m in re.finditer(PAT["row_ok"], text):
p, top, pitch, cap = (int(x) for x in m.groups())
d["rows"][p] = {"cap": cap, "top": top, "pitch": pitch, "ok": cap >= min_rows}
for m in re.finditer(PAT["row_bad"], text):
p, cap = int(m.group(1)), int(m.group(2))
d["rows"].setdefault(p, {"cap": cap, "ok": cap >= min_rows})
m = re.search(PAT["overflow_ok"], text)
if m:
d["overflow_ok"], d["overflow_worst"] = True, m.group(1)
else:
m = re.search(PAT["overflow_bad"], text)
if m:
d["overflow_ok"], d["overflow_worst"], d["overflow_px"] = False, m.group(1), int(m.group(2))
m = re.search(PAT["topbar_nav"], text)
if m:
d["topbar"], d["nav_w"], d["nav_h"] = (int(x) for x in m.groups())
m = re.search(PAT["shell_ok"], text)
if m:
d["shell_ok"], d["app_h"] = True, int(m.group(1))
m = re.search(PAT["console"], text)
if m:
d["console"] = m.group(1).strip()
return d
# ── 运行环境发现(本机是托管运行时;换机器靠 env 覆盖)────────────────────────
def find_node(explicit=""):
"""定位 node:`--node` → `NODE_BIN` → PATH → 本机托管的最新一版。⛔ 不写死版本号。"""
for c in (explicit, os.environ.get("NODE_BIN", "")):
if c and os.path.exists(c):
return c
w = shutil.which("node")
if w:
return w
cand = sorted(glob.glob(os.path.join(
os.environ.get("WORKBUDDY_BIN_ROOT", r"E:\ProgramData\.workbuddy\binaries"),
"node", "versions", "*", "node.exe")))
return cand[-1] if cand else ""
def find_harness(explicit=""):
"""定位 browser-harness:`--harness` → `BH_BIN` → PATH → 用户 bin。⛔ 不写死路径。"""
for c in (explicit, os.environ.get("BH_BIN", "")):
if c and os.path.exists(c):
return c
w = shutil.which("browser-harness") or shutil.which("browser-harness.exe")
if w:
return w
cand = os.path.expanduser("~/.local/bin/browser-harness.exe")
return cand if os.path.exists(cand) else ""
# ── 文档/原型定位(按**目录形态**找,⛔ 不写死文件名)──────────────────────
def find_doc(ws):
"""②段《界面布局》:`<ws>/docs/pm/<项目>/prd/2b-*.md` 里取**最新改过**的那份。"""
hits = glob.glob(os.path.join(ws, "docs", "pm", "*", "prd", "2b-*.md"))
return max(hits, key=os.path.getmtime) if hits else ""
def find_proto(ws):
"""原型:`<ws>/docs/pm/<项目>/ui/*.html` 里取最新改过的那份(跳过 `_` 开头的暂存件)。"""
hits = [p for p in glob.glob(os.path.join(ws, "docs", "pm", "*", "ui", "*.html"))
if not os.path.basename(p).startswith("_")]
return max(hits, key=os.path.getmtime) if hits else ""
def resolve(ws="", doc="", proto=""):
"""把(可选的)三条入参统一成绝对路径;缺的按目录形态自动找,找不到返回空串。"""
ws = os.path.abspath(ws or os.getcwd())
return (ws,
os.path.abspath(doc) if doc else find_doc(ws),
os.path.abspath(proto) if proto else find_proto(ws))
def die(msg, code=2):
sys.stderr.write("⛔ %s\n" % msg)
return code
@@ -0,0 +1,110 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门②:原型 JS/模板字面量的**语法门禁**(只读原型,⛔ 不写盘)。
为什么需要它(同一个坑踩了 4 次的真实事故,不是"为了严谨"):
③段原型是单文件自包含,页面结构写在一堆 JS **模板字面量**里(反引号包起来的字符串)。
只要在模板里的 **HTML 注释**(`<!-- … -->`)中写出一个反引号,模板字面量会被提前闭合,
后面那段就被当成 JS 代码解析 —— 症状是**页面静默坏掉**(白屏 / `xxx is not defined`),
而 `node --check 整份 html` **报不出来**(html 不是 js)⇒ 一路混到浏览器里才炸。
查两件事:
① **语法**:把 `<script>` 段抠出来交给 `node --check`(⛔ 不是查整份 html)。
② **模板里的反引号**:多行扫描 script 区内的每个 `<!-- … -->` 块,块内出现反引号即报红
(⛔ 必须多行扫:反引号常落在注释的续行上,单行 grep 抓不到)。
用法:
python check_js.py --ws <工作区> # 自动找原型
python check_js.py --proto X.html --node <node.exe>
退出码:0 = PASS;1 = FAIL;2 = 用法/定位失败。
"""
import argparse
import io
import os
import re
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _common as C # noqa: E402
def main():
ap = argparse.ArgumentParser(description="③段闸门②:原型 JS 语法 + 模板反引号")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找 docs/pm/*/ui/*.html)")
ap.add_argument("--node", default="", help="node 可执行文件(缺省=自动发现)")
a = ap.parse_args()
_, _, proto = C.resolve(a.ws, "", a.proto)
if not proto:
return C.die("没找到原型 HTML")
node = C.find_node(a.node)
src = io.open(proto, encoding="utf-8").read()
if "<script>" not in src or "</script>" not in src:
return C.die("原型里没有 `<script>` 段:%s" % proto)
i = src.index("<script>") + len("<script>")
j = src.rindex("</script>")
js = src[i:j]
fails = []
# ① 语法
if not node:
fails.append("没找到 node(用 --node 或环境变量 NODE_BIN 指定)")
print("FAIL 语法:没找到 node ⇒ 这一条没跑(⛔ 不是 PASS)")
else:
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False,
encoding="utf-8", newline="") as f:
f.write(js)
tmp = f.name
try:
r = subprocess.run([node, "--check", tmp], capture_output=True)
if r.returncode == 0:
print("PASS 语法:<script> 段 %d 行,node --check 通过" % js.count("\n"))
else:
base = src[:i].count("\n") + 1
head = [l for l in r.stderr.decode("utf-8", "replace").split("\n") if l.strip()][:4]
fails.append("语法不过:" + " / ".join(head))
print("FAIL 语法:node --check 报错(下面的行号是 script 段内的,原文件约 +%d 行)" % base)
for l in head:
print(" " + l)
finally:
os.unlink(tmp)
# ② 模板里的反引号
bad = []
blocks = re.findall(r"<!--[\s\S]*?-->", js)
for m in re.finditer(r"<!--[\s\S]*?-->", js):
blk = m.group(0)
if "`" in blk:
line = js[:m.start()].count("\n") + src[:i].count("\n") + 2
bad.append("原文件第 %d 行附近:模板里的 HTML 注释含 %d 个反引号 —— 「%s」"
% (line, blk.count("`"), blk.split("\n")[0][:70]))
skipped = False
if bad:
fails.extend(bad)
print("FAIL 模板里的反引号:")
for b in bad:
print(" " + b)
elif blocks:
print("PASS 模板里的反引号:script 区内 %d 个 <!-- --> 块(多行扫描)都不含反引号" % len(blocks))
else:
# 🔴 判据没覆盖面时**明说不适用**(HARD):写 PASS 是假绿(扫了 0 个块,等于什么都没查),
# 写 FAIL 是把合法状态判成故障 ⇒ 两者都会让下一个人读错。
skipped = True
print("SKIP 模板里的反引号:script 区内 0 个 HTML 注释块 ⇒ **本项无覆盖面**"
"(成因:这一版原型的模板里没写 HTML 注释;等模板里再有注释,本项自动生效)")
print()
if fails:
print("结论:FAIL %d 项 —— 每一条都会让**页面在浏览器里才炸**(`node --check` 整份 html 看不到)。" % len(fails))
print("修法:注释里的反引号去掉(写 `.cls` 改写成「cls」或 class 名)。")
return 1
print("结论:全过。原型 %s" % proto)
if skipped:
print(" ⚠️ 其中「模板里的反引号」一项**无覆盖面**(见上面那行 SKIP)—— 它没有判过任何东西。")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,139 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门①:②段《界面布局》的页面骨架 ↔ 原型里的 `FROZEN` 表,逐页逐位比对。
为什么单拎出来:页面内的自检只能证明「渲染出来的板块 == `FROZEN` 表」,
证明不了「`FROZEN` 表 == ②段原文」。两张表各改一边就没人发现
⇒ 本闸门直接把②段那份 Markdown 当权威读,逐页逐位比。
判据:**页面集合**相等、每页**板块数量**相等、**顺序**相等、名字剥掉「(…)」注解后**逐字相同**。
🔴 结构性事实(页集合、节号、页数、板块总数)**一律现读文档**,⛔ 不在代码里写死 ——
②段改结构(例如页面增减、节号重排)时,写死常量的副本就会「读不到而报错」或
「按旧页数静默比错」;这类副本一旦散在各项目里,同一条闸门会出现多个版本。
用法:
python check_skeleton.py --ws <工作区> # 自动找 ②段 与 原型
python check_skeleton.py --doc X.md --proto Y.html # 指定两份
python check_skeleton.py --ws <工作区> --expect-pages 7 # 想额外钉住页数时才给
退出码:0 = PASS;1 = FAIL(列出每一处不一致);2 = 用法/定位失败。
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _common as C # noqa: E402
SEC_ANY = re.compile(r"^##\s+\S", re.M) # 任意二级标题(节的边界)
HEADING = re.compile(r"^##\s+(.*\S)\s*$", re.M)
PAGE = re.compile(r"^###\s*(P\d)\b")
ITEM = re.compile(r"^\s*(\d+)\.\s*(.+)$")
FROZEN = re.compile(r"^const FROZEN=\{(.*?)^\};", re.S | re.M)
FROZEN_ROW = re.compile(r"^\s*(p\d):\[(.*?)\],?\s*$", re.M)
def _pages_in(seg):
out, cur = {}, None
for line in seg.split("\n"):
mp = PAGE.match(line)
if mp:
cur = mp.group(1).lower()
out[cur] = []
continue
mi = ITEM.match(line)
if cur and mi:
out[cur].append(mi.group(2).split("——")[0].strip())
return out
def find_section(s):
"""定位**骨架那一节**。
🔴 判据按「标题里含『页骨架』」取(`## 五、七页骨架` 这种)—— ⛔ 不写死节号、⛔ 不写死页数。
先试「页骨架」,再退回「含骨架且里面**真解析得出页**的那一节」
(理由:正文里会有「这一版骨架改了什么」这类**同词异义**的标题,只按「骨架」两字认会认错节)。
"""
heads = [(m.start(), m.group(1)) for m in HEADING.finditer(s)]
for keys in (("页骨架",), ("骨架",)):
for i, (pos, title) in enumerate(heads):
if not any(k in title for k in keys):
continue
nxt = SEC_ANY.search(s, pos + 1)
seg = s[pos:nxt.start() if nxt else len(s)]
pg = _pages_in(seg)
if pg:
return seg, title, pg
return None, "", {}
def from_doc(path):
s = open(path, encoding="utf-8").read()
seg, title, out = find_section(s)
if not out:
raise SystemExit(C.die(
"②段里找不到「骨架」那一节(期望标题形如 `## 五、七页骨架`,且节内用 `### P1 …` 列页):%s" % path))
return out
def from_proto(path):
s = open(path, encoding="utf-8").read()
m = FROZEN.search(s)
if not m:
raise SystemExit(C.die("原型里找不到 `const FROZEN` 表:%s" % path))
out = {}
for k, body in FROZEN_ROW.findall(m.group(1)):
out[k] = re.findall(r"'([^']*)'", body)[1:] # 第 0 项是页面标题
if not out:
raise SystemExit(C.die("`FROZEN` 表解析为空(期望 `p1:['标题', '板块1', …]`):%s" % path))
return out
def norm(x):
return x.split("(")[0].strip()
def main():
ap = argparse.ArgumentParser(description="③段闸门①:②段骨架 ↔ 原型 FROZEN 表")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--doc", default="", help="②段界面布局(缺省=自动找 docs/pm/*/prd/2b-*.md)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找 docs/pm/*/ui/*.html)")
ap.add_argument("--expect-pages", type=int, default=0, help="想额外钉住页数时才给")
a = ap.parse_args()
ws, doc, proto = C.resolve(a.ws, a.doc, a.proto)
if not doc:
return C.die("没找到 ②段界面布局(找过 %s/docs/pm/*/prd/2b-*.md)" % ws)
if not proto:
return C.die("没找到原型 HTML(找过 %s/docs/pm/*/ui/*.html)" % ws)
d, p = from_doc(doc), from_proto(proto)
bad = []
if a.expect_pages and len(d) != a.expect_pages:
bad.append("②段页数 %d ≠ 期望 %d:%s" % (len(d), a.expect_pages, sorted(d)))
if set(d) != set(p):
bad.append("页面集合不同:②段 %s / 原型 %s" % (sorted(d), sorted(p)))
for k in sorted(set(d) & set(p)):
x, y = d[k], p[k]
if len(x) != len(y):
bad.append("%s 板块数 ②段 %d ≠ 原型 %d(②段 %s / 原型 %s)" % (k, len(x), len(y), x, y))
continue
for i, (u, v) in enumerate(zip(x, y)):
nu, nv = norm(u), norm(v)
if nu != nv:
bad.append("%s 第 %d 块 ②段「%s」≠ 原型「%s」(剥注解后「%s」≠「%s」)"
% (k, i + 1, u, v, nu, nv))
n = sum(len(v) for v in d.values())
if bad:
print("FAIL 骨架与②段不一致(②段 %d 页 %d 个板块)" % (len(d), n))
for x in bad:
print(" · " + x)
print("\n出处:②段 %s\n 原型 %s" % (doc, proto))
return 1
print("PASS 骨架冻结:%d 页 %d 个板块,②段与原型 `FROZEN` 表逐页逐位一致" % (len(d), n))
print(" ②段 %s\n 原型 %s" % (doc, proto))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,199 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门③的取数件:取原型 `#/selftest` 的自检文本(容忍「自检太慢 → IPC 5 s 超时」)。
为什么不能直接用常规 DOM 抓取:browser-harness 的 IPC 客户端读超时是 **5 s**,
而原型自检要逐个控件真驱动、再逐页量几何 ⇒ 主线程被占住 > 5 s ⇒ 应答回不来。
⚠️ 这是**取证通道的限制,不是原型的缺陷**:客户端超时**不会**取消渲染进程里已经在跑的那段 JS
⇒ 正确做法是「发一次、超时也别管,然后轮询直到结果出现」。
做两件事:
① 轮询 `#selftest-out` 直到拿到文本(每次失败等 1 s 再试,最多 `--count` 次);
② 顺带核视口(要多少必须是多少,⛔ 不接受近似)。
🔴 标签纪律:**一类任务只开一个标签页并复用**(复用 id 记在 `--tab-state`)。
⛔ 不新开、⛔ 本脚本自己不关标签 —— 收工时由 `--close-tab` 或人工关一次。
用法:
python grab_selftest.py --proto X.html --viewport 1440x900 --out out.txt
python grab_selftest.py --proto X.html --close-tab # 收工关标签
退出码:0 = 拿到文本;1 = 没拿到;2 = 没有可用浏览器实例/定位失败。
"""
import argparse
import io
import os
import socket
import subprocess
import sys
import tempfile
from urllib.parse import quote, urlparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _common as C # noqa: E402
BU_URL = os.environ.get("BU_CDP_URL", "http://127.0.0.1:9223")
SCRIPT = r"""
import io, json, os, time
BU = @BU@
url = @URL@
out = @OUT@
state = @STATE@
W, H = @W@, @H@
# --- 复用唯一一个标签(一类任务只开一个标签并复用)---
tid = None
try:
if state and os.path.exists(state):
tid = (io.open(state, encoding='utf-8').read().strip() or None)
except Exception:
tid = None
live = {t['targetId']: t for t in list_tabs()}
if tid in live:
switch_tab(tid)
else:
tid = new_tab()
try:
os.makedirs(os.path.dirname(state), exist_ok=True)
io.open(state, 'w', encoding='utf-8').write(tid)
except Exception:
pass
# 🔴 顺序要紧:**先定视口,再导航**。自检是页面 load 时跑完的,视口必须在此之前就位,
# 否则页面会按上一个视口量一遍几何,出来的数是上一轮的。
cdp("Emulation.setDeviceMetricsOverride", width=W, height=H, deviceScaleFactor=1, mobile=False)
# ⚠️ ⛔ 不能用 wait_for_load():自检占住渲染主线程,`document.readyState` 的 IPC 读会超时,
# 把整段取证打断。正确等法是下面的轮询:超时就当没出、过 1 秒再问。
# 🔴 **必须先落脚空白页**:`#/selftest` 是同一个 URL,直接导航只改 hash、**不重新加载**
# ⇒ 页面不重跑自检,读到的是上一次(可能是别的视口)的数。
try:
goto_url("about:blank")
except Exception:
pass
wait(0.15)
goto_url(url)
wait(0.6)
WAIT = "(function(){{var e=document.getElementById('selftest-out');return e?e.textContent:null}})()"
res = None
t0 = time.time()
for i in range(@COUNT@):
try:
v = js(WAIT)
except Exception:
v = None
if v:
res = v
break
wait(1.0)
el = time.time() - t0
try:
vw = js("innerWidth"); vh = js("innerHeight")
except Exception:
vw = vh = -1
if res:
io.open(out, 'w', encoding='utf-8', newline='').write(res)
print("ELAPSED %.1fs VIEWPORT %dx%d" % (el, vw, vh))
else:
print("NO-OUTPUT after %.1fs (viewport %dx%d)" % (el, vw, vh))
cdp("Emulation.clearDeviceMetricsOverride")
"""
CLOSE = r"""
import io, os
state = @STATE@
try:
tid = (io.open(state, encoding='utf-8').read().strip() or None) if state and os.path.exists(state) else None
except Exception:
tid = None
if tid:
try:
close_tab(tid); print("CLOSED %s" % tid)
except Exception as e:
print("CLOSE-FAIL %r" % (e,))
try:
os.remove(state)
except Exception:
pass
"""
def _probe(host="127.0.0.1", port=9223):
s = socket.socket()
s.settimeout(1.5)
try:
s.connect((host, port))
return True
except OSError:
return False
finally:
s.close()
def build(vp, out, proto, count, state):
vw, vh = [int(x) for x in vp.lower().replace(",", "x").split("x")]
out = os.path.abspath(out)
os.makedirs(os.path.dirname(out), exist_ok=True)
url = "file:///" + quote(os.path.abspath(proto).replace("\\", "/"), safe="/:") + "#/selftest"
return (SCRIPT.replace("@BU@", repr(BU_URL)).replace("@URL@", repr(url))
.replace("@OUT@", repr(out)).replace("@STATE@", repr(os.path.abspath(state)))
.replace("@W@", str(vw)).replace("@H@", str(vh)).replace("@COUNT@", str(count)))
def _run(harness, code):
env = dict(os.environ)
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
env.pop(k, None)
env["NO_PROXY"] = "127.0.0.1,localhost"
env["BU_CDP_URL"] = BU_URL
tf = tempfile.NamedTemporaryFile("w", suffix=".py", delete=False, encoding="utf-8")
tf.write(code)
tf.close()
try:
with open(tf.name, "rb") as f:
return subprocess.run([harness], capture_output=True, env=env, input=f.read())
finally:
os.unlink(tf.name)
def main():
ap = argparse.ArgumentParser(description="③段闸门③取数件:取原型自检文本")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
ap.add_argument("--viewport", default="1440x900")
ap.add_argument("--out", default="", help="自检文本落盘位置(`--close-tab` 时不需要)")
ap.add_argument("--count", type=int, default=150, help="轮询次数(每次 1 s)")
ap.add_argument("--tab-state", default="", help="标签 id 的记账文件(缺省=临时目录下的固定名)")
ap.add_argument("--harness", default="", help="browser-harness 可执行文件(缺省=自动发现)")
ap.add_argument("--close-tab", action="store_true", help="收工:关掉复用的那个标签")
a = ap.parse_args()
harness = C.find_harness(a.harness)
if not harness:
return C.die("没找到 browser-harness(用 --harness 或环境变量 BH_BIN 指定)")
state = a.tab_state or os.path.join(tempfile.gettempdir(), "bh_proto_tab.txt")
host = urlparse(BU_URL).hostname or "127.0.0.1"
port = urlparse(BU_URL).port or 9223
if not _probe(host, port):
sys.stderr.write("⛔ %s 上没有浏览器实例在跑(本工具只连不起)。\n" % BU_URL)
return 2
if a.close_tab:
_run(harness, CLOSE.replace("@STATE@", repr(os.path.abspath(state))))
return 0
if not a.out:
return C.die("--out 是必需的(除 `--close-tab` 外)")
_, _, proto = C.resolve(a.ws, "", a.proto)
if not proto:
return C.die("没找到原型 HTML")
if os.path.exists(a.out):
os.remove(a.out)
os.makedirs(os.path.dirname(os.path.abspath(state)), exist_ok=True)
r = _run(harness, build(a.viewport, a.out, proto, a.count, state))
sys.stderr.write("[%s] %s\n" % (a.viewport, r.stdout.decode("utf-8", "replace").strip()))
err = r.stderr.decode("utf-8", "replace")
if "Traceback" in err:
sys.stderr.write(err[-800:] + "\n")
return 0 if os.path.exists(a.out) else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,155 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门④:变异对照 —— 证明原型自检的判据**不是「恒绿」**。
做法:把原型复制一份,按**规则文件**往里注入一处已知的假病,再跑一次自检,
要求它必须报红,并且红在**预期的那一条**上。测完即删临时件。
为什么必须有它:一个永远 PASS 的自检等于没有自检。只有「注病 → 报红 → 复原 → 回绿」
这条链走通了,前面那串 PASS 才算证据。
🔴 **框架与数据分家**:本文件是框架(进技能包,跨项目共用);
注入点表是**数据**,放在原型同目录的 `mutate-rules.json`(跟着原型走,⛔ 不进技能包)。
用 `--rules` 指定别的位置。
规则文件格式(JSON 数组,每项一条变异):
[{"name": "M1 死控件",
"why": "把 P2-01 的实现掏空 —— 点它不再有任何反馈",
"find": "'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },",
"repl": "'P2-01':()=>{},",
"expect": "P2-01",
"viewport": "1440x900"}]
⚠️ 两条会毁掉对照的写法(都踩过):
· **注入点失效** ⇒ 对照变成假对照(让人以为这条判据有人看着)⇒ 本框架把它算**失败**,不算跳过;
· **注入写坏原型**(注入进去但原型跑不起来)⇒ 输出整个拿不到 ⇒ 同样算失败。
用法:
python mutate_test.py --ws <工作区> # 用原型同目录的 mutate-rules.json
python mutate_test.py --proto X.html --rules R.json
退出码:0 = 全部抓住且基线回绿;1 = 有没抓住的;2 = 基线不是全绿/定位失败/没有浏览器实例。
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _common as C # noqa: E402
def load_rules(path):
"""读规则文件:`.py`(含 `MUTANTS`)或 `.json` 都行;每项补齐成 6 元组。"""
if path.endswith(".py"):
ns = {}
exec(compile(open(path, encoding="utf-8").read(), path, "exec"), ns)
raw = ns.get("MUTANTS") or []
else:
raw = json.loads(open(path, encoding="utf-8").read())
out = []
for r in raw:
if isinstance(r, dict):
out.append((r["name"], r.get("why", ""), r["find"], r["repl"],
r["expect"], r.get("viewport", "1440x900")))
else:
out.append(tuple(r) + (("1440x900",) if len(r) < 6 else ()))
return out
def run(proto, vp, out):
"""跑一次自检取文本;拿不到返回 None。"""
if os.path.exists(out):
os.remove(out)
grab = os.path.join(os.path.dirname(os.path.abspath(__file__)), "grab_selftest.py")
r = subprocess.run([sys.executable, grab, "--proto", proto, "--viewport", vp,
"--count", "40", "--out", out], capture_output=True, text=True)
if r.returncode == 2:
raise SystemExit(C.die("没有可用浏览器实例(9223 没在跑)⇒ 变异对照这一条没跑"))
if not os.path.exists(out):
return None
return open(out, encoding="utf-8", errors="replace").read()
def headline(text):
m = re.search(C.PAT["summary"], text or "")
return (int(m.group(1)), int(m.group(2))) if m else None
def main():
ap = argparse.ArgumentParser(description="③段闸门④:变异对照(框架)")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
ap.add_argument("--rules", default="", help="规则文件(缺省=原型同目录 mutate-rules.json)")
ap.add_argument("--only", default="", help="只跑名字里含这个串的条目(排查单条用)")
ap.add_argument("--out-dir", default="", help="自检文本落哪(缺省=临时目录)")
a = ap.parse_args()
ws, _, proto = C.resolve(a.ws, "", a.proto)
if not proto:
return C.die("没找到原型 HTML")
rules = a.rules or os.path.join(os.path.dirname(proto), "mutate-rules.json")
if not os.path.exists(rules):
hits = glob.glob(os.path.join(os.path.dirname(proto), "mutate-rules.*"))
if not hits:
return C.die("没有规则文件(期望 %s,或在原型同目录放 mutate-rules.json)" % rules)
rules = hits[0]
MUTANTS = load_rules(rules)
if a.only:
MUTANTS = [m for m in MUTANTS if a.only in m[0]]
if not MUTANTS:
return C.die("规则文件里没有可跑的条目:%s" % rules)
out_dir = a.out_dir or tempfile.gettempdir()
os.makedirs(out_dir, exist_ok=True)
out = os.path.join(out_dir, "mut-selftest.txt")
src = open(proto, encoding="utf-8").read()
base = run(proto, "1440x900", out)
hb = headline(base)
print("基线(未注入):PASS %s / FAIL %s" % hb if hb else "基线读不到自检输出")
if not hb or hb[1] != 0:
print("⛔ 基线就不是全绿 ⇒ 先把原型修到全绿,再做变异对照(否则对照判不出来)。")
return 2
bad = 0
for name, why, find, repl, expect, vp in MUTANTS:
if find not in src:
print("⚠️ %s:找不到注入点 ⇒ **算失败**(注入点失效的对照比没有对照更坏)" % name)
bad += 1
continue
tmp = os.path.join(out_dir, "mut-proto.html")
with open(tmp, "w", encoding="utf-8") as f:
f.write(src.replace(find, repl, 1))
text = run(tmp, vp, out)
h = headline(text)
try:
os.remove(tmp)
except OSError:
pass
if not h:
print("❌ %s:注入后自检跑不出来(连输出都没有)⇒ 算失败" % name)
bad += 1
continue
caught = h[1] > 0 and expect in (text or "")
print("%s %s:注入(%s) ⇒ PASS %d / FAIL %d | %s"
% ("✅" if caught else "❌", name, why, h[0], h[1],
"报红且红在预期处(%s,视口 %s)" % (expect, vp) if caught
else "**没在预期处报红(期望含「%s」,视口 %s)**" % (expect, vp)))
if not caught:
bad += 1
for line in (text or "").split("\n"):
if line.startswith("FAIL"):
print(" 实际:" + line)
print()
print("变异对照:%d / %d 通过" % (len(MUTANTS) - bad, len(MUTANTS)))
if bad == 0:
print("⇒ %d 处假病全部被自检抓住,且基线回绿:这套判据不是恒绿的。" % len(MUTANTS))
return 0 if bad == 0 else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,94 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门③:逐视口跑一遍原型自检,抽出密度/溢出/外壳高度链的读数表。
为什么自检本身就能出这张表:原型的 `#/selftest` 在量完几何后会把
「首行顶 / 行距 / 一屏装得下几行 / 横向溢出 / 外壳高度链」逐条打进 `#selftest-out`
⇒ 每个视口各跑一次,表就出来了(⛔ 不另起一套量法 —— 两套量法必然漂)。
判据(出处:③段 `layouts-tooling.md`):
· 每个列表页**一屏可见行数 ≥ `--min-rows`(默认 6,对应 900px 高)**,⛔ 不分档;
· 横向溢出全为 0;
· 纵向滚动只由主区承担(外壳高度链成立);
· 自检自身 FAIL 为 0。
⚠️ 解析规则取 `_common.parse_selftest()`(全包唯一一份)⇒ 原型自检输出要按
`_common.py` 头部写的那套骨架打印,否则这里读不到数。
用法:
python run_viewports.py --ws <工作区> --out-dir out/vp # 全量视口
python run_viewports.py --ws <工作区> --list # 只列参数、不跑浏览器
python run_viewports.py --ws <工作区> --viewports 1440x900,390x844
退出码:0 = 全部达标;1 = 有不达标项;2 = 定位失败/没有浏览器实例。
"""
import argparse
import os
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _common as C # noqa: E402
# 默认视口:按契约的断点走(900px 高是密度判据的基准档;390x844/500x844 是留档档)
DEFAULT_VPS = ["1440x900", "1280x900", "1024x900", "1000x900", "960x900", "920x900",
"901x900", "900x900", "840x900", "820x900", "768x900", "700x900",
"640x900", "560x900", "500x844", "390x844"]
def main():
ap = argparse.ArgumentParser(description="③段闸门③:逐视口密度/溢出/外壳高度链")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
ap.add_argument("--out-dir", default="", help="每个视口的自检文本落哪(缺省=临时目录)")
ap.add_argument("--viewports", default=",".join(DEFAULT_VPS))
ap.add_argument("--min-rows", type=int, default=6, help="一屏可见行数下限(契约默认 6)")
ap.add_argument("--harness", default="", help="browser-harness 可执行文件(缺省=自动发现)")
ap.add_argument("--list", action="store_true", help="只列解析出的路径与视口,不跑浏览器")
a = ap.parse_args()
ws, _, proto = C.resolve(a.ws, "", a.proto)
out_dir = a.out_dir or os.path.join(ws, "tmp", "vp")
vps = [v.strip() for v in a.viewports.split(",") if v.strip()]
if a.list:
print("工作区 %s" % ws)
print("原型 %s" % (proto or "(没找到)"))
print("落盘 %s" % out_dir)
print("行数下限 %d" % a.min_rows)
print("视口 %d 个:%s" % (len(vps), " ".join(vps)))
return 0
if not proto:
return C.die("没找到原型 HTML")
os.makedirs(out_dir, exist_ok=True)
grab = os.path.join(os.path.dirname(os.path.abspath(__file__)), "grab_selftest.py")
rows, bad = [], 0
for vp in vps:
out = os.path.join(out_dir, vp + ".txt")
cmd = [sys.executable, grab, "--proto", proto, "--viewport", vp,
"--count", "60", "--out", out]
if a.harness:
cmd += ["--harness", a.harness]
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if r.returncode == 2:
return C.die("没有可用浏览器实例(9223 没在跑)⇒ 逐视口这一条没跑")
text = open(out, encoding="utf-8", errors="replace").read() if os.path.exists(out) else ""
d = C.parse_selftest(text, a.min_rows)
d["vp"] = vp
rows.append(d)
caps = ",".join("%s:%s" % (k, v.get("cap")) for k, v in sorted(d["rows"].items()))
ok = (d.get("fail") == 0 and d.get("rows")
and all(v.get("ok") for v in d["rows"].values())
and d.get("overflow_ok") is not False and d.get("shell_ok") is not False)
if not ok:
bad += 1
print("%s%-9s 自检 PASS %-3s/FAIL %-3s | 一屏行数 %s | 溢出 %s | 外壳 %s | 控制台 %s"
% ("OK " if ok else "!! ", vp, d.get("pass", "?"), d.get("fail", "?"),
caps or "?", d.get("overflow_ok"), d.get("shell_ok"), d.get("console")),
flush=True)
if r.stderr.strip():
sys.stderr.write(" [stderr] " + r.stderr.strip().split("\n")[-1] + "\n")
print("\n逐视口:不达标 %d / 共 %d(行数下限 %d)" % (bad, len(rows), a.min_rows))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,152 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""③段闸门自身的变异对照:证明 `check_skeleton.py` 与 `check_js.py` **不是恒绿**。
做法:在**临时目录里**给 ②段与原型各造一处已知假病,要求对应闸门报红;
再做一个「不该报红」的对照,要求它仍然 PASS(否则判据是恒红,一样没用)。
⛔ 全程不碰工作区里的原件(改的每一处都写在临时副本上)。
四档对照:
| 档 | 造什么假病 | 期望 |
|---|---|---|
| 基线 | 什么都不动 | 两个闸门都 PASS |
| 变异 1 | ②段骨架节里某块**改名**(改「——」前面的名字本体) | 骨架闸门 FAIL,且点出是哪一页第几块 |
| 变异 2 | 原型 `<script>` 段里塞一个**含反引号的 HTML 注释** | 语法闸门 FAIL |
| 变异 3 | 原型 `FROZEN` 表里砍掉某页最后一块 | 骨架闸门 FAIL,报「板块数不等」 |
| 对照 4 | ②段骨架节的**节号加后缀**(不改内容) | 骨架闸门仍 PASS(证明节号没写死) |
⚠️ 「改名」必须改**「——」前面的名字本体**:`——` 后面是说明、`(…)` 是注解,
两者按契约**不参与比对** ⇒ 往那里加字**本来就该 PASS**,拿它当变异会得出「闸门有洞」的错结论。
用法:`python scripts/selftest_gates.py --ws <工作区>`
退出码:0 = 四档全对;1 = 有档位不符(判据有洞或定位过窄);2 = 定位失败。
"""
import argparse
import io
import os
import re
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import _common as C # noqa: E402
def _run(*args):
r = subprocess.run([sys.executable, os.path.join(HERE, args[0])] + list(args[1:]),
capture_output=True, text=True, errors="replace")
return r.returncode, (r.stdout or "") + (r.stderr or "")
def _brief(out, n=2):
return " / ".join([l.strip() for l in out.strip().split("\n") if l.strip()][:n])
def main():
ap = argparse.ArgumentParser(description="③段闸门自身的变异对照")
ap.add_argument("--ws", default="", help="工作区根(缺省=当前目录)")
ap.add_argument("--doc", default="", help="②段界面布局(缺省=自动找)")
ap.add_argument("--proto", default="", help="原型 HTML(缺省=自动找)")
a = ap.parse_args()
ws, doc, proto = C.resolve(a.ws, a.doc, a.proto)
if not doc:
return C.die("没找到 ②段界面布局(找过 %s/docs/pm/*/prd/2b-*.md)" % ws)
if not proto:
return C.die("没找到原型 HTML(找过 %s/docs/pm/*/ui/*.html)" % ws)
tmp = tempfile.mkdtemp(prefix="gate-selftest-")
rows = []
try:
print("工作区 %s\n②段 %s\n原型 %s\n" % (ws, doc, proto))
c_doc, o_doc = _run("check_skeleton.py", "--doc", doc, "--proto", proto)
c_js, o_js = _run("check_js.py", "--proto", proto)
rows.append(("基线:原件两闸门都 PASS", c_doc == 0 and c_js == 0,
"骨架 rc=%d / 语法 rc=%d" % (c_doc, c_js)))
print("== 基线 ==")
print(" 骨架 rc=%d | %s" % (c_doc, _brief(o_doc, 1)))
print(" 语法 rc=%d | %s" % (c_js, _brief(o_js, 4)))
s = io.open(doc, encoding="utf-8").read()
h = io.open(proto, encoding="utf-8").read()
# 变异 1:②段骨架节里,第一页的第 2 条块改名(名字本体)
seg = re.search(r"^##\s+[^\n]*页骨架[^\n]*$", s, re.M)
p_first = re.search(r"^###\s*P\d[^\n]*$", s, re.M)
if not (seg and p_first and p_first.start() > seg.start()):
return C.die("②段里定位不到骨架节里的第一页 ⇒ 判据形状变了,先看 check_skeleton.py")
i, j = p_first.start(), s.find("\n### ", p_first.end())
seg_txt = s[i:j if j > 0 else len(s)]
m = re.search(r"^(\s*)2\.\s*([^—\n]+)(.*)$", seg_txt, re.M)
if not m:
return C.die("骨架节的第一页里找不到「2. …」这条块 ⇒ 判据形状变了")
mutated = ("%s2. %s改名%s" % (m.group(1), m.group(2), m.group(3)))
doc_t = os.path.join(tmp, "2b-mut.md")
io.open(doc_t, "w", encoding="utf-8", newline="").write(
s[:i] + seg_txt.replace(m.group(0), mutated, 1) + s[i + len(seg_txt):])
c3, o3 = _run("check_skeleton.py", "--doc", doc_t, "--proto", proto)
hit = c3 == 1 and "≠" in o3
rows.append(("变异 1:②段某块改名 ⇒ 骨架闸门报红并点名", hit,
"rc=%d | %s" % (c3, _brief(o3, 2))))
print("\n== 变异 1:②段第一页第 2 块改名(%s → %s)==" % (m.group(2).strip(), m.group(2).strip() + "改名"))
print(" 骨架 rc=%d(期望 1)| %s" % (c3, _brief(o3, 2)))
# 变异 2:script 区里塞一个含反引号的 HTML 注释
k = h.index("<script>")
anchor = h.index("\n", k) + 1
proto_t = os.path.join(tmp, "proto-mut.html")
io.open(proto_t, "w", encoding="utf-8", newline="").write(
h[:anchor] + "\n /* 探针 */ const _p=`<!-- 注释里带 ` 反引号 -->`;\n" + h[anchor:])
c4, o4 = _run("check_js.py", "--proto", proto_t)
rows.append(("变异 2:script 区注释里塞反引号 ⇒ 语法闸门报红", c4 == 1,
"rc=%d | %s" % (c4, _brief(o4, 3))))
print("\n== 变异 2:script 区内塞一个含反引号的 HTML 注释 ==")
print(" 语法 rc=%d(期望 1)| %s" % (c4, _brief(o4, 3)))
# 变异 3:FROZEN 表砍掉某页最后一块
mm = re.search(r"^const FROZEN=\{(?:.*?)^\};", h, re.S | re.M)
if not mm:
return C.die("原型里找不到 `const FROZEN` 表")
body = mm.group(0)
row = re.search(r"^\s*(p\d):\[(.*?)\],?\s*$", body, re.M)
items = re.findall(r"'([^']*)'", row.group(2))
if len(items) < 2:
return C.die("`FROZEN` 表那一页少于 2 项 ⇒ 造不出「少一块」")
cut = body.replace(row.group(0), row.group(0).replace("'" + items[-1] + "'", "", 1), 1)
proto_t2 = os.path.join(tmp, "proto-mut2.html")
io.open(proto_t2, "w", encoding="utf-8", newline="").write(h.replace(body, cut, 1))
c5, o5 = _run("check_skeleton.py", "--doc", doc, "--proto", proto_t2)
rows.append(("变异 3:原型某页少一块 ⇒ 骨架闸门报「板块数不等」", c5 == 1 and "板块数" in o5,
"rc=%d | %s" % (c5, _brief(o5, 2))))
print("\n== 变异 3:原型 %s 页少一块板块 ==" % row.group(1))
print(" 骨架 rc=%d(期望 1)| %s" % (c5, _brief(o5, 2)))
# 对照 4:节号加后缀 ⇒ 仍应 PASS(证明节号没写死)
doc_t2 = os.path.join(tmp, "2b-heading.md")
io.open(doc_t2, "w", encoding="utf-8", newline="").write(
s.replace(seg.group(0), seg.group(0) + "(改节号也不该失效)", 1))
c6, o6 = _run("check_skeleton.py", "--doc", doc_t2, "--proto", proto)
rows.append(("对照 4:骨架节号加后缀 ⇒ 闸门仍 PASS(节号没写死)", c6 == 0,
"rc=%d | %s" % (c6, _brief(o6, 1))))
print("\n== 对照 4:骨架节号加后缀 ⇒ 应仍 PASS ==")
print(" 骨架 rc=%d(期望 0)| %s" % (c6, _brief(o6, 1)))
print()
bad = 0
for name, ok, info in rows:
if not ok:
bad += 1
print("%s %s | %s" % ("✅" if ok else "❌", name, info))
print("\n闸门自身对照:%d / %d 通过" % (len(rows) - bad, len(rows)))
if bad == 0:
print("⇒ 两闸门既不是恒绿(变异全报红),也不是恒红(节号对照仍 PASS)。")
return 0 if bad == 0 else 1
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())