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:
csh
2026-07-20 09:08:38 +08:00
parent 1d5304e7b6
commit c69278283f
18 changed files with 13955 additions and 12821 deletions
+267
View File
@@ -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())