Files
csh 9edc8fc868 feat(tsl-codegen): support unified API declarations
Add TSF conversion and shared Markdown recognition for mixed
function, class, and unit declarations.

Extend generation, linting, indexing, examples, and tests around
the unified declaration model.
2026-07-29 15:45:50 +08:00

564 lines
20 KiB
Python
Raw Permalink 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 API Markdown against the house standard.
The standard lives in tools/tsl-codegen/STANDARD.md.
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 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 `可选。`
- 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
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"(?<!\\)\|")
SEP_CELL_RE = re.compile(r"^:?-+:?$")
PARAM_HEADER = ["参数", "类型", "说明"]
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 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())
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 DECLARATION_LINE_RE.fullmatch(stripped):
continue
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,
*,
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)
# 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 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)
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 '(空)'}"))
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:
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 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 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):
"""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 文件或目录",
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="要递归校验的目录")
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())