Generate TSL API Markdown from YAML or JSON into a configurable project scope.\nAdd file and directory lint modes, tags-aware indexing, and keyword search across tags and descriptions.\nBundle the toolkit through the playbook build and sync workflows.
268 lines
9.9 KiB
Python
268 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
||
"""Lint TSL codegen function-doc 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).
|
||
|
||
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 参数 / 类型 / 说明 三列
|
||
|
||
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
|
||
|
||
# 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"^返回[::]")
|
||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
|
||
FENCE_RE = re.compile(r"^(```|~~~)")
|
||
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 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
|
||
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 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 stripped.startswith("<!--"): # tags or other comment: skip
|
||
continue
|
||
if stripped.startswith("|") or stripped.startswith("#") \
|
||
or RETURN_RE.match(stripped):
|
||
return False, lineno
|
||
return True, lineno
|
||
return False, None
|
||
|
||
|
||
def check_entry(md_display, lines, start, end, sig, findings):
|
||
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 not has_return:
|
||
findings.append((md_display, entry_line, "error", "return",
|
||
f"`{sig}` 缺少 `返回:类型` 行"))
|
||
|
||
# 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 '(空)'}"))
|
||
# 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 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)
|
||
|
||
|
||
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 文件或目录")
|
||
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())
|