✨ 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.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user