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.
This commit is contained in:
csh
2026-07-29 15:45:50 +08:00
parent bd25835d2d
commit 9edc8fc868
13 changed files with 8438 additions and 310 deletions
+332 -36
View File
@@ -1,17 +1,13 @@
#!/usr/bin/env python3
"""Lint TSL codegen function-doc markdown against the house standard.
"""Lint TSL codegen API 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).
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:
- 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 参数 / 类型 / 说明 三列
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 `可选。`
@@ -29,12 +25,26 @@ 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"^返回[:]")
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"(?<!\\)\|")
@@ -43,20 +53,6 @@ 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
@@ -82,6 +78,16 @@ def has_params(sig):
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())
@@ -130,16 +136,38 @@ def find_description(body):
stripped = raw.strip()
if not stripped or in_fence:
continue
if stripped.startswith("<!--"): # tags or other comment: skip
if DECLARATION_LINE_RE.fullmatch(stripped):
continue
if stripped.startswith("|") or stripped.startswith("#") \
or RETURN_RE.match(stripped):
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):
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)
@@ -155,10 +183,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
RETURN_RE.match(raw.strip())
for _, raw, in_fence in body if not in_fence
)
if not has_return:
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)
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
"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:
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
"空的 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 start, end, sig in iter_entries(lines):
check_entry(display, lines, start, end, sig, findings)
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):
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="校验 Markdown 文件或目录")
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="要递归校验的目录")