564 lines
20 KiB
Python
564 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""Lint TSL codegen API Markdown against the house standard.
|
||
|
||
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
|
||
class or unit members. Rules split into hard errors (CI-blocking) and soft
|
||
warnings (style convergence over the ~12k existing entries).
|
||
|
||
Hard errors include incomplete descriptions/metadata/parameter tables and
|
||
invalid class/unit API headings, visibility, or child-heading levels.
|
||
|
||
Soft warnings:
|
||
- optional-parameter wording not starting with `可选。`
|
||
- malformed / empty `<!-- tags: ... -->` line
|
||
|
||
Exit status: 1 if any error (or, with --strict, any warning); else 0.
|
||
|
||
Usage:
|
||
python lint.py --file path/to/page.md
|
||
python lint.py --dir path/to/codegen-dir
|
||
python lint.py --dir path/to/codegen-dir --strict
|
||
"""
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
|
||
|
||
RETURN_LINE_RE = re.compile(r"^返回[::]")
|
||
RETURN_RE = re.compile(r"^返回[::]\s*\S")
|
||
TYPE_LINE_RE = re.compile(r"^类型[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||
TYPE_RE = re.compile(r"^类型[::]\s*\S")
|
||
VALUE_LINE_RE = re.compile(r"^值[::]")
|
||
VALUE_RE = re.compile(r"^值[::]\s*(?:`[^`]+`|[^`\s])")
|
||
ACCESS_RE = re.compile(r"^访问[::]\s*(read|write|read\s*/\s*write)\s*$")
|
||
VISIBILITY_RE = re.compile(r"^可见性[::]\s*`?(public|protected|private)`?\s*$")
|
||
BASE_RE = re.compile(r"^父类[::]")
|
||
MODIFIERS_RE = re.compile(r"^修饰符[::]")
|
||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
|
||
FENCE_RE = re.compile(r"^(```|~~~)")
|
||
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$")
|
||
VALUE_HEADING_RE = re.compile(r"^`.+?`\s+取值$")
|
||
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
|
||
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
|
||
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
||
SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
||
|
||
PARAM_HEADER = ["参数", "类型", "说明"]
|
||
|
||
|
||
def scan_body(lines, start, end):
|
||
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
|
||
signature line). Fence delimiter lines are marked in_fence so callers
|
||
skip both the fences and their contents."""
|
||
body = []
|
||
in_fence = False
|
||
for idx in range(start + 1, end):
|
||
raw = lines[idx]
|
||
if FENCE_RE.match(raw.strip()):
|
||
body.append((idx, raw, True))
|
||
in_fence = not in_fence
|
||
continue
|
||
body.append((idx, raw, in_fence))
|
||
return body
|
||
|
||
|
||
def has_params(sig):
|
||
"""True if the signature's parentheses hold anything (`...` counts)."""
|
||
left = sig.find("(")
|
||
right = sig.rfind(")")
|
||
if left == -1 or right == -1 or right < left:
|
||
return False
|
||
return bool(sig[left + 1:right].strip())
|
||
|
||
|
||
def signature_params(sig):
|
||
"""Return the parameter names carried by a normalized API signature."""
|
||
left = sig.find("(")
|
||
right = sig.rfind(")")
|
||
if left == -1 or right == -1 or right < left:
|
||
return []
|
||
raw = sig[left + 1:right].strip()
|
||
return [item.strip() for item in raw.split(",")] if raw else []
|
||
|
||
|
||
def split_row(text):
|
||
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
|
||
parts = CELL_SPLIT_RE.split(text.strip())
|
||
if parts and parts[0].strip() == "":
|
||
parts = parts[1:]
|
||
if parts and parts[-1].strip() == "":
|
||
parts = parts[:-1]
|
||
return [p.strip() for p in parts]
|
||
|
||
|
||
def is_separator_row(cells):
|
||
return bool(cells) and all(SEP_CELL_RE.match(c) for c in cells)
|
||
|
||
|
||
def find_table(body):
|
||
"""Return (header_lineno, header_cells, [(lineno, cells)] data_rows) for the
|
||
first pipe table in the body, or None. Skips fenced content."""
|
||
collected = []
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
stripped = raw.strip()
|
||
if stripped.startswith("|"):
|
||
collected.append((lineno, stripped))
|
||
elif collected:
|
||
break # blank/prose line ends the table
|
||
if not collected:
|
||
return None
|
||
header_lineno, header_text = collected[0]
|
||
header_cells = split_row(header_text)
|
||
data = []
|
||
for lineno, text in collected[1:]:
|
||
cells = split_row(text)
|
||
if is_separator_row(cells):
|
||
continue
|
||
data.append((lineno, cells))
|
||
return header_lineno, header_cells, data
|
||
|
||
|
||
def find_description(body):
|
||
"""Return (found, lineno_of_offending_line). found is True when the first
|
||
content line after the signature is prose. When False the lineno points at
|
||
the table/heading/return line that showed up where a description belongs
|
||
(or None if the entry is empty)."""
|
||
for lineno, raw, in_fence in body:
|
||
stripped = raw.strip()
|
||
if not stripped or in_fence:
|
||
continue
|
||
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||
continue
|
||
if (
|
||
stripped.startswith("<!--")
|
||
or stripped.startswith("声明:")
|
||
or stripped.startswith("|")
|
||
or stripped.startswith("#")
|
||
or RETURN_LINE_RE.match(stripped)
|
||
or TYPE_LINE_RE.match(stripped)
|
||
or VALUE_LINE_RE.match(stripped)
|
||
or ACCESS_RE.match(stripped)
|
||
or VISIBILITY_RE.match(stripped)
|
||
or BASE_RE.match(stripped)
|
||
or MODIFIERS_RE.match(stripped)
|
||
):
|
||
return False, lineno
|
||
return True, lineno
|
||
return False, None
|
||
|
||
|
||
def check_entry(
|
||
md_display,
|
||
lines,
|
||
start,
|
||
end,
|
||
sig,
|
||
findings,
|
||
*,
|
||
returns_required=True,
|
||
visibility_required=False,
|
||
validate_parameter_rows=False,
|
||
):
|
||
entry_line = start + 1 # 1-based signature line, used for entry-level errors
|
||
body = scan_body(lines, start, end)
|
||
|
||
# description ----------------------------------------------------------
|
||
found, off_lineno = find_description(body)
|
||
if not found:
|
||
line = (off_lineno + 1) if off_lineno is not None else entry_line
|
||
findings.append((md_display, line, "error", "description",
|
||
f"`{sig}` 缺少描述(签名后第一行须为非空描述)"))
|
||
|
||
# return ---------------------------------------------------------------
|
||
has_return = any(
|
||
RETURN_RE.match(raw.strip())
|
||
for _, raw, in_fence in body if not in_fence
|
||
)
|
||
if returns_required and not has_return:
|
||
findings.append((md_display, entry_line, "error", "return",
|
||
f"`{sig}` 缺少 `返回:类型` 行"))
|
||
|
||
visibilities = []
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
match = VISIBILITY_RE.match(raw.strip())
|
||
if match:
|
||
visibilities.append((lineno, match.group(1)))
|
||
if visibility_required and not visibilities:
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
entry_line,
|
||
"error",
|
||
"visibility",
|
||
f"`{sig}` 缺少 `可见性:public|protected` 行",
|
||
)
|
||
)
|
||
for lineno, visibility in visibilities:
|
||
if visibility == "private":
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
|
||
# parameter table ------------------------------------------------------
|
||
table = find_table(body)
|
||
wants_params = has_params(sig)
|
||
if wants_params and table is None:
|
||
findings.append((md_display, entry_line, "error", "param-table",
|
||
f"`{sig}` 有参数但缺少参数表"))
|
||
elif not wants_params and table is not None:
|
||
header_lineno = table[0]
|
||
findings.append((md_display, header_lineno + 1, "error", "param-table",
|
||
f"`{sig}` 无参数却存在参数表"))
|
||
elif table is not None:
|
||
header_lineno, header_cells, data_rows = table
|
||
if header_cells != PARAM_HEADER:
|
||
findings.append((md_display, header_lineno + 1, "error",
|
||
"param-header",
|
||
f"参数表表头须为 {' / '.join(PARAM_HEADER)},"
|
||
f"实为 {' / '.join(header_cells) or '(空)'}"))
|
||
elif validate_parameter_rows:
|
||
actual_names = []
|
||
rows_valid = True
|
||
for lineno, cells in data_rows:
|
||
if len(cells) != 3 or any(not cell.strip() for cell in cells):
|
||
rows_valid = False
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
lineno + 1,
|
||
"error",
|
||
"param-row",
|
||
"参数表每行都必须包含非空的参数名、类型和说明",
|
||
)
|
||
)
|
||
continue
|
||
name_cell = cells[0]
|
||
if name_cell.startswith("`") and name_cell.endswith("`"):
|
||
name_cell = name_cell[1:-1]
|
||
actual_names.append(name_cell.strip())
|
||
expected_names = signature_params(sig)
|
||
if rows_valid and [name.casefold() for name in actual_names] != [
|
||
name.casefold() for name in expected_names
|
||
]:
|
||
findings.append(
|
||
(
|
||
md_display,
|
||
header_lineno + 1,
|
||
"error",
|
||
"param-names",
|
||
f"参数表名称/顺序必须与 `{sig}` 一致",
|
||
)
|
||
)
|
||
# soft: optional-parameter wording
|
||
for lineno, cells in data_rows:
|
||
if len(cells) < 3:
|
||
continue
|
||
desc = cells[2]
|
||
if OPTIONAL_HINT_RE.search(desc) and not desc.startswith("可选。"):
|
||
findings.append((md_display, lineno + 1, "warning", "optional",
|
||
"可选参数说明建议以 `可选。` 开头"))
|
||
|
||
# soft: tags line ------------------------------------------------------
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
m = TAGS_RE.match(raw.strip())
|
||
if m and not m.group(1).split():
|
||
findings.append((md_display, lineno + 1, "warning", "tags",
|
||
"空的 tags 行;填入关键词或删除"))
|
||
|
||
|
||
def check_api_subheadings(md_display, lines, entry, findings):
|
||
"""Enforce the fixed API child-heading levels and labels."""
|
||
heading = entry.heading
|
||
|
||
allowed_titles = set()
|
||
if heading.level == 2 and heading.root_kind == "function":
|
||
allowed_titles.add("示例")
|
||
allow_values = False
|
||
elif heading.kind in {"function", "method"}:
|
||
allowed_titles.add("示例")
|
||
allow_values = True
|
||
elif heading.kind == "property":
|
||
allow_values = True
|
||
else:
|
||
allow_values = False
|
||
expected_level = heading.level + 1
|
||
|
||
for lineno, raw, in_fence in scan_body(lines, entry.start, entry.end):
|
||
if in_fence:
|
||
continue
|
||
match = HEADING_RE.match(raw.strip())
|
||
if not match:
|
||
continue
|
||
level = len(match.group(1))
|
||
title = match.group(2)
|
||
title_allowed = title in allowed_titles or bool(
|
||
allow_values and VALUE_HEADING_RE.match(title)
|
||
)
|
||
if level == expected_level and title_allowed:
|
||
continue
|
||
if not allow_values and not allowed_titles:
|
||
message = f"{heading.kind} `{heading.signature}` 不得包含子标题"
|
||
else:
|
||
message = (
|
||
f"{heading.kind} `{heading.signature}` 的子标题必须位于 "
|
||
f"H{expected_level},且只能使用参数取值"
|
||
+ ("或示例" if "示例" in allowed_titles else "")
|
||
)
|
||
findings.append(
|
||
(md_display, lineno + 1, "error", "subheading", message)
|
||
)
|
||
|
||
|
||
def lint_file(md, root, findings):
|
||
try:
|
||
display = md.relative_to(root).as_posix()
|
||
except ValueError:
|
||
display = str(md)
|
||
lines = md.read_text(encoding="utf-8").splitlines()
|
||
for entry in iter_api_entries(lines):
|
||
heading = entry.heading
|
||
if not heading.valid:
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"heading",
|
||
heading.error,
|
||
)
|
||
)
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
for lineno, raw, in_fence in body:
|
||
if in_fence:
|
||
continue
|
||
visibility = VISIBILITY_RE.match(raw.strip())
|
||
if visibility and visibility.group(1) == "private":
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
continue
|
||
|
||
kind = heading.kind
|
||
check_api_subheadings(display, lines, entry, findings)
|
||
if kind == "function":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
validate_parameter_rows=heading.root_kind != "function",
|
||
)
|
||
continue
|
||
if kind == "method":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
returns_required=False,
|
||
visibility_required=True,
|
||
validate_parameter_rows=True,
|
||
)
|
||
continue
|
||
if kind == "property":
|
||
check_entry(
|
||
display,
|
||
lines,
|
||
entry.start,
|
||
entry.end,
|
||
heading.signature,
|
||
findings,
|
||
returns_required=False,
|
||
visibility_required=True,
|
||
validate_parameter_rows=True,
|
||
)
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
empty_types = [
|
||
lineno
|
||
for lineno, raw, fenced in body
|
||
if not fenced
|
||
and (match := TYPE_LINE_RE.fullmatch(raw.strip()))
|
||
and not match.group(1).strip()
|
||
]
|
||
for lineno in empty_types:
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"type",
|
||
f"`{heading.signature}` 的类型不能为空",
|
||
)
|
||
)
|
||
if not any(
|
||
ACCESS_RE.match(raw.strip())
|
||
for _, raw, fenced in body
|
||
if not fenced
|
||
):
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"access",
|
||
f"`{heading.signature}` 缺少访问方式",
|
||
)
|
||
)
|
||
continue
|
||
|
||
body = scan_body(lines, entry.start, entry.end)
|
||
found, offending = find_description(body)
|
||
if not found:
|
||
line = offending + 1 if offending is not None else entry.start + 1
|
||
findings.append(
|
||
(
|
||
display,
|
||
line,
|
||
"error",
|
||
"description",
|
||
f"`{heading.signature}` 缺少描述",
|
||
)
|
||
)
|
||
|
||
if kind in {"class", "unit"}:
|
||
continue
|
||
|
||
visibility_required = heading.binding != "unit"
|
||
visibilities = [
|
||
(lineno, match.group(1))
|
||
for lineno, raw, fenced in body
|
||
if not fenced and (match := VISIBILITY_RE.match(raw.strip()))
|
||
]
|
||
if visibility_required and not visibilities:
|
||
findings.append(
|
||
(
|
||
display,
|
||
entry.start + 1,
|
||
"error",
|
||
"visibility",
|
||
f"`{heading.signature}` 缺少 `可见性:public|protected` 行",
|
||
)
|
||
)
|
||
for lineno, visibility in visibilities:
|
||
if visibility == "private":
|
||
findings.append(
|
||
(
|
||
display,
|
||
lineno + 1,
|
||
"error",
|
||
"visibility",
|
||
"private API 不得进入文档",
|
||
)
|
||
)
|
||
|
||
has_type = any(TYPE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||
has_value = any(VALUE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||
if kind in {"field", "variable"} and not has_type:
|
||
findings.append(
|
||
(display, entry.start + 1, "error", "type", f"`{heading.signature}` 缺少类型")
|
||
)
|
||
if kind == "constant" and not has_value:
|
||
findings.append(
|
||
(display, entry.start + 1, "error", "value", f"`{heading.signature}` 缺少值")
|
||
)
|
||
|
||
|
||
def gather_targets(paths, root):
|
||
"""Expand paths (files/dirs) into a sorted list of *.md."""
|
||
targets = []
|
||
for p in paths:
|
||
if p.is_dir():
|
||
targets.extend(p.rglob("*.md"))
|
||
elif p.is_file() and p.suffix == ".md":
|
||
targets.append(p)
|
||
return sorted(set(targets))
|
||
|
||
|
||
def main(argv=None):
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(
|
||
description="校验 Markdown 文件或目录",
|
||
add_help=False,
|
||
allow_abbrev=False,
|
||
)
|
||
parser.add_argument(
|
||
"--help",
|
||
action="help",
|
||
help="显示本帮助并退出(不提供 -h 短选项)",
|
||
)
|
||
target_group = parser.add_mutually_exclusive_group(required=True)
|
||
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
|
||
target_group.add_argument("--dir", help="要递归校验的目录")
|
||
parser.add_argument("--strict", action="store_true",
|
||
help="treat warnings as failures")
|
||
args = parser.parse_args(argv)
|
||
|
||
target = Path(args.file or args.dir)
|
||
if not target.exists():
|
||
print(f"ERROR: target not found: {target}", file=sys.stderr)
|
||
return 2
|
||
if args.file and (not target.is_file() or target.suffix.lower() != ".md"):
|
||
print(f"ERROR: --file requires a Markdown file: {target}", file=sys.stderr)
|
||
return 2
|
||
if args.dir and not target.is_dir():
|
||
print(f"ERROR: --dir requires a directory: {target}", file=sys.stderr)
|
||
return 2
|
||
|
||
root = target if target.is_dir() else target.parent
|
||
targets = gather_targets([target], root)
|
||
if not targets:
|
||
print("no markdown targets found", file=sys.stderr)
|
||
return 2
|
||
|
||
findings = []
|
||
for md in targets:
|
||
lint_file(md, root, findings)
|
||
|
||
findings.sort(key=lambda f: (f[0], f[1], 0 if f[2] == "error" else 1))
|
||
for display, line, level, rule, message in findings:
|
||
print(f"{display}:{line}: {level}: [{rule}] {message}")
|
||
|
||
errors = sum(1 for f in findings if f[2] == "error")
|
||
warnings = sum(1 for f in findings if f[2] == "warning")
|
||
print(
|
||
f"\n{len(targets)} files, {errors} error(s), {warnings} warning(s)",
|
||
file=sys.stderr,
|
||
)
|
||
if errors or (args.strict and warnings):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|