@@ -1,17 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint TSL codegen function-doc markdown against the house standard.
|
||||
"""Lint TSL codegen API Markdown against the house standard.
|
||||
|
||||
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||||
Each `## `sig`` / `### `sig`` heading starts one function entry. Rules split
|
||||
into hard errors (CI-blocking) and soft warnings (style
|
||||
convergence over the ~12k existing entries).
|
||||
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:
|
||||
- missing/empty description (first prose line after the signature)
|
||||
- missing `返回:类型`
|
||||
- signature has parameters but the entry has no parameter table
|
||||
- signature has no parameters but a parameter table is present
|
||||
- parameter table header is not the fixed 参数 / 类型 / 说明 三列
|
||||
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 `可选。`
|
||||
@@ -29,12 +25,26 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Entry heading: `## `sig`` or `### `sig``. Matches the index generator's rule
|
||||
# so the linter and the tsv agree on what a function entry is.
|
||||
ENTRY_RE = re.compile(r"^(#{2,3})(?!#)\s+`(.+?)`\s*$")
|
||||
RETURN_RE = re.compile(r"^返回[::]")
|
||||
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"(?<!\\)\|")
|
||||
@@ -43,20 +53,6 @@ SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
||||
PARAM_HEADER = ["参数", "类型", "说明"]
|
||||
|
||||
|
||||
def iter_entries(lines):
|
||||
"""Yield (start, end, signature): each entry spans one signature heading
|
||||
to the next. Category headings without backticks fall to the tail of the
|
||||
preceding entry (harmless — checks anchor on the entry's head)."""
|
||||
starts = [
|
||||
(idx, m.group(2))
|
||||
for idx, line in enumerate(lines)
|
||||
if (m := ENTRY_RE.match(line))
|
||||
]
|
||||
for i, (start, sig) in enumerate(starts):
|
||||
end = starts[i + 1][0] if i + 1 < len(starts) else len(lines)
|
||||
yield start, end, sig
|
||||
|
||||
|
||||
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
|
||||
@@ -82,6 +78,16 @@ def has_params(sig):
|
||||
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())
|
||||
@@ -130,16 +136,38 @@ def find_description(body):
|
||||
stripped = raw.strip()
|
||||
if not stripped or in_fence:
|
||||
continue
|
||||
if stripped.startswith("<!--"): # tags or other comment: skip
|
||||
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||||
continue
|
||||
if stripped.startswith("|") or stripped.startswith("#") \
|
||||
or RETURN_RE.match(stripped):
|
||||
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):
|
||||
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)
|
||||
|
||||
@@ -155,10 +183,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
RETURN_RE.match(raw.strip())
|
||||
for _, raw, in_fence in body if not in_fence
|
||||
)
|
||||
if not has_return:
|
||||
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)
|
||||
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"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:
|
||||
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"空的 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 start, end, sig in iter_entries(lines):
|
||||
check_entry(display, lines, start, end, sig, findings)
|
||||
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):
|
||||
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="校验 Markdown 文件或目录")
|
||||
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="要递归校验的目录")
|
||||
|
||||
Reference in New Issue
Block a user