feat(tsl-codegen): refine generation and metadata enrichment

This commit is contained in:
csh
2026-08-11 18:11:32 +08:00
parent 7bd965de89
commit 7cb8b130bb
6 changed files with 1728 additions and 6 deletions
+6 -2
View File
@@ -141,12 +141,14 @@ function 重载和跨声明种类同名允许。
枚举参数在参数表之后、返回类型之前列出:
```markdown
**mode 取值**
mode 取值
- `0` — 原样返回
- `1` — 去重
```
顶级 function 的参数取值使用上述普通文本行,不使用 H3、H4 或强调文本。class method 的参数取值使用 H4 标题,例如 `#### \`mode\` 取值`unit interface class method 使用 H5 标题。参数取值标题不是 API 声明,不进入统一索引
#### 返回类型
每个顶层 function 必须包含非空返回类型:
@@ -158,6 +160,8 @@ function 重载和跨声明种类同名允许。
#### 示例代码
- 每个示例以“范例NN:说明”开头,编号按出现顺序排列并至少保留两位
- 同一 function 只使用一个示例组标题;顶级 function 使用 `### 示例`class method 使用 `#### 示例`unit interface class method 使用 `##### 示例`
- “范例NN:说明”是示例组内的普通文本,不使用 H4 或 H5 标题
- 代码围栏使用 `tsl`
- 一个代码围栏只放一个独立示例
- 字符串使用直引号 `'` 或 `"`
@@ -198,7 +202,7 @@ return demoLines();
| `factor` | float | 可选。默认 1.0,结果乘以该系数 |
| `...` | nil\|array | 可选。需要追加处理的其他数组 |
**mode 取值**
mode 取值
- `0` — 原样返回
- `1` — 去重
@@ -0,0 +1,905 @@
#!/usr/bin/env python3
"""Conservatively enrich TSL API descriptions and search tags.
Markdown is the fact source. This tool never changes API headings, signatures,
parameter tables, return types, examples, or scope placement. It only updates
the first prose description line and the optional ``<!-- tags: ... -->`` line.
"""
from __future__ import annotations
import argparse
import csv
import html
import re
import sys
import unicodedata
from collections import defaultdict
from dataclasses import dataclass, field, replace
from pathlib import Path
from bs4 import BeautifulSoup
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
ACCESS_RE = re.compile(r"^访问[:]\s*(.*?)\s*$", re.IGNORECASE)
H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
TERMINAL_PUNCTUATION_RE = re.compile(r"[。..!??;;,:]+$")
MULTISPACE_RE = re.compile(r"\s+")
FORMULA_RE = re.compile(
r"^([^。;;,]{2,40}?)\s*(?<![<>=!])=(?!=)\s*(.+)$"
)
COMPARISON_RE = re.compile(r"<=|>=|==|!=|<|>")
RELATED_FUNCTION_RE = re.compile(
r"^([A-Za-z][A-Za-z0-9_]*)\s*相关函数$"
)
ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,15})(?![A-Za-z0-9_])")
ACTION_CLAUSE_RE = re.compile(
r"^(?:用于|如果|由于|若(?!干)|支持|查询|输入|按照|按(?!钮)|与系统参数|"
r"对.+?(?:进行|使用)|从.+?(?:获取|读取|计算|返回)|"
r"由.+?(?:倒推|获取|计算|得到|生成))"
)
PROHIBITED_PROCESS_RE = re.compile(
r"Source\s*ID|来源[:]|已验证|验证通过|not[_ ]?found|"
r"测试过程|探测结果|官网定义",
re.IGNORECASE,
)
GENERIC_SUMMARIES = {
"函数",
"方法",
"属性",
"常量",
"接口",
"对象",
"",
"内置函数",
"内部函数",
"统计分布相关函数",
}
GENERIC_TAGS = {
"函数",
"方法",
"属性",
"常量",
"数据",
"tsl",
"builtin",
"dotnet",
"third",
"deprecated",
"function",
"method",
"property",
}
CLASS_DESCRIPTIONS = {
"imap": "用于连接 IMAP 服务器、管理邮箱并读取邮件的内置对象",
"mailmsg": "用于创建、解析、保存和发送邮件消息的内置对象",
"messagepart": "表示邮件正文、附件等消息分段的内置对象",
"pop3": "用于连接 POP3 服务器并读取或删除邮件的内置对象",
"smtp": "用于连接 SMTP 服务器并发送邮件的内置对象",
"tcipher": "提供对称加密和解密能力的内置对象",
"tcookie": "表示 HTTP Cookie 名称、值和属性的内置对象",
"tcookiecollection": "用于创建、查找和管理 HTTP Cookie 集合的内置对象",
"tfilestream": "以文件为存储后端并提供流式读写能力的内置对象",
"thandlestream": "封装系统句柄并提供流式读写能力的内置对象",
"thashedstringlist": "使用哈希索引加速查找的字符串列表内置对象",
"tinifile": "用于读取和写入 INI 配置文件的内置对象",
"tmeminifile": "在内存中读取、修改和保存 INI 配置的内置对象",
"tmemorystream": "以内存缓冲区保存和操作流数据的内置对象",
"tregistryinifile": "以系统注册表为存储后端的 INI 配置内置对象",
"trsa": "提供 RSA 非对称加密、解密和签名能力的内置对象",
"tsession": "表示服务端会话及其键值数据的内置对象",
"tsessionman": "用于创建、查找、维护和删除服务端会话的内置对象",
"tstream": "定义顺序读写、定位和复制操作的流基类",
"tstringlist": "提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
"twebrequest": "封装 HTTP 请求头、请求参数、Cookie 和请求正文的内置对象",
"twebresponse": "用于设置 HTTP 状态、响应头、Cookie 和响应正文的内置对象",
}
GENERIC_DESCRIPTION_OVERRIDES = {
"unicodeesc2": "将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
"sf_x2inv": "根据累计概率计算卡方分布的逆函数值",
"sf_tinv": "根据累计概率计算 t 分布的逆函数值",
"sf_sinx": "计算正弦积分",
"sf_normalinv2": "根据累计概率计算标准正态分布的逆函数值",
"sf_normalinv": "根据累计概率计算正态分布的逆函数值",
"sf_normal": "计算正态分布函数值",
"sf_gammainv": "根据累计概率计算标准 Gamma 分布的逆函数值",
"sf_gamma": "计算伽马函数值",
"sf_finv": "根据累计概率计算 F 分布的逆函数值",
"sf_ex": "计算指数积分",
"sf_error": "计算误差函数(概率函数)值",
"sf_cosx": "计算余弦积分",
"sf_betainv": "根据累计概率计算贝塔分布的逆函数值",
"sf_beta": "计算贝塔函数值",
"readfile": "读取本地文件中的数据",
"millisecondspan": "计算两个日期时间之间相差的毫秒数",
"importfile": "从文件中导入数据",
"getmsgdigest": "计算字符串或文件的摘要并返回摘要字符串",
"exportfile": "将数据导出到文件",
}
AWKWARD_DESCRIPTION_OVERRIDES = {
"encodegraph": "根据指定的类型、名称、数据和属性数组生成图形并返回",
"slndepreciation": (
"按直线折旧法计算每期折旧额,计算公式为"
"(资产原值 Cost - 资产残值 Salvage/ 折旧周期数 Life"
),
"format": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"ansiformat": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"dupestring": "将字符串 AText 重复 ACount 次并返回结果",
"stockpjcj": "返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
}
LEADING_VERBS = (
"返回",
"获取",
"读取",
"写入",
"设置",
"创建",
"删除",
"移除",
"清除",
"添加",
"插入",
"查找",
"搜索",
"判断",
"检查",
"验证",
"计算",
"转换",
"解析",
"格式化",
"比较",
"执行",
"调用",
"连接",
"关闭",
"打开",
"发送",
"接收",
"保存",
"加载",
"列出",
"表示",
"提供",
"释放",
"交换",
"排序",
"过滤",
"统计",
"控制",
"指示",
"提取",
"根据",
"",
"",
"生成",
"构造",
"采用",
"利用",
"输出",
"得到",
"获得",
"取得",
"新建",
)
@dataclass(frozen=True)
class SourceRecord:
corpus: str
page_id: str
title: str
page: Path
description: str = ""
@dataclass(frozen=True)
class EntryContext:
name: str
signature: str
kind: str
scope: str
page: str
summary: str
access: str = ""
owner: str = ""
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class PageContext:
title: str
path: str
@dataclass
class AuditChange:
page: str
name: str
kind: str
old_description: str
new_description: str
old_tags: tuple[str, ...]
new_tags: tuple[str, ...]
sources: tuple[str, ...]
@dataclass
class Audit:
entries: int = 0
source_matches: int = 0
ambiguous_sources: int = 0
changed_descriptions: int = 0
changed_tags: int = 0
changes: list[AuditChange] = field(default_factory=list)
def merge(self, other: "Audit") -> None:
self.entries += other.entries
self.source_matches += other.source_matches
self.ambiguous_sources += other.ambiguous_sources
self.changed_descriptions += other.changed_descriptions
self.changed_tags += other.changed_tags
self.changes.extend(other.changes)
def normalize_key(value: str) -> str:
return unicodedata.normalize("NFKC", value).casefold().strip()
def clean_text(value: str) -> str:
value = html.unescape(value).replace("\xa0", " ")
value = value.replace("", '"').replace("", '"')
value = value.replace("", "'").replace("", "'")
return MULTISPACE_RE.sub(" ", value).strip()
def strip_terminal_punctuation(value: str) -> str:
return TERMINAL_PUNCTUATION_RE.sub("", clean_text(value)).rstrip()
def safe_source_description(value: str) -> str:
value = strip_terminal_punctuation(value)
if not value or PROHIBITED_PROCESS_RE.search(value):
return ""
return value
def _extract_source_description(page: Path) -> str:
try:
soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")
except (OSError, UnicodeError):
return ""
root = soup.select_one("#help_content") or soup
for expected in ("简述", "说明"):
for label in root.select(".DescriteTitle"):
if clean_text(label.get_text(" ", strip=True)) != expected:
continue
marker = label.parent if label.parent is not None else label
sibling = marker.find_next_sibling()
if sibling is None:
sibling = label.find_next_sibling()
if sibling is not None:
return safe_source_description(sibling.get_text(" ", strip=True))
return ""
class SourceCatalog:
PREFERENCES = {
"builtin": ("tsl_base", "net_function", "knowledge_base"),
"third": ("net_function", "tsl_base", "knowledge_base"),
"deprecated": ("net_function", "tsl_base", "knowledge_base"),
"dotnet": ("net_function", "tsl_base", "knowledge_base"),
}
def __init__(self, records: dict[str, list[SourceRecord]] | None = None):
self.records = records or {}
self._description_cache: dict[Path, str] = {}
@classmethod
def empty(cls) -> "SourceCatalog":
return cls({})
@classmethod
def from_docs_root(cls, docs_root: Path) -> "SourceCatalog":
records: dict[str, list[SourceRecord]] = defaultdict(list)
for corpus in ("net_function", "tsl_base", "knowledge_base"):
root = docs_root / corpus
manifest = root / "manifest.tsv"
if not manifest.is_file():
continue
with manifest.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle, delimiter="\t"):
if row.get("status") != "ok" or not row.get("title"):
continue
record = SourceRecord(
corpus=corpus,
page_id=row.get("id", ""),
title=row["title"],
page=root / row.get("path", ""),
)
records[normalize_key(record.title)].append(record)
return cls(dict(records))
def _load_description(self, record: SourceRecord) -> SourceRecord:
if record.description:
return record
if record.page not in self._description_cache:
self._description_cache[record.page] = _extract_source_description(
record.page
)
return replace(
record, description=self._description_cache[record.page]
)
def match(self, entry: EntryContext) -> list[SourceRecord]:
matches = self.records.get(normalize_key(entry.name), [])
preference = self.PREFERENCES.get(
entry.scope,
("net_function", "tsl_base", "knowledge_base"),
)
rank = {name: index for index, name in enumerate(preference)}
def page_order(record: SourceRecord):
try:
page_id = (0, int(record.page_id))
except ValueError:
page_id = (1, record.page_id)
return rank.get(record.corpus, 99), page_id
return sorted(matches, key=page_order)
def describe(self, records: list[SourceRecord]) -> list[SourceRecord]:
return [self._load_description(record) for record in records]
def _starts_with_verb(value: str) -> bool:
return value.startswith(LEADING_VERBS) or bool(
ACTION_CLAUSE_RE.match(value)
)
def _choose_source_description(
current: str, sources: list[SourceRecord]
) -> str:
candidates = [
safe_source_description(record.description) for record in sources
]
candidates = [value for value in candidates if value]
if not candidates:
return current
if len(sources) == 1 and current in GENERIC_SUMMARIES:
candidate = candidates[0]
if candidate not in GENERIC_SUMMARIES and len(candidate) > len(current):
return candidate
if PROHIBITED_PROCESS_RE.search(current):
return candidates[0]
return current
def _property_description(value: str, access: str) -> str:
if _starts_with_verb(value):
return value
if (
len(value) > 30
or any(mark in value for mark in "。;,")
or value.startswith(("功能同", "只读", "用于"))
):
return value
normalized = normalize_key(access).replace(" ", "")
if value.startswith("是否"):
return (
f"控制{value}"
if "write" in normalized
else f"指示{value}"
)
if "read" in normalized and "write" in normalized:
return f"获取或设置{value}"
if "write" in normalized:
return f"设置{value}"
return f"获取{value}"
def _name_intent(name: str) -> str:
key = normalize_key(name)
rules = (
(("is", "if", "has", "can", "check", "valid"), "判断"),
(("get", "find", "query", "search"), "获取"),
(("read", "load"), "读取"),
(("write", "save", "export"), "写入"),
(("set",), "设置"),
(("create", "make", "new"), "创建"),
(("delete", "remove", "drop"), "删除"),
(("clear", "reset"), "清除"),
(("add", "append", "insert"), "添加"),
(("parse", "decode"), "解析"),
(("encode", "convert", "to"), "转换"),
(("format",), "格式化"),
(("calc", "compute"), "计算"),
(("open",), "打开"),
(("close",), "关闭"),
(("send", "post"), "发送"),
(("connect", "login"), "连接"),
(("list",), "列出"),
)
for prefixes, intent in rules:
if key.startswith(prefixes):
return intent
return ""
def improve_description(
entry: EntryContext, sources: list[SourceRecord]
) -> str:
current = strip_terminal_punctuation(entry.summary)
current = _choose_source_description(current, sources)
current = strip_terminal_punctuation(current)
if not current:
return entry.summary
if entry.kind == "class":
replacement = CLASS_DESCRIPTIONS.get(normalize_key(entry.name))
if replacement and re.fullmatch(
rf"{re.escape(entry.name)}\s*内置对象", current, re.IGNORECASE
):
return replacement
if entry.kind == "property":
return _property_description(current, entry.access)
key = normalize_key(entry.name)
generic_override = GENERIC_DESCRIPTION_OVERRIDES.get(key)
if generic_override and current.endswith("相关函数"):
return generic_override
awkward_override = AWKWARD_DESCRIPTION_OVERRIDES.get(key)
if awkward_override and current.startswith(
("返回根据", "返回将", "返回:", "返回:")
):
return awkward_override
current = re.sub(
r"^(?:该|本)函数(?:主要(?:是)?)?\s*", "", current
)
current = re.sub(r"^功能[:]\s*", "", current)
if current.startswith("是否"):
current = f"判断{current}"
for old, new in (
("读出", "读取"),
("取出", "获取"),
("得到", "获取"),
("获得", "获取"),
("取得", "获取"),
("新建", "创建"),
):
if current.startswith(old):
current = new + current[len(old):]
break
if current.startswith("") and not current.startswith("读取"):
current = "读取" + current[1:]
if current.startswith("") and not current.startswith("写入"):
current = "写入" + current[1:]
current = current.replace("读出", "读取")
current = current.replace("装载内容", "加载内容")
related_function = RELATED_FUNCTION_RE.fullmatch(current)
if related_function:
return f"返回 {related_function.group(1)} 对应的数据"
formula = FORMULA_RE.match(current)
page_key = normalize_key(entry.page)
if (
formula
and ("/financial/" in page_key or "/financial_report/" in page_key)
and not _starts_with_verb(current)
):
left, right = formula.groups()
return f"计算{left.strip()},公式为{right.strip()}"
if _starts_with_verb(current):
return current
if "/financial/" in page_key or "/financial_report/" in page_key:
if (
re.match(r"^\d+(?:\s|[..、)])", current)
or COMPARISON_RE.search(current)
):
return current
return f"返回{current}"
intent = _name_intent(entry.name)
if intent and len(current) <= 28 and not any(
verb in current[:16] for verb in LEADING_VERBS
):
return f"{intent}{current}"
return current
DOMAIN_RULES = (
(r"数组|列表|(?:^|[^a-z])(?:array|fmarray|list)(?:$|[^a-z])", ("数组", "列表", "array", "list")),
(r"矩阵|(?:^|[^a-z])matrix(?:$|[^a-z])|\bmt_", ("矩阵", "matrix")),
(r"字符串|文本|(?:^|[^a-z])(?:string|char|text)(?:$|[^a-z])", ("字符串", "文本", "string", "text")),
(r"日期|时间|(?:^|[^a-z])(?:datetime|date|time)(?:$|[^a-z])", ("日期时间", "日期", "时间", "datetime")),
(r"文件|目录|路径|(?:^|[^a-z])(?:file|folder|directory|path)(?:$|[^a-z])", ("文件", "目录", "路径", "file")),
(r"网络|(?:^|[^a-z])(?:http|https|cgi|cookie|url)(?:$|[^a-z])", ("网络", "HTTP", "请求", "network")),
(r"缓存|(?:^|[^a-z])cache(?:$|[^a-z])", ("缓存", "缓存管理", "cache")),
(r"数据库|(?:^|[^a-z])(?:sql|dbf|ini)(?:$|[^a-z])", ("数据库", "SQL", "database")),
(r"进程|线程|(?:^|[^a-z])(?:process|thread|pipe)(?:$|[^a-z])", ("进程", "线程", "process")),
(r"颜色|(?:^|[^a-z])(?:color|rgb|cmyk)(?:$|[^a-z])", ("颜色", "RGB", "color")),
(r"图形|图表|(?:^|[^a-z])(?:graph|chart)(?:$|[^a-z])", ("图形", "图表", "graph")),
(r"统计|概率|分布|(?:^|[^a-z])(?:statistics|cdf|pdf)(?:$|[^a-z])", ("统计", "概率", "statistics")),
(r"优化|线性规划|(?:^|[^a-z])optimization(?:$|[^a-z])", ("优化", "求解", "optimization")),
(r"数值|数学|(?:^|[^a-z])(?:numeric|math)(?:$|[^a-z])", ("数学", "数值", "numeric")),
(r"类型转换|(?:^|[^a-z])(?:convert|conversion)(?:$|[^a-z])", ("类型转换", "转换", "conversion")),
(r"对象|(?:^|[^a-z])(?:object|class)(?:$|[^a-z])", ("对象", "实例", "object")),
(r"(?:^|[^a-z])(?:com|ole)(?:$|[^a-z])|activex", ("COM", "OLE", "自动化")),
(r"(?:^|[^a-z])ftp(?:$|[^a-z])", ("FTP", "文件传输")),
(r"邮件|(?:^|[^a-z])(?:smtp|pop3|imap|mail)(?:$|[^a-z])", ("邮件", "SMTP", "POP3", "IMAP")),
(r"财务|金融|证券|股票|基金|债券|期货|期权", ("金融",)),
(r"报表|报告期|report", ("报表", "报告期")),
)
SPECIAL_TAG_RULES = (
(r"去重|删除重复|dropduplicate|dedup", ("去重", "删除重复", "deduplicate")),
(r"哈希索引|哈希表", ("哈希索引", "哈希表", "hash")),
(
r"摘要|(?:^|[^a-z0-9_])(?:crc32|md5|sha1|sha224|sha256|sha384|sha512|sm3|digest|hash)(?:$|[^a-z0-9_])",
("摘要", "哈希", "散列", "digest", "hash"),
),
(r"排序|sort", ("排序", "sort")),
(r"过滤|筛选|filter", ("过滤", "筛选", "filter")),
(r"查找|搜索|find|search|lookup", ("查找", "搜索", "lookup")),
)
INTENT_TAGS = {
"返回": ("返回", "获取"),
"获取": ("获取", "查询"),
"读取": ("读取", "获取"),
"写入": ("写入", "保存"),
"设置": ("设置", "修改"),
"创建": ("创建", "生成"),
"删除": ("删除", "移除"),
"清除": ("清除", "重置"),
"添加": ("添加", "追加"),
"查找": ("查找", "搜索"),
"判断": ("判断", "检查"),
"计算": ("计算", "求解"),
"转换": ("转换", "编码"),
"解析": ("解析", "parse"),
"格式化": ("格式化", "format"),
"打开": ("打开", "open"),
"关闭": ("关闭", "close"),
"发送": ("发送", "提交"),
"连接": ("连接", "登录"),
"列出": ("列出", "列表"),
"输出": ("输出", "打印"),
}
def _taxonomy_tags(title: str) -> list[str]:
result = []
for raw in re.split(r"\s*/\s*|\s+-\s+", title):
value = raw.strip(" #")
value = re.sub(r"\([^)]*\)|[^]*", "", value).strip()
for suffix in ("相关函数", "及其实现", "相关"):
if value.endswith(suffix):
value = value[: -len(suffix)].rstrip()
if not value or normalize_key(value) in GENERIC_TAGS:
continue
if len(value) > 18:
continue
chinese = "".join(re.findall(r"[\u3400-\u9fff]+", value))
latin = re.sub(r"[\u3400-\u9fff]+", " ", value).strip()
if latin and normalize_key(latin) not in GENERIC_TAGS:
result.extend(
re.findall(r"[A-Za-z][A-Za-z0-9_+.-]*", latin)
)
if chinese:
result.append(chinese)
return result
def _description_intent(summary: str) -> str:
value = strip_terminal_punctuation(summary)
replacements = {
"得到": "获取",
"获得": "获取",
"取得": "获取",
"新建": "创建",
}
for source, target in replacements.items():
if value.startswith(source):
return target
for intent in INTENT_TAGS:
if value.startswith(intent):
return intent
for intent in ("读取", "写入", "保存", "计算", "判断", "转换", "解析"):
if intent in value[:16]:
return "写入" if intent == "保存" else intent
return ""
def derive_tags(entry: EntryContext, page: PageContext) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
def add(value: str) -> None:
value = clean_text(value).strip()
key = normalize_key(value)
if (
not value
or key in seen
or key in GENERIC_TAGS
or len(tags) >= 12
):
return
seen.add(key)
tags.append(value)
for tag in entry.tags:
add(tag)
for tag in _taxonomy_tags(page.title):
add(tag)
haystack = " ".join(
(entry.name, entry.signature, entry.summary, page.title, page.path)
)
domain_haystack = " ".join((page.title, page.path))
for pattern, aliases in SPECIAL_TAG_RULES:
if re.search(pattern, haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
summary = strip_terminal_punctuation(entry.summary)
intent = _description_intent(summary)
if not intent and not _starts_with_verb(summary):
is_market_price = (
normalize_key(entry.name) in {"open", "close"}
and "盘价" in summary
)
if not is_market_price:
intent = _name_intent(entry.name)
for alias in INTENT_TAGS.get(intent, ()):
add(alias)
for pattern, aliases in DOMAIN_RULES:
if re.search(pattern, domain_haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
for acronym in ACRONYM_RE.findall(entry.summary):
add(acronym)
return tags
def _scope_for_page(page: str) -> str:
parts = Path(page).parts
return parts[0] if len(parts) > 1 else Path(page).stem
def _page_title(lines: list[str], page: str) -> PageContext:
for line in lines:
match = H1_RE.match(line)
if match:
return PageContext(match.group(1), page)
return PageContext(Path(page).stem, page)
def _entry_metadata(lines: list[str], start: int, end: int):
declaration_seen = False
description_index = None
tag_index = None
access = ""
for index in range(start + 1, end):
text = lines[index].strip()
if not text:
continue
if not declaration_seen:
if DECLARATION_LINE_RE.fullmatch(text):
declaration_seen = True
continue
tag_match = TAGS_RE.fullmatch(text)
if tag_match:
tag_index = index
continue
access_match = ACCESS_RE.fullmatch(text)
if access_match:
access = access_match.group(1)
continue
if description_index is None:
if text.startswith(("|", "#", "返回:", "类型:", "可见性:", "值:")):
continue
description_index = index
tags = ()
if tag_index is not None:
tags = tuple(TAGS_RE.fullmatch(lines[tag_index].strip()).group(1).split())
return description_index, tag_index, access, tags
def enrich_markdown(
text: str, page: str, source_catalog: SourceCatalog
) -> tuple[str, Audit]:
had_final_newline = text.endswith("\n")
lines = text.splitlines()
page_context = _page_title(lines, page)
entries = [entry for entry in iter_api_entries(lines) if entry.heading.valid]
audit = Audit(entries=len(entries))
root_owner = ""
owners: dict[int, str] = {}
for entry in entries:
if entry.heading.level == 2:
root_owner = entry.heading.name if entry.heading.kind in {"class", "unit"} else ""
owners[entry.start] = root_owner if entry.heading.level > 2 else ""
for api in reversed(entries):
description_index, tag_index, access, existing_tags = _entry_metadata(
lines, api.start, api.end
)
if description_index is None:
continue
old_description = lines[description_index].strip()
entry = EntryContext(
name=api.heading.name,
signature=api.heading.signature,
kind=api.heading.kind,
scope=_scope_for_page(page),
page=page,
summary=old_description,
access=access,
owner=owners.get(api.start, ""),
tags=existing_tags,
)
sources = source_catalog.match(entry)
if sources:
audit.source_matches += 1
if len(sources) > 1:
audit.ambiguous_sources += 1
evidence = sources
normalized_summary = strip_terminal_punctuation(old_description)
if (
normalized_summary in GENERIC_SUMMARIES
or PROHIBITED_PROCESS_RE.search(normalized_summary)
):
evidence = source_catalog.describe(sources)
new_description = improve_description(entry, evidence)
entry_for_tags = replace(entry, summary=new_description)
new_tags = tuple(derive_tags(entry_for_tags, page_context))
if new_description != old_description:
lines[description_index] = new_description
audit.changed_descriptions += 1
if new_tags != existing_tags:
rendered = f"<!-- tags: {' '.join(new_tags)} -->"
if tag_index is not None:
lines[tag_index] = rendered
else:
insert_at = description_index + 1
if insert_at < len(lines) and lines[insert_at].strip() == "":
insert_at += 1
lines[insert_at:insert_at] = [rendered, ""]
else:
lines[insert_at:insert_at] = ["", rendered, ""]
audit.changed_tags += 1
if new_description != old_description or new_tags != existing_tags:
audit.changes.append(
AuditChange(
page=page,
name=api.heading.name,
kind=api.heading.kind,
old_description=old_description,
new_description=new_description,
old_tags=existing_tags,
new_tags=new_tags,
sources=tuple(
f"{record.corpus}:{record.page_id}" for record in sources
),
)
)
result = "\n".join(lines)
if had_final_newline:
result += "\n"
return result, audit
def _write_report(path: Path, audit: Audit) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
writer.writerow(
(
"page",
"name",
"kind",
"old_description",
"new_description",
"old_tags",
"new_tags",
"sources",
)
)
for item in audit.changes:
writer.writerow(
(
item.page,
item.name,
item.kind,
item.old_description,
item.new_description,
" ".join(item.old_tags),
" ".join(item.new_tags),
" ".join(item.sources),
)
)
def _selected(page: str, scopes: set[str], prefixes: tuple[str, ...]) -> bool:
if scopes and _scope_for_page(page) not in scopes:
return False
return not prefixes or page.startswith(prefixes)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--audit", action="store_true", help="report changes only")
mode.add_argument("--apply", action="store_true", help="write proposed changes")
parser.add_argument("--root", type=Path, required=True, help="codegen Markdown root")
parser.add_argument(
"--docs-root", type=Path, required=True, help="cached tmp/docs root"
)
parser.add_argument("--report", type=Path, help="optional TSV audit report")
parser.add_argument("--scope", action="append", default=[])
parser.add_argument("--prefix", action="append", default=[])
args = parser.parse_args(argv)
catalog = SourceCatalog.from_docs_root(args.docs_root)
aggregate = Audit()
changed_files = 0
for md in sorted(args.root.rglob("*.md")):
page = md.relative_to(args.root).as_posix()
if not _selected(page, set(args.scope), tuple(args.prefix)):
continue
before_stat = md.stat()
original = md.read_text(encoding="utf-8")
enriched, audit = enrich_markdown(original, page, catalog)
aggregate.merge(audit)
if enriched == original:
continue
changed_files += 1
if args.apply:
after_stat = md.stat()
if (
after_stat.st_mtime_ns != before_stat.st_mtime_ns
or after_stat.st_size != before_stat.st_size
):
raise RuntimeError(f"target changed while processing: {md}")
md.write_text(enriched, encoding="utf-8", newline="\n")
if args.report:
_write_report(args.report, aggregate)
print(
"entries={entries} source_matches={source_matches} "
"ambiguous_sources={ambiguous_sources} changed_files={changed_files} "
"changed_descriptions={changed_descriptions} changed_tags={changed_tags}".format(
entries=aggregate.entries,
source_matches=aggregate.source_matches,
ambiguous_sources=aggregate.ambiguous_sources,
changed_files=changed_files,
changed_descriptions=aggregate.changed_descriptions,
changed_tags=aggregate.changed_tags,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -23,7 +23,7 @@ Function declaration fields:
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.
values (list of {value, desc}) -> a `name 取值` enum section.
Usage (run from repo root):
python tools/tsl-codegen/scripts/generate.py --file entry.yml
@@ -559,14 +559,14 @@ def render_param_table(params, where):
def render_enum_sections(params, heading_level=None):
"""Render value sections, preserving legacy function-page headings."""
"""Render standard value sections for top-level and nested callables."""
lines = []
for param in params:
values = param.get("values")
if not values:
continue
if heading_level is None:
lines.append(f"**{param['name']} 取值**")
lines.append(f"{param['name']} 取值")
else:
lines.append(f"{'#' * heading_level} `{param['name']}` 取值")
lines.append("")
@@ -0,0 +1,778 @@
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "enrich_metadata.py"
def load_script():
spec = importlib.util.spec_from_file_location("tsl_enrich_metadata", SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class SourceCatalogTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
self.temp_dir = tempfile.TemporaryDirectory()
self.docs = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def write_corpus(self, corpus, rows, descriptions):
root = self.docs / corpus
pages = root / "pages"
pages.mkdir(parents=True)
manifest = [
"id\ttitle\turl\tpath\tsource_bytes\tutf8_bytes\tsha256\tstatus"
]
for page_id, title in rows:
manifest.append(
f"{page_id}\t{title}\thttp://example/{page_id}\t"
f"pages/{page_id}.html\t1\t1\tsha\tok"
)
description = descriptions[page_id]
(pages / f"{page_id}.html").write_text(
"<div id='help_content'><h3>"
+ title
+ "</h3><div><div class='DescriteTitle'>简述</div></div>"
+ f"<div>{description}</div></div>",
encoding="utf-8",
)
(root / "manifest.tsv").write_text(
"\n".join(manifest) + "\n", encoding="utf-8"
)
def test_exact_matching_preserves_underscores_and_prefers_scope_corpus(self):
self.write_corpus(
"tsl_base",
[("1", "alpha_test")],
{"1": "基础解释器说明"},
)
self.write_corpus(
"net_function",
[("2", "alpha_test"), ("3", "alphaTest")],
{"2": "NET 说明", "3": "无下划线的另一个函数"},
)
catalog = self.module.SourceCatalog.from_docs_root(self.docs)
builtin = self.module.EntryContext(
name="alpha_test",
signature="alpha_test()",
kind="function",
scope="builtin",
page="builtin/demo.md",
summary="原说明",
)
dotnet = self.module.EntryContext(
name="alpha_test",
signature="alpha_test()",
kind="function",
scope="dotnet",
page="dotnet/demo.md",
summary="原说明",
)
self.assertEqual("tsl_base", catalog.match(builtin)[0].corpus)
self.assertEqual("net_function", catalog.match(dotnet)[0].corpus)
self.assertNotIn(
"alphaTest", {record.title for record in catalog.match(dotnet)}
)
def test_duplicate_titles_remain_visible_for_audit(self):
self.write_corpus(
"net_function",
[("1", "same_name"), ("2", "same_name")],
{"1": "说明一", "2": "说明二"},
)
catalog = self.module.SourceCatalog.from_docs_root(self.docs)
entry = self.module.EntryContext(
name="same_name",
signature="same_name()",
kind="function",
scope="dotnet",
page="dotnet/demo.md",
summary="原说明",
)
self.assertEqual(["1", "2"], [item.page_id for item in catalog.match(entry)])
class DescriptionQualityTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def entry(self, **overrides):
values = {
"name": "profitRatio",
"signature": "profitRatio(report_date)",
"kind": "function",
"scope": "dotnet",
"page": "dotnet/financial/profitability.md",
"summary": "总资产收益率(%)。",
}
values.update(overrides)
return self.module.EntryContext(**values)
def test_removes_only_terminal_sentence_punctuation(self):
entry = self.entry(summary="总资产收益率(%)。")
self.assertEqual(
"返回总资产收益率(%)",
self.module.improve_description(entry, []),
)
def test_preserves_formula_and_identifiers(self):
entry = self.entry(
summary="总资产报酬率(%)=利润总额/平均资产总额*100。"
)
self.assertEqual(
"计算总资产报酬率(%),公式为利润总额/平均资产总额*100",
self.module.improve_description(entry, []),
)
def test_rejects_source_process_prose(self):
entry = self.entry(summary="返回指定值")
source = self.module.SourceRecord(
corpus="net_function",
page_id="1",
title="profitRatio",
page=Path("1.html"),
description="Windows 已验证通过,Linux 返回 not found",
)
self.assertEqual(
"返回指定值", self.module.improve_description(entry, [source])
)
def test_current_environment_can_be_real_api_semantics(self):
self.assertEqual(
"获取当前环境时间系统参数",
self.module.safe_source_description("获取当前环境时间系统参数。"),
)
def test_property_access_is_stated_explicitly(self):
entry = self.entry(
name="Host",
signature="Host",
kind="property",
scope="builtin",
page="builtin/object/ftp.md",
summary="远程服务器地址。",
access="read/write",
owner="FTP",
)
self.assertEqual(
"获取或设置远程服务器地址",
self.module.improve_description(entry, []),
)
def test_boolean_property_description_is_idempotent(self):
entry = self.entry(
name="UseTLS",
signature="UseTLS",
kind="property",
scope="builtin",
page="builtin/object/imap.md",
summary="是否采用 SSL 连接",
access="read/write",
owner="IMAP",
)
first = self.module.improve_description(entry, [])
repeated = self.module.improve_description(
self.module.EntryContext(
**{**entry.__dict__, "summary": first}
),
[],
)
self.assertEqual("控制是否采用 SSL 连接", first)
self.assertEqual(first, repeated)
def test_low_confidence_sentence_is_kept_except_terminal_punctuation(self):
entry = self.entry(
name="opaqueApi",
page="dotnet.md",
summary="根据调用上下文处理结果,具体规则取决于输入。",
)
self.assertEqual(
"根据调用上下文处理结果,具体规则取决于输入",
self.module.improve_description(entry, []),
)
def test_short_description_is_not_replaced_by_ambiguous_source_text(self):
entry = self.entry(
name="clRed",
page="builtin/color.md",
summary="红色",
)
sources = [
self.module.SourceRecord(
corpus="net_function",
page_id="1",
title="clRed",
page=Path("1.html"),
description="定义",
)
]
self.assertEqual("红色", self.module.improve_description(entry, sources))
def test_existing_action_word_is_normalized_without_duplicate_verb(self):
getter = self.entry(
name="getCValue",
page="builtin/color.md",
summary="得到颜色的 CMYK 模式 C 值。",
)
writer = self.entry(
name="write",
page="builtin/cgi.md",
summary="输出字符串。",
)
self.assertEqual(
"获取颜色的 CMYK 模式 C 值",
self.module.improve_description(getter, []),
)
self.assertEqual("输出字符串", self.module.improve_description(writer, []))
def test_equation_inside_algorithm_sentence_is_not_rewritten_as_formula(self):
entry = self.entry(
name="se_Gauss",
page="builtin/optimization.md",
summary="用高斯消去法求解线性方程组 AX = B",
)
self.assertEqual(
"用高斯消去法求解线性方程组 AX = B",
self.module.improve_description(entry, []),
)
def test_builtin_class_gets_a_purpose_description(self):
entry = self.entry(
name="TStringList",
signature="TStringList",
kind="class",
scope="builtin",
page="builtin/object/tstringlist.md",
summary="TStringList 内置对象",
)
self.assertEqual(
"提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
self.module.improve_description(entry, []),
)
def test_long_property_explanation_is_not_wrapped_in_access_boilerplate(self):
entry = self.entry(
name="CommaTextW",
signature="CommaTextW",
kind="property",
scope="builtin",
page="builtin/object/tstringlist.md",
summary=(
"功能同 CommaText,区别是在读取时返回宽字节字符串,"
"而 CommaText 返回多字节字符串"
),
access="read/write",
owner="TStringList",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_short_read_and_load_phrases_are_normalized(self):
read_entry = self.entry(
name="read",
page="builtin/object/tstream.md",
summary="读出内容",
)
load_entry = self.entry(
name="loadFromFile",
page="builtin/object/tstringlist.md",
summary="从指定的文件中装载内容",
)
self.assertEqual(
"读取内容", self.module.improve_description(read_entry, [])
)
self.assertEqual(
"从指定的文件中加载内容",
self.module.improve_description(load_entry, []),
)
def test_embedded_read_out_is_normalized_without_duplicate_prefix(self):
entry = self.entry(
name="readExcelSheets",
page="dotnet.md",
summary="从Excel文件中读出Sheets列表。",
)
self.assertEqual(
"从Excel文件中读取Sheets列表",
self.module.improve_description(entry, []),
)
def test_existing_decomposition_verb_is_not_prefixed(self):
entry = self.entry(
name="decodeGraphGroup",
page="builtin/graph.md",
summary="分解图形组合并写入输出参数",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_comparison_expression_is_not_rewritten_as_formula(self):
entry = self.entry(
name="stockStepAmount",
page="dotnet/financial/stock-capital-flow.md",
summary="分档区间 V1<=Value<V2 的成交金额",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_action_sentence_is_not_prefixed_with_return(self):
for summary in (
"提取股票的财务比率,与系统证券相关",
"根据报告期获取财务指标",
"将日线数据转换为周线数据",
"统计指定报告期的基金数量",
"生成指定区间的分析结果",
):
with self.subTest(summary=summary):
entry = self.entry(
name="annualRatio",
page="dotnet/financial/financial-analysis.md",
summary=summary,
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_action_clauses_are_not_prefixed_with_return(self):
for summary in (
"用于生成投资组合结果名称",
"如果存在对应记录则返回证券代码",
"对矩阵进行正交处理",
"从净值表中获取基金列表",
"由交易价格倒推出隐含波动率",
"按照周期读取数据",
"与系统参数证券和日期相关",
):
with self.subTest(summary=summary):
entry = self.entry(
name="financialHelper",
page="dotnet/financial/financial-analysis.md",
summary=summary,
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_boolean_description_uses_judgment_wording(self):
entry = self.entry(
name="isBankBond",
page="dotnet/financial/bond-basic-info.md",
summary="是否银行间债券。如果是,返回1,否则返回0",
)
self.assertEqual(
"判断是否银行间债券。如果是,返回1,否则返回0",
self.module.improve_description(entry, []),
)
def test_function_boilerplate_is_removed_from_action_description(self):
entry = self.entry(
name="cb_ytm",
page="dotnet/financial/convertible-bond.md",
summary="该函数采用牛顿迭代法获取可转债的到期收益率",
)
self.assertEqual(
"采用牛顿迭代法获取可转债的到期收益率",
self.module.improve_description(entry, []),
)
def test_financial_numbered_explanation_is_not_prefixed_with_return(self):
entry = self.entry(
name="financialMode",
page="dotnet/financial/financial-analysis.md",
summary="1 表示合并报表,2 表示母公司报表",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_related_data_placeholder_is_made_explicit(self):
entry = self.entry(
name="cb_downPeriodConversionPeriod",
page="dotnet/financial/convertible-bond.md",
summary="CB_DownPeriodConversionPeriod 相关函数",
)
self.assertEqual(
"返回 CB_DownPeriodConversionPeriod 对应的数据",
self.module.improve_description(entry, []),
)
def test_reviewed_generic_descriptions_use_precise_purpose_text(self):
cases = (
(
"sf_Normal",
"统计分布相关函数。",
"计算正态分布函数值",
),
(
"readFile",
"文件访问函数相关函数。",
"读取本地文件中的数据",
),
(
"unicodeEsc2",
"多语言支持函数相关函数。",
"将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
),
)
for name, summary, expected in cases:
with self.subTest(name=name):
entry = self.entry(name=name, page="dotnet.md", summary=summary)
self.assertEqual(
expected, self.module.improve_description(entry, [])
)
def test_reviewed_awkward_descriptions_are_rewritten_directly(self):
cases = (
(
"encodeGraph",
"返回根据指定的类型、名称、数据以及属性数组生成的图形。",
"根据指定的类型、名称、数据和属性数组生成图形并返回",
),
(
"dupeString",
"返回将指定的字符串AText重复ACount次后的字符串",
"将字符串 AText 重复 ACount 次并返回结果",
),
(
"stockpjcj",
"返回:区间平均成交,区间成交金额/区间成交量",
"返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
),
)
for name, summary, expected in cases:
with self.subTest(name=name):
entry = self.entry(
name=name, page="builtin/string.md", summary=summary
)
self.assertEqual(
expected, self.module.improve_description(entry, [])
)
class TagDerivationTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def test_array_deduplication_gets_controlled_chinese_and_english_aliases(self):
entry = self.module.EntryContext(
name="arrDropDuplicate",
signature="arrDropDuplicate(data)",
kind="function",
scope="builtin",
page="builtin/array.md",
summary="删除数组中的重复元素",
)
page = self.module.PageContext(
title="Builtin / 数组", path="builtin/array.md"
)
tags = self.module.derive_tags(entry, page)
self.assertTrue(
{"数组", "列表", "去重", "删除重复", "array", "deduplicate"}
<= set(tags)
)
self.assertLessEqual(len(tags), 12)
self.assertNotIn("函数", tags)
self.assertNotIn("builtin", tags)
def test_file_digest_gets_digest_and_hash_aliases(self):
entry = self.module.EntryContext(
name="getMsgDigest",
signature="getMsgDigest(s, mode)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="计算字符串摘要,支持 CRC32、MD5、SHA 和 SM3",
)
page = self.module.PageContext(
title="Builtin / 字符串", path="builtin/string.md"
)
tags = self.module.derive_tags(entry, page)
self.assertTrue(
{"字符串", "摘要", "哈希", "散列", "digest", "hash"}
<= set(tags)
)
def test_existing_tags_are_preserved_first_and_output_is_idempotent(self):
entry = self.module.EntryContext(
name="readFile",
signature="readFile(alias, file_name)",
kind="function",
scope="builtin",
page="builtin/file.md",
summary="读取文件内容",
tags=("自定义", "读取"),
)
page = self.module.PageContext(
title="Builtin / 文件", path="builtin/file.md"
)
first = self.module.derive_tags(entry, page)
second = self.module.derive_tags(
self.module.EntryContext(**{**entry.__dict__, "tags": tuple(first)}),
page,
)
self.assertEqual(["自定义", "读取"], first[:2])
self.assertEqual(first, second)
def test_substrings_do_not_create_digest_or_com_tags(self):
reshape = self.module.EntryContext(
name="reshape",
signature="reshape(data, shape)",
kind="function",
scope="builtin",
page="builtin/array.md",
summary="重构数组形状",
)
uncompress = self.module.EntryContext(
name="uniuncompress",
signature="uniuncompress(data, type)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="统一解压缩数据",
)
reshape_tags = self.module.derive_tags(
reshape,
self.module.PageContext("Builtin / 数组", reshape.page),
)
uncompress_tags = self.module.derive_tags(
uncompress,
self.module.PageContext("Builtin / 字符串", uncompress.page),
)
self.assertNotIn("摘要", reshape_tags)
self.assertNotIn("hash", reshape_tags)
self.assertNotIn("COM", uncompress_tags)
self.assertNotIn("OLE", uncompress_tags)
def test_runtime_and_character_names_do_not_create_time_or_graph_tags(self):
random_entry = self.module.EntryContext(
name="randomfrom",
signature="randomfrom(values)",
kind="function",
scope="builtin",
page="builtin/runtime.md",
summary="返回随机数组元素",
)
char_entry = self.module.EntryContext(
name="charToByteLen",
signature="charToByteLen(s, max_len)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="计算字符串占用的字节数",
)
random_tags = self.module.derive_tags(
random_entry,
self.module.PageContext("Builtin / 运行时", random_entry.page),
)
char_tags = self.module.derive_tags(
char_entry,
self.module.PageContext("Builtin / 字符串", char_entry.page),
)
self.assertNotIn("日期时间", random_tags)
self.assertNotIn("图形", char_tags)
def test_parameter_name_does_not_create_unrelated_time_domain(self):
entry = self.module.EntryContext(
name="futureValue",
signature=(
"futureValue(rate, n_periods, payment, present_value, payment_time)"
),
kind="function",
scope="builtin",
page="builtin/numeric.md",
summary="返回一项投资的未来值",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("Builtin / 数值计算", entry.page),
)
self.assertNotIn("日期时间", tags)
def test_hash_index_is_not_tagged_as_cryptographic_digest(self):
entry = self.module.EntryContext(
name="THashedStringList",
signature="THashedStringList",
kind="class",
scope="builtin",
page="builtin/object/thashedstringlist.md",
summary="使用哈希索引加速查找的字符串列表内置对象",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"Object / THashedStringList 字符串列表", entry.page
),
)
self.assertIn("哈希索引", tags)
self.assertIn("hash", tags)
self.assertNotIn("摘要", tags)
self.assertNotIn("digest", tags)
def test_business_statistics_verb_does_not_add_probability_domain(self):
entry = self.module.EntryContext(
name="fundCount",
signature="fundCount(report_date)",
kind="function",
scope="dotnet",
page="dotnet/financial/fund-holdings.md",
summary="统计指定报告期的基金数量",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("金融 / 基金 / 持仓", entry.page),
)
self.assertNotIn("概率", tags)
self.assertNotIn("statistics", tags)
self.assertNotIn("统计指定报告期的基金数量", tags)
self.assertNotIn("证券", tags)
def test_taxonomy_tags_remove_generic_suffixes(self):
entry = self.module.EntryContext(
name="weightedMedian",
signature="weightedMedian(data, weights)",
kind="function",
scope="dotnet",
page="dotnet/financial/weighted-statistics.md",
summary="计算加权中位数",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"基础算法常见加权统计量及其实现 / 时间相关函数",
entry.page,
),
)
self.assertIn("基础算法常见加权统计量", tags)
self.assertIn("时间", tags)
self.assertNotIn("基础算法常见加权统计量及其实现", tags)
self.assertNotIn("时间相关函数", tags)
def test_taxonomy_tags_drop_parenthetical_explanations_and_punctuation(self):
entry = self.module.EntryContext(
name="close",
signature="close()",
kind="function",
scope="dotnet",
page="dotnet/datawarehouse/after_market.md",
summary="收盘价,与系统参数股票、时间、周期和复权相关",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"数据仓库 / 盘后相关(在当日可用于盘中)", entry.page
),
)
self.assertIn("数据仓库", tags)
self.assertIn("盘后", tags)
self.assertNotIn("(", tags)
self.assertNotIn(")", tags)
self.assertNotIn("盘后相关在当日可用于盘中", tags)
self.assertNotIn("关闭", tags)
self.assertNotIn("close", tags)
def test_name_intent_does_not_override_an_explicit_action_clause(self):
entry = self.module.EntryContext(
name="openEndFundFilterByFundIndex",
signature="openEndFundFilterByFundIndex(fund_inds)",
kind="function",
scope="dotnet",
page="dotnet/financial/fund-pool.md",
summary="根据基金指数获取成分基金,并对基金进行筛选",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("金融 / 基金 / 基金池", entry.page),
)
self.assertNotIn("打开", tags)
self.assertNotIn("open", tags)
class MarkdownEnrichmentTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def test_inserts_tags_after_description_without_touching_code(self):
text = (
"# Builtin / 数组\n\n"
"## `arrDropDuplicate(data)`\n\n"
"声明:function\n\n"
"删除数组中的重复元素。\n\n"
"| 参数 | 类型 | 说明 |\n"
"| --- | --- | --- |\n"
"| `data` | array | 输入数组 |\n\n"
"返回:array\n\n"
"### 示例\n\n"
"```tsl\nreturn arrDropDuplicate(array(1, 1));\n```\n"
)
result, audit = self.module.enrich_markdown(
text, "builtin/array.md", self.module.SourceCatalog.empty()
)
repeated, repeated_audit = self.module.enrich_markdown(
result, "builtin/array.md", self.module.SourceCatalog.empty()
)
self.assertIn("删除数组中的重复元素\n\n<!-- tags:", result)
self.assertIn("return arrDropDuplicate(array(1, 1));", result)
self.assertEqual(result, repeated)
self.assertEqual(1, audit.changed_descriptions)
self.assertEqual(1, audit.changed_tags)
self.assertEqual(0, repeated_audit.changed_descriptions)
self.assertEqual(0, repeated_audit.changed_tags)
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -884,6 +884,41 @@ class DocGenCliTest(unittest.TestCase):
self.assertIn("范例02:多行输出", text)
self.assertIn("// 输出:\n// first\n// second", text)
def test_top_level_enum_sections_are_plain_text_not_emphasis_headings(self):
self.write_input(
{
"module": "项目 / 枚举",
"path": "base/enum_values",
"declarations": [
{
"kind": "function",
"name": "demo",
"signature": "demo(mode)",
"desc": "按模式返回结果。",
"params": [
{
"name": "mode",
"type": "integer",
"desc": "处理模式",
"values": [
{"value": 0, "desc": "原样返回"},
{"value": 1, "desc": "转换后返回"},
],
}
],
"returns": "integer",
}
],
}
)
result = self.run_cli()
self.assertEqual(0, result.returncode, result.stderr)
text = self.generated("base/enum_values").read_text(encoding="utf-8")
self.assertIn("\nmode 取值\n", text)
self.assertNotIn("**mode 取值**", text)
def test_atomic_write_failure_preserves_existing_output_and_cleans_temp(self):
module = load_script()
output = self.root / "existing.md"
+1 -1
View File
@@ -801,7 +801,7 @@ class DocLintCliTest(unittest.TestCase):
"| --- | --- | --- |",
"| `mode` | integer | 模式 |",
"",
"**mode 取值**",
"mode 取值",
"",
"### 示例",
"",