Files
playbook/tools/tsl-codegen/scripts/lint.py
T
csh c69278283f feat(tsl-codegen): add decoupled documentation toolkit
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.
2026-07-20 09:08:38 +08:00

268 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())