✨ 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:
@@ -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="要递归校验的目录")
|
||||
|
||||
Reference in New Issue
Block a user