✨ 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"
|
||||
"skills/tsl-syntax-reference"
|
||||
"skills/tsl-api-reference"
|
||||
"tools/tsl-codegen"
|
||||
)
|
||||
for path in "${managed_paths[@]}"; do
|
||||
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)。
|
||||
|
||||
## 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 规范部署到项目内,并用统一入口执行。
|
||||
|
||||
@@ -19,12 +19,13 @@ def build_agents_text(ruleset_path: Path) -> str:
|
||||
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"
|
||||
syntax_skill = repo_root / "skills" / "tsl-syntax-reference"
|
||||
api_skill = repo_root / "skills" / "tsl-api-reference"
|
||||
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()]
|
||||
if 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:
|
||||
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)
|
||||
|
||||
(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(syntax_skill, output / "skills" / "tsl-syntax-reference")
|
||||
copy_tree(api_skill, output / "skills" / "tsl-api-reference")
|
||||
copy_tree(codegen_toolkit, output / "tools" / "tsl-codegen")
|
||||
|
||||
|
||||
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
|
||||
_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
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -55,6 +55,7 @@ def search_keyword(rows, terms):
|
||||
row.get("name", ""),
|
||||
row.get("signature", ""),
|
||||
row.get("module", ""),
|
||||
row.get("tags", ""),
|
||||
row.get("summary", ""),
|
||||
]
|
||||
).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