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:
csh
2026-07-20 09:08:38 +08:00
parent 1d5304e7b6
commit c69278283f
18 changed files with 13955 additions and 12821 deletions
+1
View File
@@ -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
View File
@@ -1 +1 @@
skills/**
skills/thirdparty/**
+6
View File
@@ -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 规范部署到项目内,并用统一入口执行。
+7 -3
View File
@@ -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:
-361
View File
@@ -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())
File diff suppressed because it is too large Load Diff
@@ -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()
+230
View File
@@ -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 可以由维护者在自己的
版本库中管理
+206
View File
@@ -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)
+53
View File
@@ -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)"
}
]
}
+39
View File
@@ -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)
+191
View File
@@ -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())
+246
View File
@@ -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())
+267
View File
@@ -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()
+85
View File
@@ -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()
+43
View File
@@ -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()
+40
View File
@@ -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()