@xiaohuo123212/elench 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/NOTICE +53 -0
- package/README.md +298 -2
- package/bin/elench.js +69 -0
- package/package.json +37 -4
- package/scripts/clean-pycache.js +37 -0
- package/scripts/npm_smoke.js +88 -0
- package/src/README-/345/205/274/345/256/271/345/261/202.md +26 -0
- package/src/ast_util.py +111 -0
- package/src/bench.py +376 -0
- package/src/borrow.py +282 -0
- package/src/bound.py +13 -0
- package/src/cfg.py +219 -0
- package/src/chart.py +209 -0
- package/src/check_docs.py +156 -0
- package/src/checker.py +2473 -0
- package/src/contract_metric.py +87 -0
- package/src/cost_check.py +13 -0
- package/src/cost_diff.py +533 -0
- package/src/cost_domain/__init__.py +32 -0
- package/src/cost_domain/bound.py +739 -0
- package/src/cost_domain/cost_check.py +394 -0
- package/src/cost_domain/cost_interp.py +1998 -0
- package/src/cost_domain/symbolic.py +554 -0
- package/src/cost_explain.py +503 -0
- package/src/cost_interp.py +13 -0
- package/src/cxx_map.py +184 -0
- package/src/dispatch.py +314 -0
- package/src/dsh.py +3980 -0
- package/src/dup_scan.py +180 -0
- package/src/elench.py +15 -0
- package/src/emit.py +56 -0
- package/src/ffi_c.py +70 -0
- package/src/fmt.py +115 -0
- package/src/formatter.py +596 -0
- package/src/grammar.py +272 -0
- package/src/guarantees.py +116 -0
- package/src/highlight.py +156 -0
- package/src/interp.py +2119 -0
- package/src/isolated.py +161 -0
- package/src/jsonval.py +236 -0
- package/src/kappa_vs_measured.py +347 -0
- package/src/kinds.py +122 -0
- package/src/lexer.py +559 -0
- package/src/migrate_docs.py +115 -0
- package/src/otel.py +579 -0
- package/src/otel_dashboard.py +218 -0
- package/src/otel_export.py +380 -0
- package/src/otel_local_sink.py +141 -0
- package/src/otel_view.py +156 -0
- package/src/parser.py +1909 -0
- package/src/parser.py.bak +1781 -0
- package/src/policy.py +271 -0
- package/src/rename_ch.py +81 -0
- package/src/requires.py +242 -0
- package/src/segment_plan.py +150 -0
- package/src/shape.py +130 -0
- package/src/stdlib.py +1856 -0
- package/src/stdlib.py.bak +1751 -0
- package/src/symbolic.py +13 -0
- package/src/sync_docs.py +225 -0
- package/src/test_checker.py.bak +443 -0
- package/src/test_formatter.py.bak +201 -0
- package/src/test_invalid.py.bak +342 -0
- package/src/test_parser.py.bak +423 -0
- package/src/typecheck.py +865 -0
- package/src/typecheck.py.bak +741 -0
- package/src/typenames.py +56 -0
- package/src/walk.py +197 -0
package/src/chart.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2026 Elench contributors
|
|
4
|
+
"""chart.py —— 极简折线图(纯 SVG,无依赖)。
|
|
5
|
+
|
|
6
|
+
**为什么自己写**:`otel.to_svg` 是"承诺 vs 实测"那张固定双线图的渲染器,
|
|
7
|
+
它假设了 `rows` 的字段结构。紧度图要画的是**比值**(y 轴线性、两条线),
|
|
8
|
+
复用会逼着我去迁就它的数据结构;画图这件事本来就只有 80 行。
|
|
9
|
+
|
|
10
|
+
约定:
|
|
11
|
+
- x 用对数刻度(规模天然是 2 的幂);
|
|
12
|
+
- y 可选线性(比值)或对数(操作数);
|
|
13
|
+
- 每个系列一条折线 + 图例,**不叠在一张图上讲两件事**——调用方负责拆图。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import List, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
W, H = 820, 480
|
|
21
|
+
L, R, T, B = 92, 210, 58, 64
|
|
22
|
+
PW, PH = W - L - R, H - T - B
|
|
23
|
+
|
|
24
|
+
COLORS = ["#1f5fa8", "#c0392b", "#2e7d32", "#8e44ad", "#d35400"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _esc(s: str) -> str:
|
|
28
|
+
return (s.replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def line_chart(series: Sequence[Tuple[str, Sequence[Tuple[float, float]]]],
|
|
32
|
+
title: str, ylabel: str, log_x: bool = True,
|
|
33
|
+
log_y: bool = False, hline: float = None,
|
|
34
|
+
annotate: bool = False, vline: float = None,
|
|
35
|
+
note: str = None, direct_labels: bool = False) -> str:
|
|
36
|
+
"""series = [(名字, [(x, y), ...]), ...] → SVG 文本。"""
|
|
37
|
+
pts = [(x, y) for _n, xs in series for x, y in xs if y == y and y > 0]
|
|
38
|
+
if not pts:
|
|
39
|
+
# **空面板也要有标题**:否则读者只看到一块白板,分不清"没数据"还是"画砸了"
|
|
40
|
+
return (f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
|
|
41
|
+
f'font-family="system-ui,Segoe UI,sans-serif" font-size="13">'
|
|
42
|
+
f'<rect width="{W}" height="{H}" fill="#ffffff"/>'
|
|
43
|
+
f'<text x="{L}" y="30" font-size="17" font-weight="600" fill="#1a1a1a">'
|
|
44
|
+
f'{_esc(title)}</text>'
|
|
45
|
+
f'<text x="{L}" y="{T + 40}" fill="#777">'
|
|
46
|
+
f'{_esc(note or "(没有可画的数据)")}</text></svg>')
|
|
47
|
+
xs_all = [p[0] for p in pts]
|
|
48
|
+
ys_all = [p[1] for p in pts]
|
|
49
|
+
x0, x1 = min(xs_all), max(xs_all)
|
|
50
|
+
if x0 == x1:
|
|
51
|
+
x0, x1 = x0 / 2, x1 * 2
|
|
52
|
+
y0, y1 = min(ys_all), max(ys_all)
|
|
53
|
+
if hline is not None and hline > 0:
|
|
54
|
+
y0, y1 = min(y0, hline), max(y1, hline)
|
|
55
|
+
y0 = y0 * 0.9 if y0 > 0 else 0
|
|
56
|
+
y1 = y1 * 1.1
|
|
57
|
+
|
|
58
|
+
def sx(x):
|
|
59
|
+
if log_x and x > 0:
|
|
60
|
+
return L + PW * (math.log(x / x0) / math.log(x1 / x0))
|
|
61
|
+
return L + PW * ((x - x0) / (x1 - x0) if x1 > x0 else 0.5)
|
|
62
|
+
|
|
63
|
+
def sy(y):
|
|
64
|
+
if log_y:
|
|
65
|
+
a, b = math.log(y0), math.log(y1)
|
|
66
|
+
return T + PH * (1 - (math.log(y) - a) / (b - a if b > a else 1))
|
|
67
|
+
return T + PH * (1 - (y - y0) / (y1 - y0 if y1 > y0 else 1))
|
|
68
|
+
|
|
69
|
+
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
70
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
|
|
71
|
+
f'font-family="system-ui,Segoe UI,sans-serif" font-size="13">',
|
|
72
|
+
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
|
|
73
|
+
f'<text x="{L}" y="30" font-size="17" font-weight="600" fill="#1a1a1a">'
|
|
74
|
+
f'{_esc(title)}</text>',
|
|
75
|
+
f'<text x="18" y="{T + PH / 2}" fill="#444" transform="rotate(-90 18 '
|
|
76
|
+
f'{T + PH / 2})" text-anchor="middle">{_esc(ylabel)}</text>',
|
|
77
|
+
f'<rect x="{L}" y="{T}" width="{PW}" height="{PH}" fill="none" '
|
|
78
|
+
f'stroke="#bbb"/>']
|
|
79
|
+
|
|
80
|
+
# x 轴刻度(点很多时抽稀标签,避免叠字;折线仍然逐点画)
|
|
81
|
+
_xs_sorted = sorted(set(xs_all))
|
|
82
|
+
_step = max(1, len(_xs_sorted) // 8) if len(_xs_sorted) > 10 else 1
|
|
83
|
+
for _i, x in enumerate(_xs_sorted):
|
|
84
|
+
px = sx(x)
|
|
85
|
+
out.append(f'<line x1="{px:.1f}" y1="{T}" x2="{px:.1f}" y2="{T + PH}" '
|
|
86
|
+
f'stroke="#eee"/>')
|
|
87
|
+
if _i % _step == 0 or _i == len(_xs_sorted) - 1:
|
|
88
|
+
out.append(f'<text x="{px:.1f}" y="{T + PH + 20}" text-anchor="middle" '
|
|
89
|
+
f'fill="#555">{x:g}</text>')
|
|
90
|
+
# y 轴刻度:对数轴用 1/10/100/… 这样的整十点;线性轴平均分 4 段
|
|
91
|
+
if log_y:
|
|
92
|
+
_ticks = []
|
|
93
|
+
p = 1.0
|
|
94
|
+
while p < y1 * 10:
|
|
95
|
+
if y0 * 0.9 <= p <= y1 * 1.1:
|
|
96
|
+
_ticks.append(p)
|
|
97
|
+
p *= 10
|
|
98
|
+
yticks = _ticks or [y0, y1]
|
|
99
|
+
else:
|
|
100
|
+
yticks = [y0 + (y1 - y0) * i / 4 for i in range(5)]
|
|
101
|
+
for y in yticks:
|
|
102
|
+
py = sy(y)
|
|
103
|
+
out.append(f'<line x1="{L}" y1="{py:.1f}" x2="{L + PW}" y2="{py:.1f}" '
|
|
104
|
+
f'stroke="#f3f3f3"/>')
|
|
105
|
+
_lab = f"{y:.0f}" if y >= 1 else f"{y:.2f}"
|
|
106
|
+
out.append(f'<text x="{L - 10}" y="{py + 4:.1f}" text-anchor="end" '
|
|
107
|
+
f'fill="#555">{_lab}</text>')
|
|
108
|
+
# 竖线(例如"常数项不再主导"的 n0)
|
|
109
|
+
if vline is not None and vline > 0:
|
|
110
|
+
_vx = sx(vline)
|
|
111
|
+
out.append(f'<line x1="{_vx:.1f}" y1="{T}" x2="{_vx:.1f}" y2="{T + PH}" '
|
|
112
|
+
f'stroke="#999" stroke-dasharray="6 5"/>')
|
|
113
|
+
out.append(f'<text x="{_vx + 4:.1f}" y="{T + 14}" fill="#777">n0={vline:.0f}</text>')
|
|
114
|
+
|
|
115
|
+
# 参照线(例如比值 = 1)
|
|
116
|
+
if hline is not None and hline > 0:
|
|
117
|
+
py = sy(hline)
|
|
118
|
+
out.append(f'<line x1="{L}" y1="{py:.1f}" x2="{L + PW}" y2="{py:.1f}" '
|
|
119
|
+
f'stroke="#999" stroke-dasharray="6 5"/>')
|
|
120
|
+
out.append(f'<text x="{L + PW - 6}" y="{py - 6:.1f}" text-anchor="end" '
|
|
121
|
+
f'fill="#777">{hline:g}</text>')
|
|
122
|
+
|
|
123
|
+
_seen = set()
|
|
124
|
+
_dup = []
|
|
125
|
+
_ends = [] # (末点 x, 末点 y, 名字, 颜色)
|
|
126
|
+
for i, (name, data) in enumerate(series):
|
|
127
|
+
_sig = tuple((round(x, 6), round(y, 6)) for x, y in data if y == y)
|
|
128
|
+
if _sig in _seen:
|
|
129
|
+
_dup.append(name)
|
|
130
|
+
continue
|
|
131
|
+
_seen.add(_sig)
|
|
132
|
+
color = COLORS[i % len(COLORS)]
|
|
133
|
+
d = " ".join(f"{sx(x):.1f},{sy(y):.1f}" for x, y in data if y == y and y > 0)
|
|
134
|
+
if not d:
|
|
135
|
+
continue
|
|
136
|
+
out.append(f'<polyline points="{d}" fill="none" stroke="{color}" '
|
|
137
|
+
f'stroke-width="2.4"/>')
|
|
138
|
+
for x, y in data:
|
|
139
|
+
if y == y and y > 0:
|
|
140
|
+
out.append(f'<circle cx="{sx(x):.1f}" cy="{sy(y):.1f}" r="3" '
|
|
141
|
+
f'fill="{color}"/>')
|
|
142
|
+
if annotate:
|
|
143
|
+
out.append(f'<text x="{sx(x) + 6:.1f}" y="{sy(y) - 6:.1f}" '
|
|
144
|
+
f'font-size="11" fill="{color}">{y:.2f}</text>')
|
|
145
|
+
_pts_ok = [(x, y) for x, y in data if y == y and y > 0]
|
|
146
|
+
if _pts_ok:
|
|
147
|
+
_ends.append((sx(_pts_ok[-1][0]), sy(_pts_ok[-1][1]), name, color))
|
|
148
|
+
if not direct_labels:
|
|
149
|
+
ly = T + 18 + i * 24
|
|
150
|
+
out.append(f'<line x1="{L + PW + 18}" y1="{ly}" x2="{L + PW + 52}" '
|
|
151
|
+
f'y2="{ly}" stroke="{color}" stroke-width="2.4"/>')
|
|
152
|
+
out.append(f'<text x="{L + PW + 60}" y="{ly + 5}" fill="#333">'
|
|
153
|
+
f'{_esc(name)}</text>')
|
|
154
|
+
|
|
155
|
+
if direct_labels and _ends:
|
|
156
|
+
# 线尾直接写名字:比在图例里对照更直观(眼睛不用来回跳)
|
|
157
|
+
_ends.sort(key=lambda z: z[1])
|
|
158
|
+
_prev = None
|
|
159
|
+
for _ex, _ey, _nm, _col in _ends:
|
|
160
|
+
_ly2 = _ey
|
|
161
|
+
if _prev is not None and _ly2 - _prev < 15:
|
|
162
|
+
_ly2 = _prev + 15
|
|
163
|
+
_prev = _ly2
|
|
164
|
+
out.append(f'<text x="{_ex + 8:.1f}" y="{_ly2 + 4:.1f}" fill="{_col}" '
|
|
165
|
+
f'font-size="12">{_esc(_nm)}</text>')
|
|
166
|
+
|
|
167
|
+
if _dup:
|
|
168
|
+
out.append(f'<text x="{L + PW + 60}" y="{T + 18 + len(series) * 24}" '
|
|
169
|
+
f'fill="#777">({_esc("、".join(_dup))}与上线重合)</text>')
|
|
170
|
+
out.append(f'<text x="{L}" y="{H - 18}" fill="#777">x 轴:输入规模'
|
|
171
|
+
f' y 轴:{_esc(ylabel)}</text>')
|
|
172
|
+
out.append("</svg>")
|
|
173
|
+
return "\n".join(out)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def stack_svgs(svgs, titles=None, columns: int = 1) -> str:
|
|
177
|
+
"""把多张图**垂直堆进同一个文件**(每张仍是独立坐标系)。
|
|
178
|
+
|
|
179
|
+
SVG 允许嵌套 `<svg>`,所以不需要把各图的坐标系揉在一起——
|
|
180
|
+
「一个文件」和「一张图」是两件事:这个函数只合并文件,不合并语义。
|
|
181
|
+
"""
|
|
182
|
+
if not svgs:
|
|
183
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
|
|
184
|
+
import re as _re
|
|
185
|
+
total_h = 0
|
|
186
|
+
parts = []
|
|
187
|
+
cols = max(1, int(columns))
|
|
188
|
+
for svg in svgs:
|
|
189
|
+
body = _re.sub(r"^<\?xml[^>]*\?>\s*", "", svg)
|
|
190
|
+
m = _re.search(r'width="(\d+)" height="(\d+)"', body)
|
|
191
|
+
w, h = (int(m.group(1)), int(m.group(2))) if m else (W, H)
|
|
192
|
+
body = body.replace('<svg xmlns="http://www.w3.org/2000/svg"', '<svg', 1)
|
|
193
|
+
parts.append((w, h, body))
|
|
194
|
+
total_h += h
|
|
195
|
+
import math as _math
|
|
196
|
+
_rows = _math.ceil(len(parts) / cols)
|
|
197
|
+
_wcell = max(p[0] for p in parts)
|
|
198
|
+
_hcell = max(p[1] for p in parts)
|
|
199
|
+
_W, _H = _wcell * min(cols, len(parts)), _hcell * _rows
|
|
200
|
+
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
201
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{_W}" '
|
|
202
|
+
f'height="{_H}">',
|
|
203
|
+
f'<rect width="{_W}" height="{_H}" fill="#fff"/>']
|
|
204
|
+
for _i, (_w, _h, body) in enumerate(parts):
|
|
205
|
+
_cx = (_i % cols) * _wcell
|
|
206
|
+
_cy = (_i // cols) * _hcell
|
|
207
|
+
out.append(f'<g transform="translate({_cx},{_cy})">{body}</g>')
|
|
208
|
+
out.append("</svg>")
|
|
209
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2026 Elench contributors
|
|
4
|
+
|
|
5
|
+
# -*- coding: utf-8 -*-
|
|
6
|
+
"""
|
|
7
|
+
check_docs.py —— 文档语法漂移检查
|
|
8
|
+
|
|
9
|
+
为什么需要它
|
|
10
|
+
------------
|
|
11
|
+
规格改了语法,代码和样例会立刻报错,**文档不会**——
|
|
12
|
+
文档里的代码示例只是文本,改错了没人知道。这个脚本把"文档里的语言"
|
|
13
|
+
当成也编译一遍:凡是出现旧写法的行,全部列出来。
|
|
14
|
+
|
|
15
|
+
用法
|
|
16
|
+
----
|
|
17
|
+
python check_docs.py # 全仓扫描,只报告
|
|
18
|
+
python check_docs.py --fail # 有命中就以非零退出(给 CI / 提交前跑)
|
|
19
|
+
|
|
20
|
+
豁免规则
|
|
21
|
+
--------
|
|
22
|
+
有些命中是**故意的**:语法变更历史里的对照、本文件的规则说明、标准文件里的反例。
|
|
23
|
+
这些行必须带下面的标记之一才放行,否则一律算漂移:
|
|
24
|
+
|
|
25
|
+
〔旧〕 〔反例〕 〔已下线〕 〔历史〕
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import io
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
36
|
+
ROOT = os.path.abspath(os.path.join(HERE, ".."))
|
|
37
|
+
|
|
38
|
+
EXEMPT_MARKERS = ("〔旧〕", "〔反例〕", "〔已下线〕", "〔历史〕")
|
|
39
|
+
|
|
40
|
+
# 这些围栏标签里的代码不是本语言,跳过
|
|
41
|
+
OTHER_LANGS = {"rust", "cpp", "c++", "c", "python", "py", "dafny",
|
|
42
|
+
"js", "javascript", "java", "go", "ts", "typescript", "sh"}
|
|
43
|
+
|
|
44
|
+
# (名字, 正则, 说明)
|
|
45
|
+
RULES = [
|
|
46
|
+
("旧 kernel 声明", re.compile(r"^\s*kernel\s+\w+\s*\("),
|
|
47
|
+
"v0.66 起没有独立 kernel 声明;候选由 spec 里的 over 声明"),
|
|
48
|
+
("旧 pending 子句", re.compile(r"pending\s+\{"),
|
|
49
|
+
"v0.66 起 pending 是 over 里的裸词标记"),
|
|
50
|
+
("choose 缺签名", re.compile(r"choose\s+\w+\s*\{"),
|
|
51
|
+
"choose MUST 带参数表与返回类型:choose L(params) -> R {"),
|
|
52
|
+
("旧 let 绑定", re.compile(r"(?<![\w.`])let\s+[a-z_][a-z0-9_]*\s*[:=]"),
|
|
53
|
+
"v0.65 起用 val"),
|
|
54
|
+
("旧 effects pure", re.compile(r"effects\s+pure\s*;"),
|
|
55
|
+
"v0.62 起写 effects {};"),
|
|
56
|
+
("旧 objective 名", re.compile(r"minimize_(expected_time|p99|peak_mem|code_size)"),
|
|
57
|
+
"v0.65 起用 time / p99 / mem / size"),
|
|
58
|
+
("旧 kernel fn", re.compile(r"kernel\s+fn\s"),
|
|
59
|
+
"v0.62 起去掉 fn;v0.66 起 kernel 关键字整体下线"),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# 结构类检查:表格头后面必须紧跟分隔行
|
|
63
|
+
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
|
|
64
|
+
TABLE_SEP = re.compile(r"^\s*\|[:\s|-]+\|\s*$")
|
|
65
|
+
|
|
66
|
+
# 整份文件跳过
|
|
67
|
+
SKIP_FILES = {"_标准-语法变更-v0.66.md"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scan():
|
|
71
|
+
hits = []
|
|
72
|
+
for dirpath, _, files in os.walk(ROOT):
|
|
73
|
+
# 归档目录是历史快照,本来就该保留旧写法,不参与"语法漂移"检查
|
|
74
|
+
if any(seg in dirpath for seg in (".git", "_internal", "node_modules",
|
|
75
|
+
".perf", os.sep + "归档")):
|
|
76
|
+
continue
|
|
77
|
+
for f in files:
|
|
78
|
+
if not f.endswith(".md") or f in SKIP_FILES:
|
|
79
|
+
continue
|
|
80
|
+
p = os.path.join(dirpath, f)
|
|
81
|
+
try:
|
|
82
|
+
text = io.open(p, encoding="utf-8").read()
|
|
83
|
+
except UnicodeDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
rel = os.path.relpath(p, ROOT)
|
|
86
|
+
fence_tag = None
|
|
87
|
+
for no, line in enumerate(text.split("\n"), 1):
|
|
88
|
+
s = line.strip()
|
|
89
|
+
if s.startswith("```"):
|
|
90
|
+
if fence_tag is None:
|
|
91
|
+
lang = s[3:].strip().lower()
|
|
92
|
+
fence_tag = "" if lang in OTHER_LANGS else None
|
|
93
|
+
if lang and lang not in OTHER_LANGS:
|
|
94
|
+
fence_tag = None
|
|
95
|
+
continue
|
|
96
|
+
else:
|
|
97
|
+
fence_tag = None
|
|
98
|
+
continue
|
|
99
|
+
if fence_tag == "":
|
|
100
|
+
continue # 外语代码块,不按本语言规则查
|
|
101
|
+
if any(m in line for m in EXEMPT_MARKERS):
|
|
102
|
+
continue
|
|
103
|
+
for name, rx, why in RULES:
|
|
104
|
+
if rx.search(line):
|
|
105
|
+
hits.append((rel, no, name, line.strip(), why))
|
|
106
|
+
|
|
107
|
+
# 结构:表格头必须紧跟分隔行,否则表格会渲染错乱
|
|
108
|
+
lines = text.split("\n")
|
|
109
|
+
fence = False
|
|
110
|
+
for no in range(len(lines) - 1):
|
|
111
|
+
cur, nxt = lines[no], lines[no + 1]
|
|
112
|
+
if cur.strip().startswith("```"):
|
|
113
|
+
fence = not fence
|
|
114
|
+
continue
|
|
115
|
+
if fence:
|
|
116
|
+
continue
|
|
117
|
+
if not TABLE_ROW.match(cur) or TABLE_SEP.match(cur):
|
|
118
|
+
continue
|
|
119
|
+
# 只有"表头"才要求分隔行:表头 = 上一行不是表格行
|
|
120
|
+
if no > 0 and TABLE_ROW.match(lines[no - 1]):
|
|
121
|
+
continue
|
|
122
|
+
if TABLE_SEP.match(nxt):
|
|
123
|
+
continue
|
|
124
|
+
if any(m in cur for m in EXEMPT_MARKERS):
|
|
125
|
+
continue
|
|
126
|
+
hits.append((rel, no + 1, "表格头缺分隔行",
|
|
127
|
+
cur.strip()[:80],
|
|
128
|
+
"表格头后面必须紧跟 |---| 分隔行(否则表格会散架)"))
|
|
129
|
+
return hits
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv):
|
|
133
|
+
hits = scan()
|
|
134
|
+
if not hits:
|
|
135
|
+
print("✓ 文档没有语法漂移")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
by_file = {}
|
|
139
|
+
for rel, no, name, line, why in hits:
|
|
140
|
+
by_file.setdefault(rel, []).append((no, name, line, why))
|
|
141
|
+
|
|
142
|
+
for rel in sorted(by_file):
|
|
143
|
+
print(f"\n{rel} ({len(by_file[rel])} 处)")
|
|
144
|
+
for no, name, line, why in by_file[rel][:20]:
|
|
145
|
+
shown = line if len(line) <= 90 else line[:87] + "..."
|
|
146
|
+
print(f" 行{no:<5} [{name}] {shown}")
|
|
147
|
+
print(f" → {by_file[rel][0][3]}")
|
|
148
|
+
|
|
149
|
+
total = len(hits)
|
|
150
|
+
print(f"\n共 {total} 处,分布在 {len(by_file)} 个文件")
|
|
151
|
+
print("若某处是故意的对照/反例,在该行加标记:" + " ".join(EXEMPT_MARKERS))
|
|
152
|
+
return 1 if ("--fail" in argv and total) else 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
sys.exit(main(sys.argv[1:]))
|