#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""JS 注释剥离:只留真正会被执行的代码。
为什么不能用 `re.sub(r'/\\*.*?\\*/', '', src, flags=re.S)`
---------------------------------------------------
那个写法在本仓的 web/app.js 上**会吃掉几千行真代码**。
根因是**顺序**,不是正则本身:app.js 8222 行附近有一句**行注释**,
内容恰好是讲「怎么防止 CSS 注释被提前闭合」的,注释里写了 `/* */`
这两个字面量:
// 主题名 / 版本是自由文本,原样拼进 /* */ 注释时,
// 一个 "*/" 就能提前闭合注释、…
而`js_code_only` 当年是「先剥块注释、再剥行注释」。剥块注释时那行
行注释还在原文里,于是 `/*` 被当成真注释的开头,`.*?` 一路向后找
最近的 `*/` —— 匹配到几千行之后,把中间所有真代码连同若干正常注释
一起删了。
证据:app.js 里 `/*` 169 次而 `*/` 170 次(HEAD 版是 163 对 164)
—— 数量不等就是配对错位一格的铁证,而错位会一路传播到文件末尾。
所以凡是拿「剥注释后的 app.js」做断言的检查器,都得先确认自己没踩
这个坑。
正确做法:按词法顺序单遍扫,维护「代码 / 字符串 / 模板串 / 正则字面量」
四态,**只在代码态才认注释**。
第二版踩的坑(别重犯)
----------------------
第一版修好了块注释,又被模板串里的 HTML 打穿:
`http://169.254.0.1:8888/` 里的 `//`
被当成行注释,`
/var/log/drouter/*.jsonl | ` 里的
`/*` 被当成块注释 —— 又吃掉两百多行。
原因是模板串只做了「找下一个反引号」,没处理 `${}`。本仓 app.js 有
**2289 处 `${}`**,而 `${}` 里能再出现字符串和模板串,扫描器一旦在
`href="#"` 那种双引号上把状态带偏,后面整段 HTML 就被当代码扫了。
所以下面 `_skip_string` 对 `${}` 做了递归,并且**普通引号不跨行**
(JS 本身也不允许;碰到换行就当未闭合,就地收手,绝不一路吞下去)。
"""
import re
RESERVED_BEFORE_REGEX = set('(,=:[!&|?{};+-*%~^<>')
# 这些**关键字**后面同样可以合法地跟一个正则字面量。
# 漏掉它们的后果很隐蔽:app.js 第 18 行
# return /^https?:\/\//i.test(s) ? s : '';
# 判 `/` 时prev 停在 `return` 的字母 `n` 上,不在上面的符号集里 →
# 正则分支没进 → `/` 当普通字符 → 下一个 `\` 又被当成块注释开头 →
# 一路吃到后面某个 `//` → 剥出来的正则缺尾,V8 报
# "Invalid regular expression: missing /"。
#
# ⛔ 抓出这个 bug 的是 devtools/check-jsstrip.js 里那条
# 「剥后产物必须能被 V8 解析」。纯 token 计数(function/const/let
# 各多少个)**一个都没少**,python 侧自检 44 项全绿 ——
# 也就是说,**只靠自己写的自检,这个 bug 会一路绿灯走到发版**。
# 这就是「换一个独立工具再问一遍」的价值。
KEYWORDS_BEFORE_REGEX = {
'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete',
'void', 'throw', 'case', 'do', 'else', 'yield', 'await',
}
def _skip_string(src, i, quote):
"""从 src[i](引号本身)开始,返回闭合引号之后的位置。
模板串额外处理 ${...}:里面可以再出现字符串、模板串和注释。
"""
n = len(src)
j = i + 1
while j < n:
c = src[j]
if c == '\\':
j += 2
continue
if quote == '`' and src.startswith('${', j):
depth = 1
j += 2
while j < n and depth:
c2 = src[j]
if c2 in '"\'':
j = _skip_string(src, j, c2)
continue
if c2 == '`':
j = _skip_string(src, j, '`')
continue
if src.startswith('//', j):
k = src.find('\n', j)
j = n if k < 0 else k
continue
if src.startswith('/*', j):
k = src.find('*/', j + 2)
j = n if k < 0 else k + 2
continue
if c2 == '{':
depth += 1
elif c2 == '}':
depth -= 1
if depth == 0:
j += 1
break
j += 1
continue
if c == quote:
return j + 1
if quote != '`' and c == '\n':
# 普通引号不跨行。碰到换行说明前面那个引号是被 HTML 属性
# 之类的上下文骗了,就地收手,别一路吞下去。
return j
j += 1
return n
def _skip_regex(src, i):
"""从 src[i]('/' 本身)尝试当作正则字面量跳过,返回 (位置, 是否真是)。
「是除号」的情况要交还给调用方按普通字符处理,所以返回 False。
"""
n = len(src)
j = i + 1
in_class = False
while j < n:
ch = src[j]
if ch == '\\':
j += 2
continue
if ch == '[':
in_class = True
elif ch == ']':
in_class = False
elif ch == '/' and not in_class:
# ⚠️ 这里 j 已经过了分隔斜杠,再 +1 是**必需**的。
# 少这一下会切出 `/^https?:\/\` 这种缺尾的正则 —— 而正则
# 缺尾是**语法错误**,所以判据不是「token 少了一个」,
# 而是「剥后整份文件过不了 V8 解析」。node 侧那条判据
# (剥后 new Function 成功)就是这么抓到的,比数数硬得多。
j += 1
while j < n and src[j].isalpha(): # g / i / m / u / s / y
j += 1
return j, True
elif ch == '\n':
return i, False
j += 1
return i, False
def js_code_only(src):
"""剥掉 JS 的注释(块注释 + 行注释),保留字符串/模板串/正则的内容。
只处理注释,**不动字符串** —— 里面有 `` `/*` `` 或 `` `*/` `` 时
保留原样才是对的:那些是运行时真正会输出的字符。
"""
out = []
i = 0
n = len(src)
# prev = 上一个「有效 token 的尾词」。记词而不记字符是必须的:
# `return /re/` 里的 `/` 前面是关键字 return,光看最后一个字母
# `n` 是判不出「这里该当正则」的。记一个词才能拿去查
# KEYWORDS_BEFORE_REGEX。分隔符/字面量时 prev 就是那个符号本身。
prev = ''
while i < n:
c = src[i]
nxt = src[i + 1] if i + 1 < n else ''
if c == '/' and nxt == '*':
j = src.find('*/', i + 2)
if j < 0:
break # 未闭合:后面全是注释
out.append(' ') # 占位,保持行号不变
i = j + 2
# prev 不变:注释不是 token,不影响「除号 vs 正则」的判断
continue
if c == '/' and nxt == '/':
j = src.find('\n', i)
if j < 0:
break
i = j # 换行符留给下一轮
continue
if c in '"\'' or c == '`':
j = _skip_string(src, i, c)
out.append(src[i:j])
# 字符串是「值」,正则不能紧跟它,所以 prev 置成一个
# 既不是符号也不是关键字的哨兵。
prev = '\x00' # NUL:绝不与任何集合元素相等
i = j
continue
if c == '/' and (prev == '' or prev in RESERVED_BEFORE_REGEX
or prev in KEYWORDS_BEFORE_REGEX):
j, ok = _skip_regex(src, i)
if ok:
out.append(src[i:j])
prev = '/'
i = j
continue
out.append(c)
if c.isalnum() or c in '_$':
# 标识符/数字:并进 prev 组成完整的尾词
prev = prev + c if prev and (prev[-1].isalnum() or
prev[-1] in '_$') else c
elif not c.isspace():
# 符号:整个替换掉 prev(只看最近那个符号)
prev = c
# 空白不更新 prev
i += 1
return ''.join(out)
def js_code_only_selfcheck(src, label='app.js'):
"""自检:剥完之后结构没被破坏。
这不是形式主义。旧实现最典型的失败形态是**静默**吃掉代码,
只看「断言绿了」根本发现不了。
⚠️ 这里**不要**拿「剥前后行数比」当判据。我第一版写了
`剥后行数/原行数 > 0.97`,看着很合理,实际是错的:
**剥掉一个多行块注释本来就必然减少行数**。app.js 里有一堆
十几行的说明性块注释,剥完少了 127 行(13370 → 13243)完全正常。
拿这条当判据,要么把对的实现判成错,要么被人调阈值调到失效
—— 后者就是恒绿的诞生过程。
真正能抓「误吃代码」的是这两条:
1) 剥完仍是合法 JS(由 devtools/check-jsstrip.js 交给 V8 验)
2) 剥掉的东西里不含任何代码标识符(下面按 token 逐个对照数量)
"""
stripped = js_code_only(src)
for fn in ('function ', '=>'):
assert fn in stripped, '%s: 剥注释后连 %r 都没了' % (label, fn)
return stripped
TRAPS = [
# 名字, 源码片段。片段里每段都有一个 `realN` 标记,剥完必须都在。
('URL 里的双斜杠',
'const u = "http://1.2.3.4:8080/x";\nconst real1 = 1;'),
('通配路径里的注释起止',
'const p = "/var/log/d/*.log";\nconst real2 = 2;'),
('字符串里字面写注释起止',
'const s = "/* not a comment */";\nconst real3 = 3;'),
('模板串里 URL',
'const t = `${1 + 1}`;\nconst real4 = 4;'),
('模板串里通配路径',
'const t2 = `/var/log/drouter/*.jsonl | `;\nconst real5 = 5;'),
('真注释里带注释终止符',
'// 这里说一对斜杠星号会被误认\nconst real6 = 6;'),
('正则字面量',
'const r = /^https?:\\/\\//i;\nconst real7 = 7;'),
('除法不是正则',
'const q = 10 / 2 / 1;\nconst real8 = 8;'),
('模板串里嵌套单引号串',
'const t3 = `${"a\'b".length}`;\nconst real9 = 9;'),
('模板串里嵌套模板串',
'const t4 = `${`x${1}y`}`;\nconst real10 = 10;'),
('单引号里含双引号',
'const s5 = \'he said "hi"\';\nconst real11 = 11;'),
('转义引号',
'const s6 = "a\\"b";\nconst real12 = 12;'),
]
REAL_CMTS = [
'// 行注释\nconst a = 1;',
'/* 块注释 */\nconst b = 2;',
'/*\n多行\n块注释\n*/\nconst c = 3;',
]
# ⚠️ 分隔线必须是**剥注释器不可能动**的东西。
# 第一版我拿 `/*<>*/` 当分隔线 —— 那是块注释,jsstrip 正确地
# 把它剥了,于是 split 切不开、12 个陷阱全判失败。差点把「设计错误」
# 误报成「实现有 bug」去改已经正确的代码。
# 现在用 `@@@` 这种裸标识符:JS 里不可能出现,字符串/模板串/正则里
# 也不含(下面每段都断言过了),所以它是绝对中性的分隔符。
SEP = '\n@@@DR_SPLIT@@@\n'
SPLIT_TOKEN = '@@@DR_SPLIT@@@'
def selftest(verbose=True):
"""全部自检。返回 (通过数, 失败列表)。"""
import os
import sys
ok = 0
bad = []
def c(desc, cond, extra=''):
nonlocal ok
if cond:
ok += 1
if verbose:
print('[OK] %s' % desc)
else:
bad.append(desc)
print('[NG] %s' % desc)
if extra != '':
for ln in str(extra).splitlines()[:4]:
print(' %s' % ln)
# --- 1) 陷阱:剥注释不能吃掉真代码 ---
bundle = SEP.join(x[1] for x in TRAPS)
parts = js_code_only(bundle).split(SPLIT_TOKEN)
if len(parts) != len(TRAPS):
c('陷阱分段数正确(%d)' % len(TRAPS), False,
'实际 %d 段 —— 说明分隔线被误当注释吃掉了' % len(parts))
for i, (name, snippet) in enumerate(TRAPS):
marks = re.findall(r'real\d+', snippet)
got = parts[i] if i < len(parts) else ''
lost = [m for m in marks if m not in got]
c('陷阱存活:%s(%d 个真代码标记全在)' % (name, len(marks)),
not lost, '丢了:%s' % ','.join(lost))
# --- 2) 真注释必须被剥掉 ---
cparts = js_code_only(SEP.join(REAL_CMTS)).split(SPLIT_TOKEN)
for i, s in enumerate(REAL_CMTS):
got = cparts[i] if i < len(cparts) else ''
c('真注释被剥掉:%s' % s.split('\n')[0][:18], '注释' not in got,
got.strip()[:80])
# --- 3) 对真实 app.js 做量度 ---
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
s = open(os.path.join(root, 'web', 'app.js'), encoding='utf-8').read()
out = js_code_only_selfcheck(s)
if verbose:
print('app.js 剥注释:%d → %d 字符,%d → %d 行'
% (len(s), len(out), s.count('\n'), out.count('\n')))
for tok in ('/api/snapshot', 'data-lock=', 'data-save-tag=',
'/api/snapshot/protect', '/api/snapshot/note',
"api('/api/snapshot'", "$('#sy-protect')"):
c('app.js 剥后仍含 %s' % tok, out.count(tok) >= 1)
for tok in ('吃注释', '判据', '假绿', 'CSS 注释'):
c('app.js 剥后无注释残留 %s' % tok, out.count(tok) == 0)
# 模板串里的 URL / 通配路径必须原样保留
for tok in ('http://169.254.0.1:8888/', '/var/log/drouter/*.jsonl'):
c('模板串内 %s 原样保留' % tok, out.count(tok) >= 1)
# 剥后块注释必须配平(半开的注释是误吃信号)
c('剥后块注释起止仍配平',
out.count('/*') == out.count('*/'),
'开 %d / 闭 %d' % (out.count('/*'), out.count('*/')))
# --- 4) token 数量逐个对照 ---
# ⚠️ 判据是「剥后**只多不少**」,不是「完全相等」。
# 我第一版写 `数量不变`,结果 4 条全红(let 258→257、await 280→276、
# innerHTML 377→375)。查下来那些 token 出现在**注释里** ——
# 比如「这里之前用了 innerHTML 会被 X」这种讲来历的话。
# 剥注释本来就会连带剥掉注释里提到的标识符,所以数量**必然**减少。
#
# 反过来,「只多不少」这条恰好能抓真正的误吃:一旦剥到真代码,
# 某个 token 会**变多**(原文被切开重组)或者大段消失。
# 真正严格的那道判据在 devtools/check-jsstrip.js:把产物交给 V8
# 解析,剥坏了一定是语法错误。
for tok in ('function', 'const', 'let', 'async', 'await',
'innerHTML', 'addEventListener', 'querySelector',
'dataset.', 'className'):
c('剥后 token 数量只多不少:%s' % tok,
out.count(tok) <= s.count(tok),
'原 %d → 剥后 %d(变多了 = 原文被切开了)' % (s.count(tok), out.count(tok)))
# 关键标识符一个都不能少(这些绝不该只出现在注释里)
for tok in ("api('/api/snapshot'", 'data-lock=', 'data-save-tag=',
'/api/snapshot/protect', '/api/snapshot/note'):
c('剥后关键调用完好:%s' % tok, out.count(tok) >= 1)
return ok, bad
if __name__ == '__main__':
import os
import re
import sys
_ok, _bad = selftest()
# 额外产出:把剥后的 app.js 落盘,供 devtools/check-jsstrip.js
# 交给 V8 解析。node 这边自己跑不通 python(Windows EBUSY),
# 所以由python 侧备料、node 侧验收。
try:
_here = os.path.dirname(os.path.abspath(__file__))
_root = os.path.dirname(_here)
_out = os.path.join(_here, '.jsstrip-appjs.out')
with open(_out, 'w', encoding='utf-8') as f:
f.write(js_code_only(
open(os.path.join(_root, 'web', 'app.js'), encoding='utf-8').read()))
print('已写出 %s(供 node 侧交叉验证)' % os.path.relpath(_out, _root))
except Exception as _e:
print('写产物失败:%s' % _e)
_bad.append('写 jsstrip 产物失败')
print()
print('=' * 60)
print('通过 %d 项,失败 %d 项' % (_ok, len(_bad)))
for d in _bad:
print(' · %s' % d)
print('=' * 60)
sys.exit(1 if _bad else 0)