✨ 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:
@@ -104,6 +104,7 @@ jobs:
|
|||||||
"docs/tsl"
|
"docs/tsl"
|
||||||
"skills/tsl-syntax-reference"
|
"skills/tsl-syntax-reference"
|
||||||
"skills/tsl-api-reference"
|
"skills/tsl-api-reference"
|
||||||
|
"tools/tsl-codegen"
|
||||||
)
|
)
|
||||||
for path in "${managed_paths[@]}"; do
|
for path in "${managed_paths[@]}"; do
|
||||||
if [ ! -e "$bundle/$path" ]; then
|
if [ ! -e "$bundle/$path" ]; then
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
skills/**
|
skills/thirdparty/**
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ skills = ["tsl-syntax-reference", "tsl-api-reference"]
|
|||||||
|
|
||||||
安装与使用详见 [SKILLS.md](SKILLS.md)。
|
安装与使用详见 [SKILLS.md](SKILLS.md)。
|
||||||
|
|
||||||
|
## tools/(维护工具)
|
||||||
|
|
||||||
|
- `tools/tsl-codegen/`:TSL 函数文档生成、校验与索引工具套件,供维护 `tsl-api-reference` 的函数文档树使用,详见 [tools/tsl-codegen/README.md](tools/tsl-codegen/README.md)。
|
||||||
|
|
||||||
|
套件与 skill 解耦:手动部署 skill 时套件不随行;仅公共同步(`scripts/build_tsl_playbook.py` 构建 + `.gitea/workflows/sync-tsl-playbook.yml` 发布)会把套件随 skill 一起带到 `tsl-playbook` 分支。
|
||||||
|
|
||||||
## 在其他项目中使用本 Playbook
|
## 在其他项目中使用本 Playbook
|
||||||
|
|
||||||
由于本仓库需要内部权限访问,其他项目**不能仅用外链引用**;推荐把 Playbook 规范部署到项目内,并用统一入口执行。
|
由于本仓库需要内部权限访问,其他项目**不能仅用外链引用**;推荐把 Playbook 规范部署到项目内,并用统一入口执行。
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ def build_agents_text(ruleset_path: Path) -> str:
|
|||||||
return text.rstrip("\n") + "\n"
|
return text.rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
def ensure_sources(repo_root: Path) -> tuple[Path, Path, Path, Path]:
|
def ensure_sources(repo_root: Path) -> tuple[Path, Path, Path, Path, Path]:
|
||||||
docs_tsl = repo_root / "docs" / "tsl"
|
docs_tsl = repo_root / "docs" / "tsl"
|
||||||
syntax_skill = repo_root / "skills" / "tsl-syntax-reference"
|
syntax_skill = repo_root / "skills" / "tsl-syntax-reference"
|
||||||
api_skill = repo_root / "skills" / "tsl-api-reference"
|
api_skill = repo_root / "skills" / "tsl-api-reference"
|
||||||
ruleset = repo_root / "rulesets" / "tsl" / "index.md"
|
ruleset = repo_root / "rulesets" / "tsl" / "index.md"
|
||||||
sources = (docs_tsl, syntax_skill, api_skill, ruleset)
|
codegen_toolkit = repo_root / "tools" / "tsl-codegen"
|
||||||
|
sources = (docs_tsl, syntax_skill, api_skill, ruleset, codegen_toolkit)
|
||||||
missing = [str(path) for path in sources if not path.exists()]
|
missing = [str(path) for path in sources if not path.exists()]
|
||||||
if missing:
|
if missing:
|
||||||
raise FileNotFoundError("missing source path(s): " + ", ".join(missing))
|
raise FileNotFoundError("missing source path(s): " + ", ".join(missing))
|
||||||
@@ -47,7 +48,9 @@ def clean_output(output: Path, repo_root: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def build(output: Path, repo_root: Path) -> None:
|
def build(output: Path, repo_root: Path) -> None:
|
||||||
docs_tsl, syntax_skill, api_skill, ruleset = ensure_sources(repo_root)
|
docs_tsl, syntax_skill, api_skill, ruleset, codegen_toolkit = ensure_sources(
|
||||||
|
repo_root
|
||||||
|
)
|
||||||
clean_output(output, repo_root)
|
clean_output(output, repo_root)
|
||||||
|
|
||||||
(output / "AGENTS.md").write_text(
|
(output / "AGENTS.md").write_text(
|
||||||
@@ -56,6 +59,7 @@ def build(output: Path, repo_root: Path) -> None:
|
|||||||
copy_tree(docs_tsl, output / "docs" / "tsl")
|
copy_tree(docs_tsl, output / "docs" / "tsl")
|
||||||
copy_tree(syntax_skill, output / "skills" / "tsl-syntax-reference")
|
copy_tree(syntax_skill, output / "skills" / "tsl-syntax-reference")
|
||||||
copy_tree(api_skill, output / "skills" / "tsl-api-reference")
|
copy_tree(api_skill, output / "skills" / "tsl-api-reference")
|
||||||
|
copy_tree(codegen_toolkit, output / "tools" / "tsl-codegen")
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Rebuild the bundled TSL API function index from the codegen markdown tree.
|
|
||||||
|
|
||||||
The markdown tree is the source of truth: each `## `sig`` / `### `sig`` heading
|
|
||||||
is one function entry. Both the tsv and every index.md are derived products;
|
|
||||||
regenerate them whenever the leaf md changes rather than editing by hand. The
|
|
||||||
top/scope/module index.md pages are rebuilt from the same entry headings the
|
|
||||||
tsv uses, so their `函数数` counts can never drift from the tsv.
|
|
||||||
|
|
||||||
Columns (tab-separated, LF line endings, UTF-8):
|
|
||||||
name scope module signature page anchor summary
|
|
||||||
- name: signature text up to the first '('
|
|
||||||
- scope: first path segment under the codegen root (builtin | dotnet)
|
|
||||||
- module: second path segment for nested pages, else the flat file stem
|
|
||||||
- signature: verbatim from the heading, backticks stripped
|
|
||||||
- page: POSIX path relative to the codegen root
|
|
||||||
- anchor: GitHub-style slug of the name (lowercased, chars outside
|
|
||||||
[a-z0-9_] removed); per-page duplicate slugs get -1/-2 suffixes
|
|
||||||
in document order, matching the rendered heading anchors.
|
|
||||||
- summary: first prose line under the entry heading, empty for table,
|
|
||||||
heading, or standalone return-type lines
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/tsl_codegen_function_index.py # rewrite in place
|
|
||||||
python scripts/tsl_codegen_function_index.py --check # verify, no write
|
|
||||||
python scripts/tsl_codegen_function_index.py --root PATH --tsv PATH
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
ENTRY_RE = re.compile(r"^#{2,3}(?!#)\s+`(.+?)`\s*$")
|
|
||||||
RETURN_RE = re.compile(r"^返回[::]")
|
|
||||||
HEADER = ["name", "scope", "module", "signature", "page", "anchor", "summary"]
|
|
||||||
|
|
||||||
|
|
||||||
def slug(name):
|
|
||||||
"""GitHub-style anchor slug: lowercase, keep [a-z0-9_], drop the rest."""
|
|
||||||
return re.sub(r"[^a-z0-9_]", "", name.lower())
|
|
||||||
|
|
||||||
|
|
||||||
def extract_summary(lines, heading_idx):
|
|
||||||
"""Return the first prose line under a function entry heading."""
|
|
||||||
for line in lines[heading_idx + 1:]:
|
|
||||||
text = line.strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if text.startswith("|") or text.startswith("#") or RETURN_RE.match(text):
|
|
||||||
return ""
|
|
||||||
return text.replace("\t", " ")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_page(codegen_root, md):
|
|
||||||
"""Yield [name, scope, module, signature, page, anchor, summary] rows."""
|
|
||||||
page = md.relative_to(codegen_root).as_posix()
|
|
||||||
scope, module = scope_module(page)
|
|
||||||
seen = {}
|
|
||||||
rows = []
|
|
||||||
lines = md.read_text(encoding="utf-8").splitlines()
|
|
||||||
for idx, line in enumerate(lines):
|
|
||||||
m = ENTRY_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
sig = m.group(1)
|
|
||||||
name = sig.split("(", 1)[0]
|
|
||||||
base = slug(name)
|
|
||||||
n = seen.get(base, 0)
|
|
||||||
seen[base] = n + 1
|
|
||||||
anchor = base if n == 0 else f"{base}-{n}"
|
|
||||||
summary = extract_summary(lines, idx)
|
|
||||||
rows.append([name, scope, module, sig, page, anchor, summary])
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def scope_module(page):
|
|
||||||
parts = page.split("/")
|
|
||||||
scope = parts[0]
|
|
||||||
module = parts[1] if len(parts) >= 3 else Path(parts[-1]).stem
|
|
||||||
return scope, module
|
|
||||||
|
|
||||||
|
|
||||||
def build_rows(codegen_root):
|
|
||||||
"""Scan the whole codegen tree, return sorted rows (skips index.md)."""
|
|
||||||
rows = []
|
|
||||||
for md in sorted(codegen_root.rglob("*.md")):
|
|
||||||
if md.name == "index.md":
|
|
||||||
continue
|
|
||||||
rows.extend(parse_page(codegen_root, md))
|
|
||||||
rows.sort(key=lambda r: (r[0].lower(), r[4], r[3]))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def render_tsv(rows):
|
|
||||||
lines = ["\t".join(HEADER)]
|
|
||||||
lines.extend("\t".join(r) for r in rows)
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def read_tsv(tsv_path):
|
|
||||||
text = tsv_path.read_text(encoding="utf-8")
|
|
||||||
return [line.split("\t") for line in text.splitlines() if line.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# index.md generation
|
|
||||||
#
|
|
||||||
# index.md pages are navigation, derived entirely from the leaf pages:
|
|
||||||
# - counts come from the same ENTRY_RE the tsv uses (single source of truth,
|
|
||||||
# so an index count can never disagree with the tsv);
|
|
||||||
# - labels come from each leaf page's H1;
|
|
||||||
# - the module title is the leaf H1 with its last " / " segment stripped.
|
|
||||||
# Per builtin-doc-template-rule, `函数数:N` lives only in index.md, and each
|
|
||||||
# index.md uses at most `#`/`##`, so it never trips markdownlint MD001.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
H1_RE = re.compile(r"^#\s+(.+?)\s*$")
|
|
||||||
|
|
||||||
SCOPE_META = {
|
|
||||||
"builtin": {
|
|
||||||
"heading": "Builtin",
|
|
||||||
"scope_desc": "本目录为本地 TSL 内置函数。",
|
|
||||||
"link_desc": "本地 TSL 内置函数。",
|
|
||||||
# builtin leaf H1 is 'Builtin - 基础 / 数组'; strip the scope prefix so
|
|
||||||
# module labels read '基础', mirroring dotnet's prefix-free '债券'.
|
|
||||||
"title_prefix": "Builtin - ",
|
|
||||||
},
|
|
||||||
"dotnet": {
|
|
||||||
"heading": "Dotnet",
|
|
||||||
"scope_desc": "本目录为 .NET 平台函数(按功能重分类)。",
|
|
||||||
"link_desc": ".NET 平台函数。",
|
|
||||||
"title_prefix": "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TOP_PREAMBLE = (
|
|
||||||
"# TSL Codegen\n\n"
|
|
||||||
"本目录是 TSL 函数调用事实目录。\n\n"
|
|
||||||
"目录链路表达函数性质;函数条目只保留调用事实。\n\n"
|
|
||||||
"函数查询从这里开始。\n\n"
|
|
||||||
"## 主目录\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def page_h1(md):
|
|
||||||
"""First `# ` heading text, or the file stem if the page has no H1."""
|
|
||||||
for line in md.read_text(encoding="utf-8").splitlines():
|
|
||||||
m = H1_RE.match(line)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
return md.stem
|
|
||||||
|
|
||||||
|
|
||||||
def page_entry_count(md):
|
|
||||||
"""Number of function entries on a leaf page (same rule as the tsv)."""
|
|
||||||
return sum(1 for line in md.read_text(encoding="utf-8").splitlines()
|
|
||||||
if ENTRY_RE.match(line))
|
|
||||||
|
|
||||||
|
|
||||||
def module_title(h1):
|
|
||||||
"""Leaf H1 with its last ' / ' segment stripped ('债券 / 基本信息'→'债券')."""
|
|
||||||
return h1.rsplit(" / ", 1)[0] if " / " in h1 else h1
|
|
||||||
|
|
||||||
|
|
||||||
def display_label(h1, prefix):
|
|
||||||
"""Strip a scope's title_prefix from an H1 for display in index tables."""
|
|
||||||
return h1[len(prefix):] if prefix and h1.startswith(prefix) else h1
|
|
||||||
|
|
||||||
|
|
||||||
def collect_leaves(codegen_root):
|
|
||||||
"""Return leaf-page dicts: rel/scope/module/file/h1/count (skips index.md).
|
|
||||||
|
|
||||||
module is None for flat pages that sit directly under a scope
|
|
||||||
(e.g. dotnet/forex.md). Pages deeper than scope/module/leaf.md are skipped.
|
|
||||||
"""
|
|
||||||
leaves = []
|
|
||||||
for md in sorted(codegen_root.rglob("*.md")):
|
|
||||||
if md.name == "index.md":
|
|
||||||
continue
|
|
||||||
parts = md.relative_to(codegen_root).as_posix().split("/")
|
|
||||||
if len(parts) == 3:
|
|
||||||
scope, module = parts[0], parts[1]
|
|
||||||
elif len(parts) == 2:
|
|
||||||
scope, module = parts[0], None
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
leaves.append(
|
|
||||||
{
|
|
||||||
"scope": scope,
|
|
||||||
"module": module,
|
|
||||||
"file": parts[-1],
|
|
||||||
"h1": page_h1(md),
|
|
||||||
"count": page_entry_count(md),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return leaves
|
|
||||||
|
|
||||||
|
|
||||||
def render_index_table(header_cols, rows):
|
|
||||||
"""Render a 3-column markdown table; last column right-aligned."""
|
|
||||||
out = [f"| {' | '.join(header_cols)} |", "| --- | --- | ---: |"]
|
|
||||||
out.extend(f"| {label} | [{label}]({link}) | {count} |" for label, link, count in rows)
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def build_indexes(codegen_root):
|
|
||||||
"""Return {absolute Path: text} for every top/scope/module index.md."""
|
|
||||||
leaves = collect_leaves(codegen_root)
|
|
||||||
pages = {}
|
|
||||||
|
|
||||||
scopes = sorted({leaf["scope"] for leaf in leaves})
|
|
||||||
|
|
||||||
# top index.md
|
|
||||||
top_links = []
|
|
||||||
for scope in scopes:
|
|
||||||
desc = SCOPE_META.get(scope, {}).get("link_desc", "")
|
|
||||||
top_links.append(f"- [{scope}/]({scope}/):{desc}")
|
|
||||||
pages[codegen_root / "index.md"] = TOP_PREAMBLE + "\n".join(top_links) + "\n"
|
|
||||||
|
|
||||||
for scope in scopes:
|
|
||||||
scope_leaves = [x for x in leaves if x["scope"] == scope]
|
|
||||||
meta = SCOPE_META.get(scope, {"heading": scope, "scope_desc": ""})
|
|
||||||
modules = sorted({x["module"] for x in scope_leaves if x["module"]})
|
|
||||||
flat = sorted((x for x in scope_leaves if x["module"] is None),
|
|
||||||
key=lambda x: x["file"])
|
|
||||||
|
|
||||||
prefix = meta.get("title_prefix", "")
|
|
||||||
|
|
||||||
scope_rows = []
|
|
||||||
for module in modules:
|
|
||||||
mod_leaves = sorted(
|
|
||||||
(x for x in scope_leaves if x["module"] == module),
|
|
||||||
key=lambda x: x["file"],
|
|
||||||
)
|
|
||||||
title = display_label(module_title(mod_leaves[0]["h1"]), prefix)
|
|
||||||
total = sum(x["count"] for x in mod_leaves)
|
|
||||||
scope_rows.append((title, f"{module}/", total))
|
|
||||||
|
|
||||||
# module index.md
|
|
||||||
mod_rows = [
|
|
||||||
(display_label(x["h1"], prefix), x["file"], x["count"])
|
|
||||||
for x in mod_leaves
|
|
||||||
]
|
|
||||||
mod_text = (
|
|
||||||
f"# {title}\n\n"
|
|
||||||
f"函数数:{total}\n\n"
|
|
||||||
f"{render_index_table(['叶子', '文件', '函数数'], mod_rows)}\n"
|
|
||||||
)
|
|
||||||
pages[codegen_root / scope / module / "index.md"] = mod_text
|
|
||||||
|
|
||||||
for leaf in flat:
|
|
||||||
scope_rows.append(
|
|
||||||
(display_label(leaf["h1"], prefix), leaf["file"], leaf["count"])
|
|
||||||
)
|
|
||||||
|
|
||||||
scope_total = sum(x["count"] for x in scope_leaves)
|
|
||||||
scope_text = (
|
|
||||||
f"# {meta['heading']}\n\n"
|
|
||||||
f"{meta['scope_desc']}\n\n"
|
|
||||||
f"函数数:{scope_total}\n\n"
|
|
||||||
f"{render_index_table(['模块', '目录', '函数数'], scope_rows)}\n"
|
|
||||||
)
|
|
||||||
pages[codegen_root / scope / "index.md"] = scope_text
|
|
||||||
|
|
||||||
return pages
|
|
||||||
|
|
||||||
|
|
||||||
def check_indexes(codegen_root):
|
|
||||||
"""Return list of (path, reason) where a generated index differs from disk."""
|
|
||||||
problems = []
|
|
||||||
for path, text in build_indexes(codegen_root).items():
|
|
||||||
if not path.is_file():
|
|
||||||
problems.append((path, "missing"))
|
|
||||||
elif path.read_text(encoding="utf-8") != text:
|
|
||||||
problems.append((path, "stale"))
|
|
||||||
return problems
|
|
||||||
|
|
||||||
|
|
||||||
def write_indexes(codegen_root):
|
|
||||||
"""Write all generated index.md pages; return the count written."""
|
|
||||||
pages = build_indexes(codegen_root)
|
|
||||||
for path, text in pages.items():
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(text, encoding="utf-8", newline="\n")
|
|
||||||
return len(pages)
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
parser.add_argument(
|
|
||||||
"--root",
|
|
||||||
help="codegen root (default: skills/tsl-api-reference/references/codegen)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--tsv",
|
|
||||||
help="output tsv (default: skills/tsl-api-reference/data/function_index.tsv)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--check",
|
|
||||||
action="store_true",
|
|
||||||
help="verify the tsv matches the md tree; exit 1 if not (no write)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
repo_root = Path(__file__).resolve().parents[1]
|
|
||||||
skill_root = repo_root / "skills" / "tsl-api-reference"
|
|
||||||
codegen_root = (
|
|
||||||
Path(args.root) if args.root else skill_root / "references" / "codegen"
|
|
||||||
)
|
|
||||||
if not codegen_root.is_dir():
|
|
||||||
print(f"ERROR: codegen root not found: {codegen_root}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
tsv_path = Path(args.tsv) if args.tsv else skill_root / "data" / "function_index.tsv"
|
|
||||||
|
|
||||||
rows = build_rows(codegen_root)
|
|
||||||
new_text = render_tsv(rows)
|
|
||||||
|
|
||||||
if args.check:
|
|
||||||
index_problems = check_indexes(codegen_root)
|
|
||||||
if not tsv_path.is_file():
|
|
||||||
print(f"MISMATCH: tsv does not exist: {tsv_path}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
current = tsv_path.read_text(encoding="utf-8")
|
|
||||||
tsv_ok = current == new_text
|
|
||||||
if tsv_ok and not index_problems:
|
|
||||||
print(
|
|
||||||
f"OK: {tsv_path} matches md tree ({len(rows)} rows); "
|
|
||||||
f"index.md pages up to date"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
if not tsv_ok:
|
|
||||||
cur_rows = read_tsv(tsv_path)[1:]
|
|
||||||
cur_keys = {tuple(r) for r in cur_rows}
|
|
||||||
new_keys = {tuple(r) for r in rows}
|
|
||||||
print(
|
|
||||||
f"MISMATCH: tsv out of date "
|
|
||||||
f"(tsv {len(cur_rows)} rows, md {len(rows)} rows; "
|
|
||||||
f"+{len(new_keys - cur_keys)} -{len(cur_keys - new_keys)}). "
|
|
||||||
f"Run without --check to rebuild.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
for path, reason in index_problems:
|
|
||||||
print(
|
|
||||||
f"MISMATCH: index {reason}: "
|
|
||||||
f"{path.relative_to(codegen_root).as_posix()}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
tsv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tsv_path.write_text(new_text, encoding="utf-8", newline="\n")
|
|
||||||
n_index = write_indexes(codegen_root)
|
|
||||||
print(f"wrote {tsv_path}: {len(rows)} rows; {n_index} index.md pages")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name scope module signature page anchor summary
|
name scope module signature page anchor tags summary
|
||||||
_and builtin math _and(x, y) builtin/math/numeric.md _and 与,返回位运算二进制X And 二进制Y
|
_and builtin math _and(x, y) builtin/math/numeric.md _and 与,返回位运算二进制X And 二进制Y
|
||||||
_not builtin math _not(x) builtin/math/numeric.md _not 非,返回位运算Not X,把二进制x全部取反,即非0为1,非1为0。
|
_not builtin math _not(x) builtin/math/numeric.md _not 非,返回位运算Not X,把二进制x全部取反,即非0为1,非1为0。
|
||||||
_or builtin math _or(x, y) builtin/math/numeric.md _or 或,返回位运算X Or Y
|
_or builtin math _or(x, y) builtin/math/numeric.md _or 或,返回位运算X Or Y
|
||||||
|
|||||||
|
Can't render this file because it is too large.
|
@@ -55,6 +55,7 @@ def search_keyword(rows, terms):
|
|||||||
row.get("name", ""),
|
row.get("name", ""),
|
||||||
row.get("signature", ""),
|
row.get("signature", ""),
|
||||||
row.get("module", ""),
|
row.get("module", ""),
|
||||||
|
row.get("tags", ""),
|
||||||
row.get("summary", ""),
|
row.get("summary", ""),
|
||||||
]
|
]
|
||||||
).lower()
|
).lower()
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# TSL Codegen Toolkit
|
||||||
|
|
||||||
|
本工具把 YAML/JSON 录入文件转换为 TSL API skill 使用的 Markdown 函数文档,
|
||||||
|
并根据 Markdown 重建 `function_index.tsv`
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
tools/tsl-codegen/
|
||||||
|
├─ README.md 使用说明
|
||||||
|
├─ STANDARD.md 函数文档与录入格式标准
|
||||||
|
├─ examples/
|
||||||
|
│ ├─ example.yaml YAML 录入例子
|
||||||
|
│ └─ example.json JSON 录入例子
|
||||||
|
├─ scripts/
|
||||||
|
│ ├─ generate.py YAML/JSON → Markdown
|
||||||
|
│ ├─ lint.py Markdown 格式校验
|
||||||
|
│ └─ build_index.py 重建 function_index.tsv
|
||||||
|
└─ tests/ 工具测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用顺序
|
||||||
|
|
||||||
|
### 1. 进入仓库根目录
|
||||||
|
|
||||||
|
先切换到包含 `tools/` 和 `skills/` 的仓库根目录。后续命令都从该目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/playbook
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 使用 Python 标准库,不需要额外安装解析包。YAML 需要安装 `pyyaml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install pyyaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 阅读标准
|
||||||
|
|
||||||
|
先阅读 [`STANDARD.md`](STANDARD.md)。其中定义:
|
||||||
|
|
||||||
|
- Markdown 函数条目的固定结构
|
||||||
|
- YAML/JSON 录入字段
|
||||||
|
- 参数表、返回类型和示例代码规则
|
||||||
|
|
||||||
|
录入文件和生成的 Markdown 都必须符合该标准
|
||||||
|
|
||||||
|
### 3. 准备自己的 YAML 或 JSON
|
||||||
|
|
||||||
|
从以下例子选择一种格式:
|
||||||
|
|
||||||
|
- [`examples/example.yaml`](examples/example.yaml)
|
||||||
|
- [`examples/example.json`](examples/example.json)
|
||||||
|
|
||||||
|
例子中的函数是格式示例,不是真实 TSL API。复制例子到自己的工作目录,再修改
|
||||||
|
`module`、`path` 和 `functions`。例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tmp/my-functions.yaml
|
||||||
|
tmp/my-functions.json
|
||||||
|
```
|
||||||
|
|
||||||
|
一个录入文件对应一个 Markdown 叶子页。录入文件不放入 skill;是否长期保留由
|
||||||
|
维护者自行决定
|
||||||
|
|
||||||
|
### 4. 选择 Skill 中的目标位置
|
||||||
|
|
||||||
|
TSL API skill 的相关目录如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/tsl-api-reference/
|
||||||
|
├─ SKILL.md
|
||||||
|
├─ data/
|
||||||
|
│ └─ function_index.tsv
|
||||||
|
├─ references/
|
||||||
|
│ └─ codegen/
|
||||||
|
│ ├─ builtin/ playbook 维护
|
||||||
|
│ ├─ dotnet/ playbook 维护
|
||||||
|
│ └─ project/ 用户项目文档的默认 scope
|
||||||
|
│ └─ <module-dir>/<page>.md
|
||||||
|
└─ scripts/
|
||||||
|
└─ lookup.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Markdown 目标路径固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/tsl-api-reference/references/codegen/<scope>/<module-dir>/<page>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<scope>`:用户文档默认使用 `project`,也可以自定义单级目录名。`builtin` 和
|
||||||
|
`dotnet` 由 playbook 项目维护,不应用于存放用户自己的函数文档
|
||||||
|
- `<module-dir>`:功能分类目录,例如 `base`、`runtime`、`document`
|
||||||
|
- `<page>.md`:同类函数的叶子文档,例如 `array.md`、`string.md`
|
||||||
|
|
||||||
|
录入文件的 `module` 是 Markdown 一级标题,不是目录名。例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module: 我的项目 / 数组
|
||||||
|
path: base/array
|
||||||
|
目标文件: project/base/array.md
|
||||||
|
```
|
||||||
|
|
||||||
|
查找项目中的现有页面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg --files skills/tsl-api-reference/references/codegen/project
|
||||||
|
```
|
||||||
|
|
||||||
|
生成后可以直接打开目标 Markdown 手动阅读。例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/tsl-api-reference/references/codegen/project/base/array.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 生成 Markdown
|
||||||
|
|
||||||
|
#### 新建叶子页
|
||||||
|
|
||||||
|
目标文件不存在时,可以直接生成到 skill。例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 使用相同命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.json
|
||||||
|
```
|
||||||
|
|
||||||
|
生成器读取录入文件中的 `path`,默认写入
|
||||||
|
`skills/tsl-api-reference/references/codegen/project/<path>.md`。不指定
|
||||||
|
`--scope` 时,scope 就是 `project`
|
||||||
|
|
||||||
|
需要使用自定义 scope 时,通过 `--scope` 指定单级目录名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.json --scope my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改现有叶子页
|
||||||
|
|
||||||
|
生成器会整体覆盖 `path` 对应的页面。只有录入文件包含该页面的全部函数时才运行
|
||||||
|
生成器。只修改现有页面中的少量函数时,应按照 `STANDARD.md` 直接编辑 Markdown
|
||||||
|
|
||||||
|
### 6. 手动检查并校验 Markdown
|
||||||
|
|
||||||
|
以下命令以新建页面
|
||||||
|
`skills/tsl-api-reference/references/codegen/project/base/my_functions.md` 为例
|
||||||
|
|
||||||
|
先打开文件,检查页面标题、函数签名、参数、返回类型和示例
|
||||||
|
|
||||||
|
#### 6.1 格式化表格(可选)
|
||||||
|
|
||||||
|
此步骤不是必需的,仅用于对齐 Markdown 表格列宽。使用前需要安装 Node.js,并在
|
||||||
|
仓库根目录安装 `prettier`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --save-dev prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
然后格式化目标文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prettier --write skills/tsl-api-reference/references/codegen/project/base/my_functions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2 校验
|
||||||
|
|
||||||
|
校验目标文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/lint.py --file skills/tsl-api-reference/references/codegen/project/base/my_functions.md
|
||||||
|
```
|
||||||
|
|
||||||
|
校验整个项目目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/lint.py --dir skills/tsl-api-reference/references/codegen/project
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `--strict` 时,警告也会导致校验失败
|
||||||
|
|
||||||
|
校验器将以下问题视为错误:
|
||||||
|
|
||||||
|
- 缺少描述
|
||||||
|
- 缺少返回类型
|
||||||
|
- 有参数但没有参数表
|
||||||
|
- 无参数但存在参数表
|
||||||
|
- 参数表不是固定三列
|
||||||
|
|
||||||
|
以下问题默认作为警告:
|
||||||
|
|
||||||
|
- 可选参数说明未以 `可选。` 开头
|
||||||
|
- tags 行为空
|
||||||
|
|
||||||
|
### 7. 更新并验证函数索引
|
||||||
|
|
||||||
|
Markdown 确认无误后,重建 TSV:
|
||||||
|
|
||||||
|
索引会分别保存函数的 tags 和描述。关键词检索会同时匹配函数名、签名、模块、
|
||||||
|
tags 和描述
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference
|
||||||
|
```
|
||||||
|
|
||||||
|
检查 TSV 是否与 Markdown 一致:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference --check
|
||||||
|
```
|
||||||
|
|
||||||
|
默认从 `<skill-dir>/references/codegen` 读取 Markdown,并写入
|
||||||
|
`<skill-dir>/data/function_index.tsv`
|
||||||
|
|
||||||
|
最后使用函数名验证 skill 可以检索到新条目。把 `myFunction` 替换为真实函数名:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python skills/tsl-api-reference/scripts/lookup.py --name myFunction
|
||||||
|
```
|
||||||
|
|
||||||
|
最终提交:
|
||||||
|
|
||||||
|
- 新增或修改的 Markdown 叶子页
|
||||||
|
- `skills/tsl-api-reference/data/function_index.tsv`
|
||||||
|
|
||||||
|
TSL API skill 只需要 Markdown 和 TSV。录入用的 YAML/JSON 可以由维护者在自己的
|
||||||
|
版本库中管理
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# TSL 函数文档标准
|
||||||
|
|
||||||
|
本文件是 TSL codegen 函数文档的唯一标准,包含 Markdown 存储格式和 YAML/JSON
|
||||||
|
录入格式
|
||||||
|
|
||||||
|
适用范围:`references/codegen/**/*.md` 中的函数条目
|
||||||
|
|
||||||
|
## 基本原则
|
||||||
|
|
||||||
|
- Markdown 是唯一存储源
|
||||||
|
- YAML/JSON 用于生成 Markdown
|
||||||
|
- 函数签名由维护者提供;工具原样输出,不校正签名内容
|
||||||
|
- 示例输出必须全部写成 `//` 注释,不得把裸结果写成 TSL 语句
|
||||||
|
- 一个 `tsl` 代码块只放一个独立示例
|
||||||
|
|
||||||
|
## Markdown 存储格式
|
||||||
|
|
||||||
|
每个函数条目按以下顺序书写:
|
||||||
|
|
||||||
|
1. `## \`函数签名\``
|
||||||
|
2. `<!-- tags: 关键词1 关键词2 -->`,可选
|
||||||
|
3. 函数描述,必填;首个非空内容必须是描述
|
||||||
|
4. 参数表,有参数时必填
|
||||||
|
5. 参数取值说明,可选
|
||||||
|
6. `返回:类型`
|
||||||
|
7. `### 示例` 和 `tsl` 代码块,可选
|
||||||
|
|
||||||
|
### 标签
|
||||||
|
|
||||||
|
标签紧跟函数签名,用空格分隔检索关键词:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- tags: 数组 排序 去重 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
没有关键词时删除整行,不保留空标签
|
||||||
|
|
||||||
|
### 参数表
|
||||||
|
|
||||||
|
参数表固定为三列:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src` | array | 待处理数组 |
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 参数名必须与函数签名一致
|
||||||
|
- 必填参数说明直接写用途
|
||||||
|
- 可选参数说明以 `可选。` 开头,并说明默认值
|
||||||
|
- 多类型使用 `\|`,例如 `nil\|array`
|
||||||
|
- 变参使用 `...` 作为参数名
|
||||||
|
|
||||||
|
### 参数取值
|
||||||
|
|
||||||
|
枚举参数在参数表之后、返回类型之前列出:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**mode 取值**
|
||||||
|
|
||||||
|
- `0` — 原样返回
|
||||||
|
- `1` — 去重
|
||||||
|
```
|
||||||
|
|
||||||
|
### 返回类型
|
||||||
|
|
||||||
|
每个函数必须包含非空返回类型:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
返回:array
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例代码
|
||||||
|
|
||||||
|
- 代码围栏使用 `tsl`
|
||||||
|
- 字符串使用直引号 `'` 或 `"`
|
||||||
|
- 注释使用 `//`,不使用 `(* *)`
|
||||||
|
- 每条语句保留分号
|
||||||
|
- 单行输出写成 `// 输出:<值>`
|
||||||
|
- 多行输出第一行写 `// 输出:`,后续每一行输出都以 `//` 开头
|
||||||
|
|
||||||
|
多行输出示例:
|
||||||
|
|
||||||
|
```tsl
|
||||||
|
return demoLines();
|
||||||
|
// 输出:
|
||||||
|
// 第一行
|
||||||
|
// 第二行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整条目示例
|
||||||
|
|
||||||
|
以下函数仅用于说明文档格式,不代表真实 TSL API
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
## `demoFn(src, mode, factor, ...)`
|
||||||
|
<!-- tags: 示例 数组 -->
|
||||||
|
|
||||||
|
按指定模式处理数组并返回结果
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| -------- | ---------- | --------------------------------- |
|
||||||
|
| `src` | array | 待处理数组 |
|
||||||
|
| `mode` | integer | 处理模式,取值见下。 |
|
||||||
|
| `factor` | float | 可选。默认 1.0,结果乘以该系数。 |
|
||||||
|
| `...` | nil\|array | 可选。需要追加处理的其他数组。 |
|
||||||
|
|
||||||
|
**mode 取值**
|
||||||
|
|
||||||
|
- `0` — 原样返回
|
||||||
|
- `1` — 去重
|
||||||
|
|
||||||
|
返回:array
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```tsl
|
||||||
|
src := array(1, 1, 2);
|
||||||
|
return demoFn(src, 1, 2.0);
|
||||||
|
// 输出:array(2,4)
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
无参函数省略参数表:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
## `demoNow()`
|
||||||
|
|
||||||
|
返回示例值
|
||||||
|
|
||||||
|
返回:integer
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```tsl
|
||||||
|
return demoNow();
|
||||||
|
// 输出:1
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## 录入数据结构
|
||||||
|
|
||||||
|
一个录入文件对应一个 Markdown 叶子页
|
||||||
|
|
||||||
|
顶层字段:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| ----------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `module` | 是 | Markdown 一级标题内容;不是目录名或文件名。例如 `module: 示例 / 数组` 生成 `# 示例 / 数组` |
|
||||||
|
| `path` | 是 | 目标 Markdown 在 scope 目录下的相对路径,包含子目录和文件名,使用 `/` 分隔且不含 `.md` 后缀。例如 `path: base/example` 在默认 `project` scope 下生成 `references/codegen/project/base/example.md` |
|
||||||
|
| `functions` | 是 | 非空函数列表 |
|
||||||
|
|
||||||
|
函数字段:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| ----------- | ------ | ------------------------------------------------------ |
|
||||||
|
| `signature` | 是 | 维护者提供的完整函数签名 |
|
||||||
|
| `desc` | 是 | 函数描述,可包含多行 |
|
||||||
|
| `tags` | 否 | 检索关键词列表;推荐填写,有助于更准确地识别和检索函数 |
|
||||||
|
| `params` | 有参时 | 参数列表;无参函数省略 |
|
||||||
|
| `returns` | 是 | 返回类型 |
|
||||||
|
| `example` | 否 | 不含代码围栏的 TSL 示例 |
|
||||||
|
|
||||||
|
参数字段:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| ---------- | ---- | ------------------------------- |
|
||||||
|
| `name` | 是 | 参数名,与签名一致 |
|
||||||
|
| `type` | 是 | 参数类型 |
|
||||||
|
| `desc` | 是 | 参数说明 |
|
||||||
|
| `optional` | 否 | `true` 时自动添加 `可选。` 前缀 |
|
||||||
|
| `values` | 否 | 枚举值列表,生成参数取值说明 |
|
||||||
|
|
||||||
|
`values` 每项包含:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| ------- | ---- | -------- |
|
||||||
|
| `value` | 是 | 枚举值 |
|
||||||
|
| `desc` | 是 | 枚举含义 |
|
||||||
|
|
||||||
|
## YAML 录入格式
|
||||||
|
|
||||||
|
YAML 适合包含多行示例的页面。解析 YAML 需要安装 `pyyaml`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- `example` 使用 `|` 块标量
|
||||||
|
- 参数名 `...` 必须加引号
|
||||||
|
- `nil|array` 可直接作为普通字符串值
|
||||||
|
|
||||||
|
完整例子:[examples/example.yaml](examples/example.yaml)
|
||||||
|
|
||||||
|
## JSON 录入格式
|
||||||
|
|
||||||
|
JSON 使用 Python 标准库解析,无额外依赖
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- JSON 不支持注释
|
||||||
|
- 多行示例使用 `\n`
|
||||||
|
- 字符串内部的双引号使用 `\"`
|
||||||
|
- 结构标点必须使用半角字符
|
||||||
|
|
||||||
|
完整例子:[examples/example.json](examples/example.json)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"module": "示例 / 数组",
|
||||||
|
"path": "base/example",
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"signature": "demoNow()",
|
||||||
|
"desc": "返回示例值。",
|
||||||
|
"returns": "integer",
|
||||||
|
"example": "return demoNow();\n// 输出:1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"signature": "demoFn(src, mode, factor, ...)",
|
||||||
|
"tags": ["示例", "数组"],
|
||||||
|
"desc": "按指定模式处理数组并返回结果。",
|
||||||
|
"params": [
|
||||||
|
{
|
||||||
|
"name": "src",
|
||||||
|
"type": "array",
|
||||||
|
"desc": "待处理数组"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mode",
|
||||||
|
"type": "integer",
|
||||||
|
"desc": "处理模式,取值见下。",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": 0,
|
||||||
|
"desc": "原样返回"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"desc": "去重"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "factor",
|
||||||
|
"type": "float",
|
||||||
|
"optional": true,
|
||||||
|
"desc": "默认 1.0,结果乘以该系数。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"type": "nil|array",
|
||||||
|
"optional": true,
|
||||||
|
"desc": "需要追加处理的其他数组。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"returns": "array",
|
||||||
|
"example": "src := array(1, 1, 2);\nreturn demoFn(src, 1, 2.0);\n// 输出:array(2,4)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module: 示例 / 数组
|
||||||
|
path: base/example
|
||||||
|
|
||||||
|
functions:
|
||||||
|
- signature: demoNow()
|
||||||
|
desc: 返回示例值。
|
||||||
|
returns: integer
|
||||||
|
example: |
|
||||||
|
return demoNow();
|
||||||
|
// 输出:1
|
||||||
|
|
||||||
|
- signature: demoFn(src, mode, factor, ...)
|
||||||
|
tags: [示例, 数组]
|
||||||
|
desc: 按指定模式处理数组并返回结果。
|
||||||
|
params:
|
||||||
|
- name: src
|
||||||
|
type: array
|
||||||
|
desc: 待处理数组
|
||||||
|
- name: mode
|
||||||
|
type: integer
|
||||||
|
desc: 处理模式,取值见下。
|
||||||
|
values:
|
||||||
|
- value: 0
|
||||||
|
desc: 原样返回
|
||||||
|
- value: 1
|
||||||
|
desc: 去重
|
||||||
|
- name: factor
|
||||||
|
type: float
|
||||||
|
optional: true
|
||||||
|
desc: 默认 1.0,结果乘以该系数。
|
||||||
|
- name: "..."
|
||||||
|
type: nil|array
|
||||||
|
optional: true
|
||||||
|
desc: 需要追加处理的其他数组。
|
||||||
|
returns: array
|
||||||
|
example: |
|
||||||
|
src := array(1, 1, 2);
|
||||||
|
return demoFn(src, 1, 2.0);
|
||||||
|
// 输出:array(2,4)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Rebuild the bundled TSL API function index from the codegen markdown tree.
|
||||||
|
|
||||||
|
The markdown tree is the source of truth: each `## `sig`` / `### `sig`` heading
|
||||||
|
is one function entry. The TSV is a derived product; regenerate it whenever the
|
||||||
|
leaf Markdown changes rather than editing it by hand.
|
||||||
|
|
||||||
|
Columns (tab-separated, LF line endings, UTF-8):
|
||||||
|
name scope module signature page anchor tags summary
|
||||||
|
- name: signature text up to the first '('
|
||||||
|
- scope: first path segment under the codegen root (for example project)
|
||||||
|
- module: second path segment for nested pages, else the flat file stem
|
||||||
|
- signature: verbatim from the heading, backticks stripped
|
||||||
|
- page: POSIX path relative to the codegen root
|
||||||
|
- anchor: GitHub-style slug of the name (lowercased, chars outside
|
||||||
|
[a-z0-9_] removed); per-page duplicate slugs get -1/-2 suffixes
|
||||||
|
in document order, matching the rendered heading anchors.
|
||||||
|
- tags: space-separated keywords from `<!-- tags: ... -->`
|
||||||
|
- summary: first prose line under the entry heading, empty for table,
|
||||||
|
heading, or standalone return-type lines
|
||||||
|
|
||||||
|
Usage (run from repo root; --skill-dir is required):
|
||||||
|
SKILL=skills/tsl-api-reference
|
||||||
|
python tools/tsl-codegen/scripts/build_index.py \
|
||||||
|
--skill-dir "$SKILL" # rewrite the TSV in place
|
||||||
|
python tools/tsl-codegen/scripts/build_index.py \
|
||||||
|
--skill-dir "$SKILL" --check # verify against md tree, no write
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
ENTRY_RE = re.compile(r"^#{2,3}(?!#)\s+`(.+?)`\s*$")
|
||||||
|
RETURN_RE = re.compile(r"^返回[::]")
|
||||||
|
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
|
||||||
|
HEADER = [
|
||||||
|
"name",
|
||||||
|
"scope",
|
||||||
|
"module",
|
||||||
|
"signature",
|
||||||
|
"page",
|
||||||
|
"anchor",
|
||||||
|
"tags",
|
||||||
|
"summary",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def slug(name):
|
||||||
|
"""GitHub-style anchor slug: lowercase, keep [a-z0-9_], drop the rest."""
|
||||||
|
return re.sub(r"[^a-z0-9_]", "", name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def extract_metadata(lines, heading_idx):
|
||||||
|
"""Return tags and the first prose line under a function entry heading."""
|
||||||
|
tags = ""
|
||||||
|
for line in lines[heading_idx + 1:]:
|
||||||
|
text = line.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
tag_match = TAGS_RE.match(text)
|
||||||
|
if tag_match:
|
||||||
|
tags = " ".join(tag_match.group(1).split()).replace("\t", " ")
|
||||||
|
continue
|
||||||
|
if text.startswith("|") or text.startswith("#") or RETURN_RE.match(text):
|
||||||
|
return tags, ""
|
||||||
|
return tags, text.replace("\t", " ")
|
||||||
|
return tags, ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page(codegen_root, md):
|
||||||
|
"""Yield index rows for one Markdown page."""
|
||||||
|
page = md.relative_to(codegen_root).as_posix()
|
||||||
|
scope, module = scope_module(page)
|
||||||
|
seen = {}
|
||||||
|
rows = []
|
||||||
|
lines = md.read_text(encoding="utf-8").splitlines()
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
m = ENTRY_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
sig = m.group(1)
|
||||||
|
name = sig.split("(", 1)[0]
|
||||||
|
base = slug(name)
|
||||||
|
n = seen.get(base, 0)
|
||||||
|
seen[base] = n + 1
|
||||||
|
anchor = base if n == 0 else f"{base}-{n}"
|
||||||
|
tags, summary = extract_metadata(lines, idx)
|
||||||
|
rows.append([name, scope, module, sig, page, anchor, tags, summary])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def scope_module(page):
|
||||||
|
parts = page.split("/")
|
||||||
|
scope = parts[0]
|
||||||
|
module = parts[1] if len(parts) >= 3 else Path(parts[-1]).stem
|
||||||
|
return scope, module
|
||||||
|
|
||||||
|
|
||||||
|
def build_rows(codegen_root):
|
||||||
|
"""Scan the whole codegen tree and return sorted rows."""
|
||||||
|
rows = []
|
||||||
|
for md in sorted(codegen_root.rglob("*.md")):
|
||||||
|
rows.extend(parse_page(codegen_root, md))
|
||||||
|
rows.sort(key=lambda r: (r[0].lower(), r[4], r[3]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def render_tsv(rows):
|
||||||
|
lines = ["\t".join(HEADER)]
|
||||||
|
lines.extend("\t".join(r) for r in rows)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def read_tsv(tsv_path):
|
||||||
|
text = tsv_path.read_text(encoding="utf-8")
|
||||||
|
return [line.split("\t") for line in text.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--skill-dir",
|
||||||
|
required=True,
|
||||||
|
help="tsl-api-reference skill dir; codegen root defaults to "
|
||||||
|
"<skill-dir>/references/codegen and tsv to "
|
||||||
|
"<skill-dir>/data/function_index.tsv",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--root",
|
||||||
|
help="explicit codegen root, overriding the one derived from --skill-dir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--tsv",
|
||||||
|
help="explicit output tsv, overriding the one derived from --skill-dir",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="verify the tsv matches the md tree; exit 1 if not (no write)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
skill_root = Path(args.skill_dir)
|
||||||
|
codegen_root = (
|
||||||
|
Path(args.root) if args.root else skill_root / "references" / "codegen"
|
||||||
|
)
|
||||||
|
if not codegen_root.is_dir():
|
||||||
|
print(f"ERROR: codegen root not found: {codegen_root}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
tsv_path = (
|
||||||
|
Path(args.tsv)
|
||||||
|
if args.tsv
|
||||||
|
else skill_root / "data" / "function_index.tsv"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = build_rows(codegen_root)
|
||||||
|
new_text = render_tsv(rows)
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
if not tsv_path.is_file():
|
||||||
|
print(f"MISMATCH: tsv does not exist: {tsv_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
current = tsv_path.read_text(encoding="utf-8")
|
||||||
|
tsv_ok = current == new_text
|
||||||
|
if tsv_ok:
|
||||||
|
print(f"OK: {tsv_path} matches md tree ({len(rows)} rows)")
|
||||||
|
return 0
|
||||||
|
if not tsv_ok:
|
||||||
|
cur_rows = read_tsv(tsv_path)[1:]
|
||||||
|
cur_keys = {tuple(r) for r in cur_rows}
|
||||||
|
new_keys = {tuple(r) for r in rows}
|
||||||
|
print(
|
||||||
|
f"MISMATCH: tsv out of date "
|
||||||
|
f"(tsv {len(cur_rows)} rows, md {len(rows)} rows; "
|
||||||
|
f"+{len(new_keys - cur_keys)} -{len(cur_keys - new_keys)}). "
|
||||||
|
f"Run without --check to rebuild.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
tsv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tsv_path.write_text(new_text, encoding="utf-8", newline="\n")
|
||||||
|
print(f"wrote {tsv_path}: {len(rows)} rows")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate compliant TSL codegen markdown from a YAML/JSON entry file.
|
||||||
|
|
||||||
|
The recording format is one leaf page: a `module` title, a relative `path`, and
|
||||||
|
a `functions` list. This script renders it to the markdown the codegen tree
|
||||||
|
stores, matching tools/tsl-codegen/STANDARD.md.
|
||||||
|
|
||||||
|
Tables are emitted as valid Markdown with single-space padding. Prettier may be
|
||||||
|
used optionally to align columns.
|
||||||
|
|
||||||
|
Input dispatch is by extension: .json parses with the stdlib (keeping the
|
||||||
|
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
|
||||||
|
script says so and points at the JSON path.
|
||||||
|
|
||||||
|
Entry schema (per function):
|
||||||
|
signature required verbatim, underscores/case untouched
|
||||||
|
desc required description; may contain multiple lines
|
||||||
|
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
|
||||||
|
params required when the signature takes args; omit for nullary
|
||||||
|
returns required return type
|
||||||
|
example optional fenced tsl block, pasted verbatim
|
||||||
|
Each param: name/type/desc required; optional (bool) -> `可选。` prefix;
|
||||||
|
values (list of {value, desc}) -> a `**name 取值**` enum section.
|
||||||
|
|
||||||
|
Usage (run from repo root):
|
||||||
|
python tools/tsl-codegen/scripts/generate.py entry.yml
|
||||||
|
python tools/tsl-codegen/scripts/generate.py entry.json \
|
||||||
|
--scope my-project
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def die(msg):
|
||||||
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def scope_name(value):
|
||||||
|
"""Validate a user-defined single directory name."""
|
||||||
|
if not value or value in {".", ".."} or "/" in value or "\\" in value:
|
||||||
|
raise argparse.ArgumentTypeError("scope 必须是单级目录名")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_format(path, fmt):
|
||||||
|
"""Pick the parser: explicit --format wins, else derive from extension."""
|
||||||
|
if fmt:
|
||||||
|
return fmt
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".json":
|
||||||
|
return "json"
|
||||||
|
if suffix in (".yml", ".yaml"):
|
||||||
|
return "yaml"
|
||||||
|
die(
|
||||||
|
f"cannot infer format from extension '{suffix}'; "
|
||||||
|
f"pass --format json|yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_entries(path, fmt=None):
|
||||||
|
"""Parse a recording file as JSON (stdlib) or YAML (pyyaml).
|
||||||
|
|
||||||
|
Format is chosen by --format when given, else by file extension.
|
||||||
|
"""
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
fmt = resolve_format(path, fmt)
|
||||||
|
if fmt == "json":
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
die(f"invalid JSON in {path}: {exc}")
|
||||||
|
if fmt == "yaml":
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
die(
|
||||||
|
"pyyaml is not installed; either `pip install pyyaml` or "
|
||||||
|
"convert the input to .json (json parses with the stdlib)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
die(f"invalid YAML in {path}: {exc}")
|
||||||
|
die(f"unknown format '{fmt}'; use json or yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def escape_cell(text):
|
||||||
|
"""Escape `|` so a value stays inside one markdown table cell."""
|
||||||
|
return str(text).replace("|", "\\|")
|
||||||
|
|
||||||
|
|
||||||
|
def require(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
die(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def param_desc(param, where):
|
||||||
|
"""Description column text: prepend `可选。` for optional params."""
|
||||||
|
desc = param.get("desc")
|
||||||
|
require(desc, f"{where}: param '{param.get('name', '?')}' missing desc")
|
||||||
|
if param.get("optional") and not desc.startswith("可选。"):
|
||||||
|
return "可选。" + desc
|
||||||
|
return desc
|
||||||
|
|
||||||
|
|
||||||
|
def render_param_table(params, where):
|
||||||
|
"""Three-column 参数/类型/说明 table with single-space padding."""
|
||||||
|
lines = ["| 参数 | 类型 | 说明 |", "| --- | --- | --- |"]
|
||||||
|
for param in params:
|
||||||
|
name = param.get("name")
|
||||||
|
ptype = param.get("type")
|
||||||
|
require(name, f"{where}: a param is missing 'name'")
|
||||||
|
require(ptype, f"{where}: param '{name}' missing 'type'")
|
||||||
|
desc = param_desc(param, where)
|
||||||
|
lines.append(
|
||||||
|
f"| `{escape_cell(name)}` | {escape_cell(ptype)} | {escape_cell(desc)} |"
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_enum_sections(params):
|
||||||
|
"""`**name 取值**` sections for every param carrying a `values` list."""
|
||||||
|
lines = []
|
||||||
|
for param in params:
|
||||||
|
values = param.get("values")
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
lines.append(f"**{param['name']} 取值**")
|
||||||
|
lines.append("")
|
||||||
|
for item in values:
|
||||||
|
lines.append(f"- `{item['value']}` — {item['desc']}")
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_function(fn, index):
|
||||||
|
"""Render one function entry to a list of lines (no trailing blank)."""
|
||||||
|
where = f"functions[{index}]"
|
||||||
|
sig = fn.get("signature")
|
||||||
|
require(sig, f"{where}: missing 'signature'")
|
||||||
|
desc = fn.get("desc")
|
||||||
|
require(desc, f"{where} ({sig}): missing 'desc'")
|
||||||
|
returns = fn.get("returns")
|
||||||
|
require(returns, f"{where} ({sig}): missing 'returns'")
|
||||||
|
|
||||||
|
lines = [f"## `{sig}`"]
|
||||||
|
tags = fn.get("tags")
|
||||||
|
if tags:
|
||||||
|
lines.append(f"<!-- tags: {' '.join(str(t) for t in tags)} -->")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(desc)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
params = fn.get("params") or []
|
||||||
|
if params:
|
||||||
|
lines.extend(render_param_table(params, f"{where} ({sig})"))
|
||||||
|
lines.append("")
|
||||||
|
lines.extend(render_enum_sections(params))
|
||||||
|
|
||||||
|
lines.append(f"返回:{returns}")
|
||||||
|
|
||||||
|
example = fn.get("example")
|
||||||
|
if example:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### 示例")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("```tsl")
|
||||||
|
lines.extend(example.rstrip("\n").split("\n"))
|
||||||
|
lines.append("```")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(data):
|
||||||
|
"""Render a whole leaf page: H1 + every function entry."""
|
||||||
|
require(isinstance(data, dict), "input root must be a mapping")
|
||||||
|
module = data.get("module")
|
||||||
|
require(module, "input missing 'module'")
|
||||||
|
functions = data.get("functions")
|
||||||
|
require(functions, "input missing non-empty 'functions'")
|
||||||
|
|
||||||
|
out = [f"# {module}", ""]
|
||||||
|
for index, fn in enumerate(functions):
|
||||||
|
out.extend(render_function(fn, index))
|
||||||
|
out.append("")
|
||||||
|
return "\n".join(out).rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def output_path(data, scope):
|
||||||
|
"""Build the leaf-page destination from the recording file's relative path."""
|
||||||
|
relative = data.get("path")
|
||||||
|
require(relative, "input missing 'path'")
|
||||||
|
require(isinstance(relative, str), "'path' must be a string")
|
||||||
|
require("\\" not in relative, "'path' must use '/' as the separator")
|
||||||
|
relative_path = Path(relative)
|
||||||
|
require(not relative_path.is_absolute(), "'path' must be relative")
|
||||||
|
require(".." not in relative_path.parts, "'path' must not contain '..'")
|
||||||
|
require(relative_path.suffix == "", "'path' must not include a file extension")
|
||||||
|
return (
|
||||||
|
Path("skills/tsl-api-reference/references/codegen")
|
||||||
|
/ scope
|
||||||
|
/ relative_path.with_suffix(".md")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
parser = argparse.ArgumentParser(description="从 YAML/JSON 录入文件生成 TSL 函数文档")
|
||||||
|
parser.add_argument(
|
||||||
|
"input",
|
||||||
|
metavar="INPUT_FILE",
|
||||||
|
help="YAML/JSON 录入文件路径,例如 tmp/my-functions.yaml",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scope",
|
||||||
|
type=scope_name,
|
||||||
|
default="project",
|
||||||
|
help="codegen 下的一级目录,可自定义(默认:project)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--format",
|
||||||
|
choices=["json", "yaml"],
|
||||||
|
help="录入文件格式;默认根据文件扩展名判断",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
in_path = Path(args.input)
|
||||||
|
if not in_path.is_file():
|
||||||
|
die(f"input not found: {in_path}")
|
||||||
|
data = load_entries(in_path, args.format)
|
||||||
|
|
||||||
|
text = render_page(data)
|
||||||
|
out_path = output_path(data, args.scope)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(text, encoding="utf-8", newline="\n")
|
||||||
|
print(
|
||||||
|
f"wrote {out_path}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_PATH = (
|
||||||
|
Path(__file__).resolve().parents[1]
|
||||||
|
/ "scripts"
|
||||||
|
/ "build_index.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"tsl_codegen_function_index", SCRIPT_PATH
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class FunctionIndexTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.skill_dir = Path(self.temp_dir.name) / "tsl-api-reference"
|
||||||
|
self.codegen_root = self.skill_dir / "references" / "codegen"
|
||||||
|
self.data_dir = self.skill_dir / "data"
|
||||||
|
leaf = self.codegen_root / "builtin" / "base" / "array.md"
|
||||||
|
leaf.parent.mkdir(parents=True)
|
||||||
|
leaf.write_text(
|
||||||
|
"# Builtin - 基础 / 数组\n\n"
|
||||||
|
"## `demo()`\n\n"
|
||||||
|
"<!-- tags: 数组 列表 -->\n\n"
|
||||||
|
"返回示例值。\n\n"
|
||||||
|
"返回:integer\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.module = load_script()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_main(self, *args):
|
||||||
|
stdout = io.StringIO()
|
||||||
|
stderr = io.StringIO()
|
||||||
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
||||||
|
result = self.module.main(["--skill-dir", str(self.skill_dir), *args])
|
||||||
|
return result, stdout.getvalue(), stderr.getvalue()
|
||||||
|
|
||||||
|
def test_rebuild_writes_only_tsv(self):
|
||||||
|
result, _, _ = self.run_main()
|
||||||
|
|
||||||
|
self.assertEqual(0, result)
|
||||||
|
self.assertTrue((self.data_dir / "function_index.tsv").is_file())
|
||||||
|
self.assertEqual([], list(self.codegen_root.rglob("index.md")))
|
||||||
|
|
||||||
|
def test_tags_and_summary_are_stored_separately(self):
|
||||||
|
rows = self.module.build_rows(self.codegen_root)
|
||||||
|
row = dict(zip(self.module.HEADER, rows[0]))
|
||||||
|
|
||||||
|
self.assertEqual("数组 列表", row["tags"])
|
||||||
|
self.assertEqual("返回示例值。", row["summary"])
|
||||||
|
|
||||||
|
def test_check_does_not_require_index_pages(self):
|
||||||
|
self.data_dir.mkdir(parents=True)
|
||||||
|
rows = self.module.build_rows(self.codegen_root)
|
||||||
|
(self.data_dir / "function_index.tsv").write_text(
|
||||||
|
self.module.render_tsv(rows),
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
result, stdout, _ = self.run_main("--check")
|
||||||
|
|
||||||
|
self.assertEqual(0, result)
|
||||||
|
self.assertIn("matches md tree", stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[1] / "scripts" / "generate.py"
|
||||||
|
|
||||||
|
|
||||||
|
class DocGenCliTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.input = self.root / "entry.json"
|
||||||
|
self.input.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"module": "项目 / 示例",
|
||||||
|
"path": "base/my_functions",
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"signature": "demo()",
|
||||||
|
"desc": "示例函数。",
|
||||||
|
"returns": "nil",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_cli(self, *args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(self.input), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
cwd=self.root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_scope_writes_configured_path_under_project(self):
|
||||||
|
result = self.run_cli()
|
||||||
|
output = (
|
||||||
|
self.root
|
||||||
|
/ "skills"
|
||||||
|
/ "tsl-api-reference"
|
||||||
|
/ "references"
|
||||||
|
/ "codegen"
|
||||||
|
/ "project"
|
||||||
|
/ "base"
|
||||||
|
/ "my_functions.md"
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertTrue(output.is_file())
|
||||||
|
self.assertTrue(output.read_text(encoding="utf-8").startswith("# 项目 / 示例\n"))
|
||||||
|
|
||||||
|
def test_custom_scope_changes_first_destination_directory(self):
|
||||||
|
result = self.run_cli("--scope", "my-project")
|
||||||
|
output = (
|
||||||
|
self.root
|
||||||
|
/ "skills"
|
||||||
|
/ "tsl-api-reference"
|
||||||
|
/ "references"
|
||||||
|
/ "codegen"
|
||||||
|
/ "my-project"
|
||||||
|
/ "base"
|
||||||
|
/ "my_functions.md"
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertTrue(output.is_file())
|
||||||
|
|
||||||
|
def test_output_option_is_rejected(self):
|
||||||
|
result = self.run_cli("--output", str(self.root / "out.md"))
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("unrecognized arguments: --output", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[1] / "scripts" / "lint.py"
|
||||||
|
VALID_PAGE = "# 项目 / 示例\n\n## `demo()`\n\n示例函数\n\n返回:nil\n"
|
||||||
|
|
||||||
|
|
||||||
|
class DocLintCliTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.page = self.root / "base" / "demo.md"
|
||||||
|
self.page.parent.mkdir()
|
||||||
|
self.page.write_text(VALID_PAGE, encoding="utf-8")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def run_cli(self, *args):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *map(str, args)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_accepts_one_markdown_file(self):
|
||||||
|
result = self.run_cli("--file", self.page)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("1 files", result.stderr)
|
||||||
|
|
||||||
|
def test_accepts_one_directory(self):
|
||||||
|
result = self.run_cli("--dir", self.root)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("1 files", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "skills"
|
||||||
|
/ "tsl-api-reference"
|
||||||
|
/ "scripts"
|
||||||
|
/ "lookup.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_script():
|
||||||
|
spec = importlib.util.spec_from_file_location("tsl_lookup", SCRIPT)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class LookupTest(unittest.TestCase):
|
||||||
|
def test_keyword_search_includes_tags_and_summary(self):
|
||||||
|
module = load_script()
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"name": "demo",
|
||||||
|
"signature": "demo()",
|
||||||
|
"module": "base",
|
||||||
|
"tags": "数组 列表",
|
||||||
|
"summary": "返回示例值",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(rows, module.search_keyword(rows, ["数组"]))
|
||||||
|
self.assertEqual(rows, module.search_keyword(rows, ["返回示例"]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user