diff --git a/skills/tsl-syntax-reference/SKILL.md b/skills/tsl-syntax-reference/SKILL.md index dd7ff89c..56513b2d 100644 --- a/skills/tsl-syntax-reference/SKILL.md +++ b/skills/tsl-syntax-reference/SKILL.md @@ -58,9 +58,9 @@ python /scripts/lookup.py --section "syntax-05-004" "syntax-02-0 - 中文口语词自动扩展到 TSL 术语(如"打印"→输出/writeLn,"列表"→数组, "程序慢"→性能分析)。保留提取词的原写法即可。 -- `tsl`、`tsf`、`tinysoft`、`program`、`debug`、`please` 这几个词不参与逐词匹配, +- `tsl`、`tsf`、`tinysoft`、`debug`、`please` 这几个词不参与逐词匹配, 但可能整体把查询导向某个专题页。无结果时靠加这类词补救没有用,改为换更具体的 - 语法要素名。 + 语法要素名。`program` 是真实 TSL 关键字,会参与精确匹配。 ## 弱命中视同无匹配 @@ -93,6 +93,9 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素 写不出某个要素的 Section ID,说明该要素还没有取回:先补齐检索,再给代码。 +不含代码块的语法解释同样要在相关结论后列出依据的 Section ID;多个结论来自不同 +章节时逐项对应,不用一个 Section ID 为整段未取回的结论背书。 + ## 代码块身份 `--section` 正文里每个代码围栏前有一行 `代码块身份:`。身份决定这段代码能否进入 @@ -115,6 +118,8 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素 ## 缺口时停止 +**🔴 CHECKPOINT · 🛑 STOP:确认以下缺口后停止生成,不输出含缺口要素的代码。** + 出现下列任一情形,即为事实缺口: **检索缺口** —— `--query` 改进查询词后仍无匹配;lookup 返回非零状态;`--section` diff --git a/skills/tsl-syntax-reference/data/lexicon.json b/skills/tsl-syntax-reference/data/lexicon.json index f0c53bdf..7fbc408f 100644 --- a/skills/tsl-syntax-reference/data/lexicon.json +++ b/skills/tsl-syntax-reference/data/lexicon.json @@ -31,23 +31,42 @@ "函数指针释放": ["DeleteInstance", "MakeInstance"], "Linux 动态加载": ["dlopen", "dlsym", ".so"], "远程调用客户端": ["RDo", "RDo2", "客户端远程调用"], - "本地弹窗": ["RDo2", "InputQuery", "客户端远程调用"] + "本地弹窗": ["RDo2", "InputQuery", "客户端远程调用"], + "全局变量": ["global", "跨函数共享变量"], + "静态计算": ["static", "表达式缓存"], + "只计算一次": ["static", "表达式缓存"], + "指定系统函数": ["system", "系统函数限定"], + "内存上限": ["_maxMem_", "最大内存"], + "每列聚集": ["avgof", "多字段聚集"], + "按行广播": ["二维矩阵", "一维数组", "行广播"] }, "page_intent_aliases": { "01_quickstart.md": ["最简单能跑", "最简单的脚本", "天软脚本", "tinysoft"], "02_core_model.md": [ "脚本和可复用", "可复用函数文件", - "声明函数后面写代码" + "声明函数后面写代码", + "完整 program 入口", + "echo 输出多个值" ], "03_values_and_literals.md": [ "字符串和数组下标", "下标从几开始", "下标起点" ], - "04_variables_and_constants.md": ["常量怎么声明", "变量能不能直接赋值"], - "05_functions_and_calls.md": ["默认参数", "函数怎么带"], - "06_expressions_and_operators.md": ["赋值和相等比较"], + "04_variables_and_constants.md": [ + "常量怎么声明", + "变量能不能直接赋值", + "全局变量", + "运行时常量" + ], + "05_functions_and_calls.md": ["默认参数", "函数怎么带", "指定系统函数"], + "06_expressions_and_operators.md": [ + "赋值和相等比较", + "运算符优先级", + "静态计算", + "只计算一次" + ], "07_control_flow.md": [ "跳出去", "循环里满足条件", @@ -59,7 +78,12 @@ "08_objects_and_classes.md": ["定义类", "创建对象"], "09_units_and_scope.md": ["多个文件", "复用一组函数"], "10_runtime_context_and_with.md": ["临时切换系统参数"], - "11_matrix_and_collections.md": ["某行存在", "二维数组怎么判断"], + "11_matrix_and_collections.md": [ + "某行存在", + "二维数组怎么判断", + "按行广播", + "二维矩阵乘一维数组" + ], "12_resultset_and_filters.md": ["保留匹配行", "按某一列"], "13_ts_sql.md": [ "左连接", @@ -67,7 +91,9 @@ "左联接", "数据库", "分组排序", - "聚合排序" + "聚合排序", + "JOIN 第二张表当前行", + "每列聚集" ], "14_debug_and_profiler.md": [ "程序慢", @@ -77,11 +103,13 @@ "执行了多久", "执行时间", "耗时", - "debug" + "debug", + "内存上限" ], "15_lexical_structure_and_compile_options.md": [ "变量名区分大小写", - "注释怎么写" + "注释怎么写", + "嵌套注释" ], "16_types_and_conversions.md": ["字符串转整数", "类型转换"], "17_external_calls_and_threads.md": [ diff --git a/skills/tsl-syntax-reference/references/02_core_model.md b/skills/tsl-syntax-reference/references/02_core_model.md index d10f7134..58cac80b 100644 --- a/skills/tsl-syntax-reference/references/02_core_model.md +++ b/skills/tsl-syntax-reference/references/02_core_model.md @@ -6,7 +6,7 @@ -回答“目标文件到底是 `.tsl` 脚本还是 `.tsf` 可复用声明文件,以及 `.tsl` 里的哪些内容会顺序执行、哪些内容只是后置声明”。 +回答“目标文件到底是 `.tsl` 脚本还是 `.tsf` 可复用声明文件,`.tsl` 里的哪些内容会顺序执行、哪些内容只是后置声明,以及何时使用完整 `program ... begin ... end.` 入口形态”。 本页是文件模型的唯一事实源:后缀判断、语句区 / 声明区顺序、`.tsf` 顶层声明形态和文件名约束都在这里收口。函数体、类体、`unit` 内部的语法外形由各自专题页拥有。 @@ -33,6 +33,7 @@ - `uses` 可以出现在顶层,但这里只把它当成辅助语句,不把它当成主体声明;函数体和类定义体里的位置限制见 [09_units_and_scope.md](09_units_and_scope.md)。 - 裸 `class Name` 不作为类定义写法使用。 - 在 `.tsl` 中,不要在声明区之后继续追加脚本语句。 +- `program Name; ... begin ... end.` 是完整程序入口形态;它可以在主 `begin` 前声明子函数,但不是普通 `.tsl` 脚本的默认起手式。 - `unit` 默认先按完整形态理解;它也可以省略 `interface` / `implementation` 写成简写形态,见 [09_units_and_scope.md](09_units_and_scope.md)。 - 不要把 `.tsl` 写成只有顶层函数的模块;如果用户要通用可复用函数,优先写 `.tsf`。 - 不要把 `.tsf` 写成会直接执行脚本语句的入口;如果用户要顺序执行入口,优先写 `.tsl`。 @@ -184,6 +185,64 @@ end. 1 ``` +### 完整 `program` 入口形态 + + + + + +普通 `.tsl` 默认仍使用前面的松散语句区模型。只有用户明确要求完整程序入口、独立脚本 / CGI 兼容形态,或现有代码已经使用 `program` 时,才写成下面的结构: + +代码块身份:可直接照写示例 + +```tsl +program DemoProgram; + +function DoubleValue(value); +begin + return value * 2; +end; + +begin + echo "result:", DoubleValue(3); +end. +``` + +代码块身份:输出片段 + +```text +result:6 +``` + +说明: + +- `program Name;` 位于文件开头 +- 子函数声明位于主 `begin ... end.` 之前 +- 完整程序以 `end.` 结束,而普通函数声明仍以 `end;` 结束 +- 不要把这套结构与“松散语句区在前、声明区在后”的默认 `.tsl` 模型混写 + +### `echo` 输出语句 + + + + + +`echo expr1, expr2, ...;` 会按从左到右的顺序输出表达式列表。简单值直接输出;数组、对象等复杂值可能只显示类型摘要。具体输出目标由宿主环境决定,可能是控制台、Web 响应或客户端输出窗口。 + +代码块身份:可直接照写示例 + +```tsl +echo "A=", 1, ",B=", 2; +``` + +代码块身份:输出片段 + +```text +A=1,B=2 +``` + +不要把 `echo` 的宿主输出位置或复杂对象的展示格式当成跨环境固定结果。 + ### 文件模型反例 @@ -262,5 +321,6 @@ function:__main__:line 9: invalid statement - 把 `.tsl` 当成 `.tsf` 来写,只给一个顶层函数,不写任何会执行的脚本语句。 - 把 `.tsf` 当成 `.tsl` 来写,在模块文件里直接堆顺序执行的脚本语句。 +- 把 `program ... begin ... end.` 与松散 `.tsl` 语句区 / 后置声明区混成一套文件结构。 - 把 `uses` 当成主体声明,而不是辅助组织语句。 - `.tsf` 文件名与顶层声明不一致:`UserAccount.tsf` 中写 `function GetUser` 或 `type Customer = class` 或 `unit CustomerModule` 会导致加载失败或检索混乱。 diff --git a/skills/tsl-syntax-reference/references/03_values_and_literals.md b/skills/tsl-syntax-reference/references/03_values_and_literals.md index 3f2ef8b0..740fe863 100644 --- a/skills/tsl-syntax-reference/references/03_values_and_literals.md +++ b/skills/tsl-syntax-reference/references/03_values_and_literals.md @@ -24,6 +24,7 @@ - `array(...)` 既可以写顺序数组,也可以写字符串键表;顺序数组和 `binary(...)` 二进制缓冲区下标从 `0` 开始,字符串下标从 `1` 开始。 +- 数组元素可以是混合类型;给不存在的下标赋值会扩张数组。显式整数键可写成 `array(0: value0, 1: value1)`。 - `s[0]` 在运行时会越界,不要把字符串当成 0 基下标。 - 字符串取子串用 `s[start:end]`,并且 `end` 是包含在结果里的。 - 字符串替换子串用 `s[start:end] := "..."`。 @@ -36,6 +37,8 @@ - `U""` 不是宽串;需要在 UTF8、宽串、普通串之间显式转换时,继续用 `utf8ToUnicode(...)`、`utf8ToAnsi(...)`、`ansiToUtf8(...)`、`unicodeToUtf8(...)`、`string(...)`、`wideString(...)`。 - `#number` 可以直接把字符码拼进字符串。 - `\0` 和 `#0` 都能把 ASCII `0` 放进字符串,并且不会把字符串截断。 +- 字符串 `like` 的右侧按正则表达式解释,不是 SQL `%` / `_` 通配,也不要把正则里的 `*` 当成独立的 glob 通配符;默认匹配不区分大小写。 +- 数值 `like` 表示近似相等;`likeEps` 和 `likeEpsRate` 分别控制绝对误差与相对误差阈值,默认值都是 `1e-6`。 ## 可直接照写示例 @@ -170,6 +173,33 @@ C 代码块说明:顺序数组 `items` 从 `0` 开始,字符串键表 `row` 用字符串键访问,字符串 `s` 从 `1` 开始。 +数组按写入下标扩张: + +代码块身份:可直接照写示例 + +```tsl +items := array(1); +items[3] := 4; +keyed := array(0: 2, 1: 3); +writeLn(length(items)); +writeLn(items[1] = nil); +writeLn(items[3]); +writeLn(keyed[0]); +writeLn(keyed[1]); +``` + +代码块身份:输出片段 + +```text +2 +1 +4 +2 +3 +``` + +说明:`length(items)` 统计已有项数,不等于“最大整数键 + 1”;下标 `1` 没有写入,因此读取为 `nil`。 + ### 字符串边界规则 @@ -416,20 +446,20 @@ A=1 B=2.34 C=AAA - 纯小数可能走科学计数法:`"_" $ 0.0000005 $ ","` 返回 `"_5E-7,"` - 带整数部分的小数保留精度:`"_" $ 1.0000005 $ ","` 返回 `"_1.0000005,"` -### 字符串 `like` 模式匹配 +### 字符串与数值 `like` - + -`like` 用于判断字符串是否符合指定模式(支持通配符和正则表达式): +字符串 `like` 的右侧是正则表达式;默认匹配不区分大小写: 代码块身份:可直接照写示例 ```tsl -result1 := "hello" like "he*"; -result2 := "HELLO" like "he*"; -result3 := "test@example.com" like "*@*"; +result1 := "a" like "A"; +result2 := "HELLO" like "hello"; +result3 := "2009-1-1" like "\\d+-\\d+-\\d+"; writeLn("result1:", result1); writeLn("result2:", result2); writeLn("result3:", result3); @@ -439,22 +469,49 @@ writeLn("result3:", result3); ```text result1: 1 -result2: 0 +result2: 1 result3: 1 ``` 说明: -- `like` 大小写敏感,`"hello" like "he*"` 返回 `1`(真),`"HELLO" like "he*"` 返回 `0`(假) -- `*` 是通配符,匹配任意字符序列 -- `like` 也支持正则表达式模式(如 `"\\d{4}-\\d{2}-\\d{2}"` 匹配日期格式) +- 默认情况下,`"a" like "A"` 和 `"HELLO" like "hello"` 都返回 `1`(真) +- `"\\d+-\\d+-\\d+"` 是正则表达式,匹配示例中的日期形态 +- 正则量词 `*` 只修饰它前面的原子;不要把 `"he*"` 解释成“`he` 后面任意字符”的 glob 模式,需要任意字符序列时写成 `.*` +- 匹配控制标志可以改变大小写规则;控制标志的精确 API、参数与目标 scope 使用 `tsl-api-reference` skill 核对 + +数值 `like` 用于近似相等判断: + +代码块身份:可直接照写示例 + +```tsl +old_abs_eps := likeEps; +old_rate_eps := likeEpsRate; +likeEps := 0.1; +likeEpsRate := 0; +writeLn(1.05 like 1); +likeEps := old_abs_eps; +likeEpsRate := old_rate_eps; +``` + +代码块身份:输出片段 + +```text +1 +``` + +说明: + +- `likeEps` 和 `likeEpsRate` 都可读写,默认值均为 `1e-6` +- 两数绝对差小于 `likeEps`,或“绝对差 / 两数绝对值的平均值”小于 `likeEpsRate` 时,数值 `like` 返回真 +- 临时修改阈值后要恢复旧值,避免影响同一运行时中的后续比较 `not like` 是取反形式(TSL 2025/8 版本起支持): 代码块身份:可直接照写示例 ```tsl -result := "abc" not like "xyz*"; +result := "abc" not like "xyz.*"; writeLn(result); ``` @@ -547,6 +604,10 @@ items := array(1, 2, 3); - 把普通字符串里的 `\uXXXX` 直接当成宽串单字符。 - 把 `U""` 当成宽串;需要宽串时按本页转换链处理。 - 把 `#0` / `\0` 当成 C 风格字符串终止符。 +- 把字符串 `like` 误写成默认区分大小写;默认行为是不区分大小写。 +- 把 `like` 只理解成字符串模式匹配,遗漏数值近似相等语义。 +- 修改 `likeEps` / `likeEpsRate` 后不恢复,导致后续代码继承意外的比较阈值。 +- 以为给较大整数下标赋值后,中间所有位置都会自动生成实际元素;未写入的位置仍为 `nil`。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/04_variables_and_constants.md b/skills/tsl-syntax-reference/references/04_variables_and_constants.md index f024fd61..defe5a08 100644 --- a/skills/tsl-syntax-reference/references/04_variables_and_constants.md +++ b/skills/tsl-syntax-reference/references/04_variables_and_constants.md @@ -6,7 +6,7 @@ -回答“普通变量怎样直接使用、`var` 在什么位置出现、常量必须怎样初始化、哪些名字一旦绑定就不能再赋值”。 +回答“普通变量怎样直接使用、`var` 在什么位置出现、常量必须怎样初始化、`global` 怎样跨函数共享,以及运行时常量怎样冻结一次调用中的表达式结果”。 ## 核心规则 @@ -27,6 +27,8 @@ - 右侧数组元素也可以是数组;拆出来的变量会直接得到对应子数组。 - 多参数赋值也可以出现在函数调用参数里。 - `{$explicit+}` 开启后,后续变量必须先用 `var` 声明;未声明变量会报 `variable not defined`。 +- `global x, y;` 声明当前任务中的全局变量;每个需要访问它的函数都要再次写 `global` 声明。 +- `const name := expression;` 是运行时常量:进入所属函数时计算,当前调用内不可再次赋值;不要与编译时常量 `const name = expression;` 混写。 ## 可直接照写示例 @@ -178,6 +180,71 @@ const kMaxRetries = 3 + 4; value := kMaxRetries; ``` +### 全局变量 `global` + + + + + +`global` 让同一运行任务中的不同函数访问同一变量。每个需要读写该变量的函数都要声明它;漏写时,同名标识符会按局部变量处理。 + +代码块身份:可直接照写示例 + +```tsl +global shared_value; +shared_value := 37; +writeLn(ReadShared()); + +function ReadShared(); +begin + global shared_value; + return shared_value; +end; +``` + +代码块身份:输出片段 + +```text +37 +``` + +本节只拥有“当前任务内跨函数共享”和“每个引用函数都要声明”的规则;不要从中推断跨任务持久化、线程隔离或进程级生命周期。 + +### 运行时常量 `const name := expression` + + + + + +运行时常量使用 `:=` 初始化,可以依赖参数、变量或函数调用。它在每次进入所属函数时重新计算一次,随后在本次调用内不可修改。 + +代码块身份:可直接照写示例 + +```tsl +writeLn(FreezeValue(2)); +writeLn(FreezeValue(5)); + +function FreezeValue(value); +begin + const fixed := value + 1; + return fixed; +end; +``` + +代码块身份:输出片段 + +```text +3 +6 +``` + +边界: + +- 编译时常量写 `const name = expression;`;运行时常量写 `const name := expression;` +- 运行时常量声明放在函数 `begin ... end` 语句体中,不放在函数头后的 `const` 声明段 +- 运行时常量不作为类成员写法 +- 初始化后再次赋值会破坏常量约束,不要生成 + ### 多参数赋值 @@ -331,6 +398,9 @@ items := array(1, 2, 3); - 以为 `const` 可以只声明名字,不写初始化表达式。 - 以为单变量拆包可以写成 `[a] := array(...)`。 - 以为 `{$explicit+}` 开启后仍然可以继续直接写未声明变量。 +- 只在脚本顶层声明一次 `global`,却忘记在读取它的函数里再次声明。 +- 把运行时常量 `const name := expression;` 写进函数头后的编译时 `const` 声明段。 +- 把运行时常量当成可重新赋值的普通变量或类成员。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/05_functions_and_calls.md b/skills/tsl-syntax-reference/references/05_functions_and_calls.md index 6488366b..4001ec2d 100644 --- a/skills/tsl-syntax-reference/references/05_functions_and_calls.md +++ b/skills/tsl-syntax-reference/references/05_functions_and_calls.md @@ -50,7 +50,8 @@ - 一旦某次调用里开始使用命名参数,后面的参数就不能再退回位置参数。 - 对二进制函数 / 系统函数直接使用命名参数,会报 `named parameter mode can't support here`;这类函数要先用 TSL 再封一层。 - 函数参数支持默认值。 -- 普通函数的默认值规则不要直接等同到 `unit interface` 声明;跨 `unit` 的默认参数边界只照本页最小反例和 [09_units_and_scope.md](09_units_and_scope.md) 处理。 +- `unit interface` 中的默认参数可以引用该接口中可访问的 `const`;实现函数头不要重复声明默认值。 +- 默认参数表达式属于新一代 TSL 能力;表达式里引用变量时,该变量按 `0` 求值。面向旧运行时或版本不明时,默认只生成字面量 / 可访问常量默认值。 - 尾部 `...` 形式的可变参数属于文档明确写法。 - 在可变参数函数体里,`Params`、`ParamCount`、`RealParamCount` 都可用。 - 可变参数组可以通过 `...` 转发给另一个函数调用。 @@ -61,6 +62,7 @@ - 匿名函数和 TSL 函数值的稳定调用方式仍是 `call(f, ...)` 或 `##f(...)`。 - `f(...)` 这种“函数变量直接调用”写法不作为可写事实;无论 `f` 是匿名函数、`findFunction(...)` 还是 `thisFunction(...)` 返回的函数指针,都不要默认写成直调。 - `::FuncName(...)` 可以指向全局/系统函数,用来绕过当前作用域里的同名局部函数。 +- `system.FuncName(...)` 专门强制指定系统函数;它与 `::FuncName(...)` 的全局限定语义不要互相替代。 - `external`、原生函数指针包装、`makeInstance` / C 回调和线程调用的事实见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。 - 不要在 `.tsl` 的函数声明区之后继续追加脚本语句。 @@ -502,7 +504,7 @@ begin end; ``` -默认值也可以写成表达式: +新一代 TSL 的默认值也可以写成表达式: 代码块身份:可直接照写示例 @@ -523,24 +525,56 @@ end; - `Pack(a: 1)` 返回 `12` - `ExprDefault()` 返回 `3` -`unit interface` 声明下的默认参数要单独看。普通函数默认参数可用,不等于跨 `unit` 声明边界也同样可靠。 +表达式默认值的版本边界: -代码块身份:反例 / 不可照写 +代码块身份:可直接照写示例 -```text -unit UnitConst; -interface - -const default_value = 888; -function F(a, b = 100, c = default_value); +```tsl +function RefDefault(a, b = a + 1); +begin + return b; +end; ``` 边界说明: -- `F(1)` 输出 `101`,`F(1, 2)` 输出 `3` -- 同一组文件下,`UnitConst.default_value` 可读到 `888`,而 `F(1, 2, 3)` 输出 `6` -- 因此不要把“普通函数默认参数可用”直接泛化成“`unit interface` 里引用 `unit const` 的默认参数也同样可靠” -- 这类跨 `unit` 的声明边界事实见 [09_units_and_scope.md](09_units_and_scope.md) +- 在支持默认参数表达式的新一代 TSL 中,`RefDefault(5)` 返回 `1`,因为默认表达式里的变量 `a` 按 `0` 求值 +- 这项能力自 2025-08-27 后的 NG 客户端 / 新一代 TSL 服务端提供;目标版本不明时不要生成变量参与的默认表达式 + +`unit interface` 可以使用接口中可访问的常量作为默认值: + +代码块身份:配置片段 / 概念骨架 + +```text +// UnitDefaults.tsf +unit UnitDefaults; + +interface + +const default_value = 888; +function F(a, b = 100, c = default_value); + +implementation + +function F(a, b, c); +begin + return a + b + c; +end; + +end. + +// main.tsl +uses UnitDefaults; +echo F(1), ",", F(1, 2); +``` + +代码块身份:输出片段 + +```text +989,891 +``` + +说明:默认值只在 `interface` 声明处写一次;实现函数头使用同一组形参,但不重复 `= ...`。 ### 可变参数 `...` @@ -686,7 +720,12 @@ end; - + + +匿名函数作用域边界: + +- 匿名函数定义在类的方法中时,可以访问当前对象的成员和方法。 +- 匿名函数不能访问宿主函数的临时变量;不要把它当成会捕获外层临时变量的词法闭包。 匿名函数变量: @@ -851,7 +890,26 @@ end; -`external`、原生函数指针包装、`makeInstance` / C 回调和线程调用,统一见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。这一篇只保留“普通函数怎样定义和调用”的主线。 + + +系统函数可能与用户定义函数同名。需要明确指定系统实现时,使用 `system.FuncName(...)`: + +代码块身份:可直接照写示例 + +```tsl +value := system.strToInt("123"); +writeLn(value); +``` + +代码块身份:输出片段 + +```text +123 +``` + +本节只拥有 `system.` 这一调用限定语法。示例中的真实函数名、签名和目标 scope 必须由 `tsl-api-reference` skill 核对;不要从本节推断任意系统 API。 + +`external`、原生函数指针包装、`makeInstance` / C 回调和线程调用,统一见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。 ## 默认生成模板 @@ -906,7 +964,10 @@ end; - 把 `a = 1` 这种比较表达式误当成命名参数调用。 - 以为默认值只能用于无类型参数。 - 在 `const` 形参上直接赋值。 -- 把普通函数的默认值规则原样套到 `unit interface` 里的 `const` 默认参数上。 +- 在旧运行时或版本不明时生成默认参数表达式。 +- 以为默认参数表达式里的形参会取本次调用实参;其中变量按 `0` 求值。 +- 在 `unit interface` 和 `implementation` 的函数头上重复写默认值。 +- 用 `::FuncName(...)` 和 `system.FuncName(...)` 互相替代,而不区分全局限定与系统函数限定。 - 把匿名函数或 `findFunction(...)` 返回值默认写成 `f(...)` 直调。 - 把命名参数直接套到二进制函数或系统函数上。 - 在一次调用里先进入命名参数模式,后面又退回位置参数。 @@ -995,7 +1056,7 @@ const default_value = 888; function F(a, b = 100, c = default_value); ``` -不要把上面这种 `unit interface` 声明直接当成已经等价于普通函数默认参数规则。按文档结果,对应的 `F(1)` 输出是 `101`,不是按 `default_value = 888` 补成的结果;具体边界见 [09_units_and_scope.md](09_units_and_scope.md)。 +上面的片段缺少 `implementation` 和 `end.`,因此不能作为完整 `unit` 文件直接照写;它不是“接口常量不能作为默认值”的反例。完整可写结构见本页默认参数章节。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/06_expressions_and_operators.md b/skills/tsl-syntax-reference/references/06_expressions_and_operators.md index a65c4e86..d41e071f 100644 --- a/skills/tsl-syntax-reference/references/06_expressions_and_operators.md +++ b/skills/tsl-syntax-reference/references/06_expressions_and_operators.md @@ -101,6 +101,7 @@ - 连续标量比较才用 `:>`、`:<`、`:<>`、`:==`、`:>=`、`:<=`。 - 数组逐元素链式比较才用 `::>`、`::<`、`::<>`、`::==`、`::>=`、`::<=`。 - 混合两类以上运算符时,优先用括号明确分组,不依赖跨语言记忆里的优先级。 +- TSL 的主要优先级从高到低是:成员/下标/调用,`not`、前置自增减与倒数/逆等一元运算,字符串 `$`,幂,乘除移位,加减(包括一元正负号)与集合/位运算,比较与 `in`/`like`/`is`,`and`/`or`,冒号,赋值,表达式前导 `@`;同级通常从左到右。 边界规则: @@ -566,6 +567,113 @@ writeLn(if 2 > 1 then 2 else 1); `if condition then true_value else false_value` 必须带 `else`,否则不是本页可照写的表达式形态。 +### 运算符优先级 + + + + + +下表是面向当前正式文档已收录运算符的保守分组,数字越小优先级越高: + + +| 级别 | 主要形态 | 说明 | +| --- | --- | --- | +| 0 | `()`、`[]`、`.`、`?.`、函数调用 | 分组、访问、下标与调用最先结合。 | +| 1 | `not`、前置 `++` / `--`、`!`、`.!`、`.!!` | 逻辑非、前置自增减与倒数/逆等一元运算。 | +| 2 | `$` | 字符串连接。 | +| 3 | `^`、`~`、`:^` | 幂、开方与对应矩阵形态。 | +| 4 | `*`、`/`、`\`、`%`、`div`、`mod`、`shl`、`shr`、`rol`、`ror` | 乘除、取余与移位。 | +| 5 | `+`、`-`、一元正号 / 负号、集合运算、点前缀位/逻辑运算 | 加减、一元正负号及同组语言运算;因此幂先于一元正负号结合,例如 `-2 ^ 2` 等于 `-(2 ^ 2)`。 | +| 6 | 比较、`is`、`in`、`sqlin`、`like`、链式比较 | 关系判断。 | +| 7 | `and`、`or` | 低于比较,因此 `a > 1 and b < 2` 按两个比较再逻辑与理解。 | +| 8 | `:` | 冒号相关表达式形态。 | +| 9 | `:=` 与各类复合赋值 | 赋值接近最低优先级。 | +| 10 | `@` | 表达式对象前导最低。 | + + +同级运算通常从左到右求值。为了避免不同语言之间的优先级记忆混淆,混合两类以上运算符时仍推荐显式加括号: + +代码块身份:可直接照写示例 + +```tsl +value := 1 + 2 * 3; +signedPower := -2 ^ 2; +flag := (2 > 1) and (1 < 2); +writeLn(value); +writeLn(signedPower); +writeLn(flag); +``` + +代码块身份:输出片段 + +```text +7 +-4 +1 +``` + +稀有矩阵运算符的详细优先级以其专题页为准;不要用本表外推尚未写入正式文档的符号。 + +### 静态计算表达式 `static` + + + + + +表达式前的 `static` 会缓存第一次计算结果。带 `name` 时,名称表达式是缓存键:同一键复用第一次结果,不同键分别计算。 + +代码块身份:可直接照写示例 + +```tsl +echo StaticValue(), ",", StaticValue(); + +function StaticValue(); +begin + return static NextValue(); +end; + +function NextValue(); +begin + global static_calls; + static_calls += 1; + return static_calls; +end; +``` + +代码块身份:输出片段 + +```text +1,1 +``` + +按键分别缓存: + +代码块身份:可直接照写示例 + +```tsl +echo NamedValue("A"), ",", NamedValue("A"), ",", NamedValue("B"); + +function NamedValue(key); +begin + return static NextValue() name key; +end; + +function NextValue(); +begin + global named_static_calls; + named_static_calls += 1; + return named_static_calls; +end; +``` + +代码块身份:输出片段 + +```text +1,1,2 +``` + +这不是类成员的 `static` 字段;类静态成员见 [08_objects_and_classes.md](08_objects_and_classes.md)。缓存结果具有运行时状态,不要用它保存每次调用都必须重新计算的值。 + ### 表达式对象 @@ -847,6 +955,8 @@ writeLn(value); - 把 `if` 表达式写成没有 `else` 的半句。 - 把本页明确的 `c?.a?.[1]` 外推成所有深链式空安全访问都可靠。 - 从其他语言推断 TSL 运算符能力。 +- 把类成员 `static field;` 与表达式前导 `static Expression [name Key]` 当成同一种语法。 +- 在复杂混合表达式里依赖其他语言的优先级记忆而省略括号。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/07_control_flow.md b/skills/tsl-syntax-reference/references/07_control_flow.md index 1b54aaee..54777a8b 100644 --- a/skills/tsl-syntax-reference/references/07_control_flow.md +++ b/skills/tsl-syntax-reference/references/07_control_flow.md @@ -16,6 +16,8 @@ - 块式分支内部的普通语句必须用分号结尾。 - 控制流块的 `begin ... end` 后可以加分号也可以不加(语法都允许)。 - `for` 支持 `to`、`downto`、可选 `step`,以及 `for i, v in array` 遍历。 +- 计数 `for` 的初值、终值和步长确定后,循环次数随之固定;循环体内不要给控制变量赋值。 +- `for i, v in array` 遍历期间,不要修改被遍历数组或其中元素。 - `while` 和 `repeat ... until` 都可直接使用;`repeat` 至少会先执行一轮再判断结束条件。 - `break` 会跳出当前最近一层循环,`continue` 会跳过当前轮剩余语句。 - `case ... of ... else ... end` 可作为语句形态生成;`end` 后可以加分号也可以不加。 @@ -154,6 +156,7 @@ for i, value in numbers do - 依次输出 `10`、`120`、`230` - 这说明 `for i, value in numbers` 里的 `i` 从 `0` 开始 +- 遍历期间把 `numbers` 当成只读集合;需要修改时先遍历副本,或在循环结束后统一写回 代码块身份:输出片段 @@ -495,6 +498,8 @@ end - 以为 `try ... finally` 会吞掉异常。 - 在还没搞清表达式规则前,先把复杂业务函数塞进条件里。 - 把控制流问题和函数文件模型问题混在一起排查。 +- 在计数 `for` 循环体里给控制变量赋值。 +- 在 `for ... in` 遍历期间修改被遍历数组或其中元素。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/10_runtime_context_and_with.md b/skills/tsl-syntax-reference/references/10_runtime_context_and_with.md index 6f555312..efaa9171 100644 --- a/skills/tsl-syntax-reference/references/10_runtime_context_and_with.md +++ b/skills/tsl-syntax-reference/references/10_runtime_context_and_with.md @@ -18,7 +18,7 @@ - `setSysParam(key, value)` 和 `getSysParam(key)` 可以直接用字符串键。 - `sysParams[key]` 可以直接读写这些运行时参数。 - 块环境语句可写成 `with *, sys_param_values do begin ... end` 或 `with **, sys_param_values do begin ... end`。 -- `with *` 会把提供的系统参数合并进当前运行时上下文;不要依赖它在块结束后自动恢复外层值。 +- `with *` 会把提供的系统参数合并进当前运行时上下文。普通自定义键可能在块后保留新值;特殊系统环境变量会在块结束后恢复,因此不要把两类键的恢复行为混为一谈。 - `with **` 会用提供的系统参数建立隔离块环境;块结束后恢复外层系统参数。 - 后缀 `with` 形式写在函数文件调用后面:`#Func() with array(...)`。 - `with array(...)` 只在该次调用里临时覆盖对应键,调用结束后会恢复外部原值。 @@ -110,7 +110,8 @@ writeLn(getSysParam("b")); - 块内输出 `2`、`3` - 块后输出 `2`、`3` -- 说明 `with *` 会把传入键合并进当前系统参数上下文;不要把它当成自动恢复外层值的隔离块 +- 这组实验只证明普通自定义键 `"a"` / `"b"` 会合并并在块后保留新值 +- 特殊系统环境变量在块结束后会恢复;不要用普通字符串键的结果外推股票、日期等特殊环境 `with *, SysParamArray do` 使用当前所有系统参数: @@ -443,7 +444,7 @@ end; - 把系统参数页直接写成金融函数页。 - 把 `#Func() with array(...)` 误判成也能直接套在本地函数 `Demo()` 后面。 -- 把 `with *` 误判成会自动恢复外层系统参数。 +- 以为 `with *` 对所有键都统一“不恢复”或统一“恢复”;普通自定义键与特殊系统环境变量的边界不同。 - 以为 `with array(...)` 改的是全局永久值,不会恢复外层原环境。 - 把网格句柄直接当最终值用,而不做 `dupvalue(...)`。 - 以为从全局缓存取出的值,本地写入后仍然保持缓存身份。 diff --git a/skills/tsl-syntax-reference/references/11_matrix_and_collections.md b/skills/tsl-syntax-reference/references/11_matrix_and_collections.md index ab29c5e5..f78006a8 100644 --- a/skills/tsl-syntax-reference/references/11_matrix_and_collections.md +++ b/skills/tsl-syntax-reference/references/11_matrix_and_collections.md @@ -31,7 +31,7 @@ - + 顺序数组与字符串键表: @@ -299,7 +299,7 @@ writeLn("子集 (1,0):", subset[1][0]); - + 基础算符作用于非完全矩阵(行长度不一致或字符串键不对齐的数组)时,对应位置不存在或为 `nil` 时**默认当 0 处理**: @@ -356,6 +356,32 @@ writeLn("(1,1):", result[1][1]); - `matrix_value + 10` 每个元素都加 10 - 这些是逐元素运算(element-wise),区别于矩阵乘法 `:*`,见 [21_matrix_deep_dive.md](21_matrix_deep_dive.md) +二维矩阵与一维数组做基础算术时,一维数组按“行”广播;它的长度必须等于矩阵行数: + +代码块身份:可直接照写示例 + +```tsl +matrix_value := array((1, 2, 3), (4, 5, 6)); +row_factors := array(10, 100); +left_result := matrix_value * row_factors; +right_result := row_factors * matrix_value; +writeLn(left_result[0][0]); +writeLn(left_result[1][2]); +writeLn(right_result[0][1]); +writeLn(right_result[1][0]); +``` + +代码块身份:输出片段 + +```text +10 +600 +20 +400 +``` + +说明:第一行使用 `10`,第二行使用 `100`;左右操作数交换后仍按行广播。不要把这条规则误写成按列广播。 + ## 本页不生成的范围 @@ -383,3 +409,4 @@ writeLn("(1,1):", result[1][1]); - 不要把普通 `array(...)` 自动升级成 `FMArray`;`FMArray` 专属事实见 [22_fmarray.md](22_fmarray.md)。 - 不要把点前缀比较 `.>` 和矩阵链式比较 `::>` 混用;`.>` 返回逻辑数组,`::>` 是链式比较。 - 不要以为非完全矩阵缺位会报错;默认当 `0` 处理。 +- 二维矩阵与一维数组运算时,不要把一维数组当成按列因子;它按行广播且长度要匹配行数。 diff --git a/skills/tsl-syntax-reference/references/13_ts_sql.md b/skills/tsl-syntax-reference/references/13_ts_sql.md index 5ce3f011..a61caf3b 100644 --- a/skills/tsl-syntax-reference/references/13_ts_sql.md +++ b/skills/tsl-syntax-reference/references/13_ts_sql.md @@ -19,8 +19,9 @@ - 在一维数组上做 TS-SQL 时,优先使用 `thisRow` 和 `thisRowIndex`。 - `select` 返回二维结果,`sselect` 返回一维结果,`vselect` 返回单值,`mselect` 返回 `Matrix`。 - `where`、`group by`、`order by` 可以直接接在 `from` 后面继续使用;`order by` 支持 `asc`/`desc` 与多列逗号分隔。 -- 分组后按聚集条件筛选用 `having`(`where` 不能用聚集);`having` 里用 `countof([字段])` 或 `countof(1)`,不要用 `countof(*)`。 +- 分组后按聚集条件筛选用 `having`(`where` 不能用聚集);计数可用 `countof([字段])`、`countof(1)`、`countof()`,也可用带空格的 `countof( * )`。无空格的 `countof(*)` 会与块注释起始符 `(*` 冲突。 - 多表 `join` 时,字段访问应写成 `[表序号].["字段名"]`;`on` 可用 `and` 写多条件;`[表序号].*` 取整表列。 +- 多表联接中,`thisRow(表序号)` 和 `thisRowIndex(表序号)` 分别取得指定来源表的当前整行与原始下标。 - 联接类型:`left join` 保留左表、`right join` 保留右表、`full join` 保留双方、`cross join` 笛卡尔积、逗号联接等价于 `cross join`;不匹配处用 `nil` 填充。 - `select` 列表支持 `distinct` 去重、`as 别名`、`as nil`(参与计算但不返回)、`起始列 to 结束列` 字段区间、`selectopt(位选项)`、`drange(区间/M of N)`。 - 聚集函数统一形态 `Func(Expr[, Cond[, N[, MovingFirst[, CacheId]]]])`:条件聚集、移动聚集、多字段聚集、`refof(Expr, N)` 引用相对行;`aggof('名', Expr)` 调用自定义聚集回调。 @@ -233,6 +234,23 @@ writeLn(join_result[0]["V2"]); 100 ``` +联接上下文里的指定来源当前行: + +代码块身份:可直接照写示例 + +```tsl +left_rows := array(("id": 1, "v": 10), ("id": 2, "v": 20)); +right_rows := array(("id": 2, "v": 20), ("id": 3, "v": 30)); +join_rows := select thisRow(1) as "LeftRow", + thisRow(2) as "RightRow", + thisRowIndex(1) as "LeftIndex", + thisRowIndex(2) as "RightIndex" + from left_rows join right_rows on [1].["id"] = [2].["id"] + end; +``` + +结果说明:唯一匹配行中,`LeftRow["v"] = 20`、`RightRow["v"] = 20`、`LeftIndex = 1`、`RightIndex = 0`。 + ### `thisGroup` @@ -773,6 +791,29 @@ writeLn(ref_prev[1]["Expr1"]); - 移动聚集:`avgof(表达式, 条件, N, MovingFirst)` 取当前行往前 N 条的滑动统计 - `refof(表达式, N)` 引用前 N 行的值(`N` 为负则往后);首行无前值时返回 `0` +`*` 作为聚集输入时必须和左括号留空格,避免 `(*` 被词法层识别为块注释: + +代码块身份:可直接照写示例 + +```tsl +a := array(("x": 1, "y": 10), ("x": 3, "y": 20)); +row_count := vselect countof( * ) from a end; +column_avg := select avgof( * ) from a end; +writeLn(row_count); +writeLn(column_avg[0]["Expr1"]); +writeLn(column_avg[0]["Expr2"]); +``` + +代码块身份:输出片段 + +```text +2 +2.0 +15.0 +``` + +说明:`avgof( * )` 对每列分别聚集;这里两列平均值依次为 `2`、`15`。 + ### `group by ... having` @@ -800,7 +841,7 @@ A 说明: - `having 聚集条件` 在分组后筛选(上例只保留成员数大于 1 的 `A` 组) -- `having` 里的计数用 `countof([字段])` 或 `countof(1)` +- `having` 里的计数优先用 `countof([字段])`、`countof(1)` 或 `countof()`;确需星号时写成 `countof( * )` 代码块身份:反例 / 不可照写 @@ -808,7 +849,7 @@ A having_rows := select ["cls"] from a group by ["cls"] having countof(*) > 1 end; ``` -`countof(*)` 这种带 `*` 的写法不成立,会报 `CountOf ( not found`。计数改用 `countof([字段])` 或 `countof(1)`。 +无空格的 `countof(*)` 会把 `(*` 词法组合解释成块注释开头,随后报 `CountOf ( not found`。改用 `countof( * )`、`countof()`、`countof([字段])` 或 `countof(1)`。 ### `thisOrder` 与多列 `order by` @@ -967,7 +1008,7 @@ query_result := select * from source_rows end; - 在 `left join` 时省略 `on` 子句或不用 `[表序号].["字段"]` 形式。 - 在 `insert` 时漏掉 `insertfields` 或字段数与值数不匹配。 - 期望 `update`/`delete` 返回新数组;它们直接修改原数组。 -- 用 `countof(*)` 数行数;`*` 星号形式不被支持,改用 `countof([字段])` 或 `countof(1)`。 +- 写无空格的 `countof(*)`,使 `(*` 与块注释起始符冲突;改用 `countof( * )`、`countof()`、`countof([字段])` 或 `countof(1)`。 代码块身份:反例 / 不可照写 @@ -975,7 +1016,7 @@ query_result := select * from source_rows end; n := vselect countof(*) from source_rows end; ``` -`countof(*)` 会报 `CountOf ( not found`。数行数改用 `countof([字段])` 或 `countof(1)`。 +`countof(*)` 会报 `CountOf ( not found`,原因是 `(*` 与块注释起始符冲突。星号写法加空格为 `countof( * )`,或改用 `countof()` / 明确表达式。 代码块身份:反例 / 不可照写 diff --git a/skills/tsl-syntax-reference/references/14_debug_and_profiler.md b/skills/tsl-syntax-reference/references/14_debug_and_profiler.md index 3cbd0ea1..5d3eb7b7 100644 --- a/skills/tsl-syntax-reference/references/14_debug_and_profiler.md +++ b/skills/tsl-syntax-reference/references/14_debug_and_profiler.md @@ -6,7 +6,7 @@ -回答“`goto`、`debugReturn`、`debugRunEnv`、`mtic` / `mtoc`、`setProfiler`、`__line__` 和 `__stack_frame` 怎样写、会怎样表现”。 +回答“`goto`、`debugReturn`、`debugRunEnv`、`mtic` / `mtoc`、`setProfiler`、内存伪变量、`__line__` 和 `__stack_frame` 怎样写、会怎样表现”。 ## 核心规则 @@ -24,6 +24,7 @@ - `setProfiler(7)` 配合 `getProfilerInfo(1)`,可以在不弹窗的情况下拿到性能分析器信息。 - `__line__` 会返回所在代码行号。 - `__stack_frame` 会返回调用栈帧数组;最小 `toStn(...)` 观察结果里,每一项是 `(line, "function")` 这一类二元组。 +- `_myMem_` 表示应用当前已使用内存,`_maxMem_` 表示应用允许使用的最大内存;两者是只读数值伪变量。单位和 `_maxMem_` 的具体取值由宿主环境决定。 ## 可直接照写示例 @@ -217,6 +218,32 @@ writeLn(length(info) > 0); - 说明 `setProfiler(7)` 可以开启性能分析器统计 - 说明 `getProfilerInfo(1)` 会直接返回性能分析器信息,而且结果是非空数组 +### 内存伪变量 `_myMem_` / `_maxMem_` + + + + + +代码块身份:可直接照写示例 + +```tsl +writeLn(ifNumber(_myMem_)); +writeLn(ifNumber(_maxMem_)); +``` + +代码块身份:输出片段 + +```text +1 +1 +``` + +说明: + +- `_myMem_` 是应用已使用内存的数值 +- `_maxMem_` 是宿主允许应用使用的最大内存数值;某些环境可能返回 `0` 表示未给出可比较的上限 +- 不要假设 `_maxMem_ >= _myMem_`,也不要在没有宿主文档时写死单位 + ### `__line__` 与 `__stack_frame` @@ -276,3 +303,4 @@ array( - 不要假设 `goto` 可以跨函数、跨脚本体或跳到单独成行的 `label`。 - 不要给计时或性能分析器调用补未写入文档参数。 - 不要把调试客户端副作用写成普通输出事实。 +- 不要假定 `_maxMem_` 总是非零、总是大于 `_myMem_`,或擅自指定内存单位。 diff --git a/skills/tsl-syntax-reference/references/15_lexical_structure_and_compile_options.md b/skills/tsl-syntax-reference/references/15_lexical_structure_and_compile_options.md index 8eabad11..c182000d 100644 --- a/skills/tsl-syntax-reference/references/15_lexical_structure_and_compile_options.md +++ b/skills/tsl-syntax-reference/references/15_lexical_structure_and_compile_options.md @@ -13,7 +13,7 @@ - 标识符大小写无关;下划线可出现在标识符中。 -- `//` 是行注释;首行 `#!` 可作为 CGI 风格注释;`{ ... }` 与 `(* ... *)` 是块注释。 +- `//` 是行注释;首行 `#!` 可作为 CGI 风格注释;`{ ... }` 与 `(* ... *)` 是块注释。两种块注释可以交错嵌套,同类块注释不能嵌套。 - 条件编译指令使用 `{$define}`、`{$undef}`、`{$ifdef}`、`{$ifndef}`、`{$else}`、`{$endif}`。 - 条件编译只编译命中的分支;未命中的分支不参与脚本编译。 - `{$explicit+}` 开启后,后续变量必须先用 `var` 声明;`{$explicit-}` 可以在同一源文件里重新关闭这个要求。 @@ -58,7 +58,7 @@ TSL 关键字大小写无关;本表统一按文档推荐写法展示。生成 - + 大小写无关与下划线标识符: @@ -112,6 +112,8 @@ writeLn(40); - 依次输出 `1`、`10`、`30` - 说明首行 `#!`、`//`、`{ ... }`、`(* ... *)` 都属于文档明确注释形态 +- 说明外层 `{ ... }` 可以包含 `(* ... *)`;反向交错也可用 +- 同类 `{ { ... } }` 或 `(* (* ... *) *)` 不构成嵌套注释,会产生语法错误 - 说明 `define` / `undef` / `ifdef` / `ifndef` / `else` / `endif` 这一组条件编译指令可以正常生效 ### 显式变量声明开关 @@ -184,27 +186,13 @@ writeLn(1); -`{$CompileOption}` 用于设置编译期开关,改变编译器的默认行为: +TSL 的编译选项使用 `{$Option+}` / `{$Option-}` 一类指令改变后续源码的编译方式。本页只拥有已经分别验证并有专题规则的选项: -代码块身份:可直接照写示例 +- `{$explicit+}` / `{$explicit-}`:切换变量是否必须预先声明,见上一节 +- `{$varByRef+}` / `{$varByRef-}`:切换未修饰形参的默认传递方式,见下一节 +- `{$ifdef ...}` 等条件编译指令:控制分支是否参与编译 -```tsl -{$CompileOption optimize=1} -echo 1 + 1; -``` - -代码块身份:输出片段 - -```text -2 -``` - -说明: - -- `{$CompileOption optimize=1}` 开启优化 -- 编译选项从出现位置开始生效,直到源文件结束或被其他选项覆盖 -- 常见选项包括 `optimize`、`buffermode`、`DebugInfo` 等 -- 编译选项细节以项目工具链和实际编译命令为准。 +`optimize`、`buffermode`、`DebugInfo` 等没有在本 skill 中形成可验证语义,不作为正式可生成选项。不要用“脚本仍能输出结果”来证明某个未知编译选项确实生效。 ### 参数默认传递开关 @@ -263,6 +251,8 @@ end; - 不要在 `{$explicit+}` 后继续直接使用未声明变量。 +- 不要同类嵌套 `{ ... }` 或 `(* ... *)` 块注释;需要嵌套时交错使用两种定界符。 +- 不要生成未在本页形成可验证规则的 `{$CompileOption optimize=...}`、`buffermode` 或 `DebugInfo`。 - 不要把 `{$i ...}` / `{$include ...}` 包含文件写法当成可用能力。 - 不要把 `反例 / 不可照写` 代码块复制进正向示例。 diff --git a/skills/tsl-syntax-reference/references/18_namespace_libpath_and_unit_runtime.md b/skills/tsl-syntax-reference/references/18_namespace_libpath_and_unit_runtime.md index 58479a2a..96b2d6a9 100644 --- a/skills/tsl-syntax-reference/references/18_namespace_libpath_and_unit_runtime.md +++ b/skills/tsl-syntax-reference/references/18_namespace_libpath_and_unit_runtime.md @@ -22,7 +22,7 @@ - 完整 `unit` 形态可以包含 `interface`、`implementation`、`initialization`、`finalization`,并以 `end.` 结束。 - `initialization` 在 `unit` 第一次被实际使用时触发,不是只因为顶层写了 `uses` 就立刻执行。 - `finalization` 会在脚本结束前触发。 -- 直接写 `DemoUnit.Member` 时,可以读到 `interface` 和 `implementation` 里的常量、变量。 +- 跨版本安全边界只保证 `interface` 中声明的常量、变量和函数可由引用者访问;只在 `implementation` 中声明的成员按私有内容处理。 - `findFunction("DemoUnit")` 拿到的是 `unit` 对象入口;本页只把它稳定暴露 `interface` 成员写成文档事实。 - `DemoUnit.var_name := value` 这种限定赋值不作为可写事实;如果要改 `unit` 状态,应导出函数或方法来改。 - `tslfilename()` 的参数规格使用 `tsl-api-reference` skill 按名查询;本页只保留它返回正在执行的 `.tsl` 主脚本完整路径这一行为事实。 @@ -111,7 +111,7 @@ FINAL -直接限定读取: +跨版本安全的限定读取: 代码块身份:配置片段 / 概念骨架 @@ -149,8 +149,6 @@ uses DemoUnit; writeLn(DemoUnit.public_const); writeLn(DemoUnit.public_var); -writeLn(DemoUnit.impl_const); -writeLn(DemoUnit.impl_var); writeLn(PublicFunc()); ``` @@ -158,10 +156,10 @@ writeLn(PublicFunc()); - `DemoUnit.public_const` 输出 `1`。 - `DemoUnit.public_var` 输出 `3`。 -- `DemoUnit.impl_const` 输出 `2`。 -- `DemoUnit.impl_var` 输出 `4`。 - `PublicFunc()` 输出 `10`。 -- 本页文档边界是:实现段函数仍私有,但实现段常量和变量可以通过 `DemoUnit.Member` 直接读取。 +- 本页的跨版本文档边界是:外部只依赖 `interface` 引出的成员;实现段里的常量、变量和函数都视为私有。 + +部分新一代解释器允许用 `DemoUnit.impl_const` / `DemoUnit.impl_var` 限定读取实现段数据,但这与经典 `unit` 可见性规则冲突,不作为跨环境默认生成能力。若项目已经依赖该行为,必须先按目标解释器实测并记录版本。 实现段函数的外部调用反例: @@ -491,7 +489,7 @@ writeLn(Hello()); - 把 `DemoUnit.var_name := value` 当成可用的限定赋值。 -- 以为 `implementation` 里的常量和变量一定都不能从 `DemoUnit.Member` 读到。 +- 默认从外部读取只在 `implementation` 中声明的成员;跨版本安全代码应通过 `interface` 导出。 - 以为 `findFunction("DemoUnit")` 暴露的成员范围和 `DemoUnit.Member` 完全相同。 - 把脚本内的 `namespace "..."` 当成和 `tsl.conf` 里的 `Namespace=...` 叠加,而不是覆盖。 - 把 `-LIBPATH` 放在脚本文件名前面。 diff --git a/skills/tsl-syntax-reference/references/22_fmarray.md b/skills/tsl-syntax-reference/references/22_fmarray.md index 1fa36cc1..fce35a6b 100644 --- a/skills/tsl-syntax-reference/references/22_fmarray.md +++ b/skills/tsl-syntax-reference/references/22_fmarray.md @@ -36,7 +36,7 @@ - + 代码块身份:可直接照写示例 diff --git a/skills/tsl-syntax-reference/scripts/lookup.py b/skills/tsl-syntax-reference/scripts/lookup.py index 608c19cb..25da24af 100644 --- a/skills/tsl-syntax-reference/scripts/lookup.py +++ b/skills/tsl-syntax-reference/scripts/lookup.py @@ -41,6 +41,7 @@ STRUCTURAL_METADATA_RE = re.compile( r"", re.DOTALL | re.IGNORECASE, ) +HTML_COMMENT_RE = re.compile(r"", re.DOTALL) IDENTITY_PREFIX = "代码块身份:" BLOCK_DESCRIPTION_PREFIX = "代码块说明:" ALLOWED_IDENTITIES = { @@ -156,7 +157,6 @@ CHINESE_QUERY_PARTICLES = ("的", "是", "吗", "呢", "吧") ASCII_FILTER_STOP_TOKENS = { "debug", "please", - "program", "tinysoft", "tsl", "tsf", @@ -480,7 +480,7 @@ def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section raise ReferenceStructureError(f"重复 section ID:{base_id}") seen_ids.add(base_id) tags = _section_tags(local_body) - searchable_body = STRUCTURAL_METADATA_RE.sub(" ", local_body) + searchable_body = HTML_COMMENT_RE.sub(" ", local_body) searchable_text = normalize( "\n".join((page.stem, page_title, *heading_path, *tags, searchable_body)) ) @@ -1122,9 +1122,9 @@ def _text_contains_exact_query(text: str, query: str) -> bool: def _intent_score(section: Section, query: str) -> int: aliases = PAGE_INTENT_ALIASES.get(section.page.name, ()) - return PAGE_INTENT_SCORE * sum( + return PAGE_INTENT_SCORE if any( _query_contains_phrase(query, alias) for alias in aliases - ) + ) else 0 def _has_chinese_context(section: Section, query: str) -> bool: @@ -1161,7 +1161,9 @@ def _tag_matched_tokens(tags: tuple[str, ...], query_token_set: set[str]) -> int def _code_text(body: str) -> str: inline = INLINE_CODE_RE.findall(body) fenced = FENCED_CODE_RE.findall(body) - return normalize("\n".join((*inline, *fenced))) + # 标识符信号只来自 ASCII 代码术语。中文散文会走标题、tag 和正文得分; + # 若把围栏里的“下标数组”等输出标签也当标识符,中文查询会被样例值劫持。 + return normalize("\n".join(ASCII_TOKEN_RE.findall("\n".join((*inline, *fenced))))) def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown: @@ -1169,8 +1171,7 @@ def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown: tokens = query_tokens(query) heading_text = normalize("\n".join(section.heading_path)) page_title_text = normalize(section.page_title) - body_without_metadata = STRUCTURAL_METADATA_RE.sub(" ", section.local_body) - body_text = normalize(SECTION_TAG_RE.sub(" ", body_without_metadata)) + body_text = normalize(HTML_COMMENT_RE.sub(" ", section.local_body)) tag_text = normalize("\n".join(section.tags)) term_text = _code_text(section.local_body) expanded_only_tokens = _synonym_tokens(query) - _base_query_tokens(query) @@ -1356,6 +1357,7 @@ def query_sections( ) ranked.sort( key=lambda match: ( + match.weak, -match.score, *(-value for value in match.priority), match.section.page.as_posix(), @@ -1400,10 +1402,9 @@ def _safe_json_string(value: str) -> str: def _plain_text_summary(body: str, limit: int = 180) -> str: - # 标签是检索元数据,不是事实正文;不能泄进候选摘要。 - without_metadata = STRUCTURAL_METADATA_RE.sub(" ", body) - without_tags = SECTION_TAG_RE.sub(" ", without_metadata) - without_fences = FENCED_CODE_RE.sub(" ", without_tags) + # HTML 注释都是维护元数据,不是事实正文;不能泄进候选摘要。 + without_comments = HTML_COMMENT_RE.sub(" ", body) + without_fences = FENCED_CODE_RE.sub(" ", without_comments) without_links = re.sub( r"!?\[([^\]]*)\]\([^)]+\)", lambda match: match.group(1), without_fences ) @@ -1464,7 +1465,7 @@ def render_candidates(result: QueryResult) -> str: def render_section(section: Section) -> str: - body = STRUCTURAL_METADATA_RE.sub("", section.body) + body = HTML_COMMENT_RE.sub("", section.body) body = re.sub(r"\n{3,}", "\n\n", body).rstrip() lines = [ "# TSL Syntax Section", diff --git a/tools/tsl-codegen/README.md b/tools/tsl-codegen/README.md index edd11454..cce92288 100644 --- a/tools/tsl-codegen/README.md +++ b/tools/tsl-codegen/README.md @@ -277,7 +277,19 @@ python tools/tsl-codegen/scripts/generate.py --dir tmp/api-recordings 生成器读取录入文件中的 `path`,默认写入 `skills/tsl-api-reference/references/codegen/project/.md`。不指定 -`--scope` 时,scope 就是 `project` +`--scope` 时,scope 就是 `project`。该路径默认相对于当前工作目录 + +从其他目录运行生成器时,使用 `--root` 指定包含 `skills/` 的项目根目录。例如在 +`tools/tsl-codegen` 目录中运行: + +```bash +python scripts/generate.py --file ../../tmp/my-api.json --root ../.. +``` + +此时仍会写入仓库根目录下的 +`skills/tsl-api-reference/references/codegen/project/.md`,不会在 +`tools/tsl-codegen` 下创建新的 `skills/` 目录。`--root` 接受绝对路径;相对路径按 +运行命令时的当前工作目录解析 写入前会自动使用仓库的 `.prettierrc.json` 格式化 markdown,使新页面与现有 builtin 页面保持一致。未安装 Prettier 或格式化失败时,生成器会停止且不写目标文件 diff --git a/tools/tsl-codegen/scripts/enrich_metadata.py b/tools/tsl-codegen/scripts/enrich_metadata.py deleted file mode 100644 index a4f97ad4..00000000 --- a/tools/tsl-codegen/scripts/enrich_metadata.py +++ /dev/null @@ -1,905 +0,0 @@ -#!/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 ```` 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"^$") -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"(? 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"" - 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()) diff --git a/tools/tsl-codegen/scripts/generate.py b/tools/tsl-codegen/scripts/generate.py index a2980ffd..505602a0 100644 --- a/tools/tsl-codegen/scripts/generate.py +++ b/tools/tsl-codegen/scripts/generate.py @@ -21,7 +21,7 @@ Function declaration fields: tags optional list of Chinese keywords -> `` params required when the signature takes args; omit for nullary returns required return type - example optional fenced tsl block, pasted verbatim + examples optional list of structured examples with desc/code/output fields Each param: name/type/desc required; optional (bool) -> `可选。` prefix; values (list of {value, desc}) -> a `name 取值` enum section. @@ -29,6 +29,7 @@ Usage (run from repo root): python tools/tsl-codegen/scripts/generate.py --file entry.yml python tools/tsl-codegen/scripts/generate.py --file entry.json \ --scope my-project + python scripts/generate.py --file entry.json --root ../.. python tools/tsl-codegen/scripts/generate.py --dir recordings """ @@ -279,7 +280,6 @@ def validate_function(fn, where, *, returns_required, extra_fields=()): "tags", "params", "returns", - "example", "examples", *extra_fields, } @@ -292,12 +292,6 @@ def validate_function(fn, where, *, returns_required, extra_fields=()): non_empty_string(fn.get("returns"), f"{where}:缺少 returns") elif "returns" in fn: optional_draft_string(fn["returns"], f"{where}: returns") - require( - not ("example" in fn and "examples" in fn), - f"{where}:example 和 examples 不能同时存在", - ) - if "example" in fn: - non_empty_string(fn["example"], f"{where}: example") if "examples" in fn: validate_examples(fn["examples"], where) return name @@ -333,7 +327,6 @@ def validate_class_member(member, where): "params", "returns", "modifiers", - "example", "examples", }, where, @@ -600,14 +593,6 @@ def render_examples(fn, heading_level): while lines and not lines[-1]: lines.pop() return lines - if fn.get("example"): - return [ - f"{'#' * heading_level} 示例", - "", - "```tsl", - *fn["example"].rstrip("\n").split("\n"), - "```", - ] return [] @@ -822,7 +807,7 @@ def format_markdown(text): return result.stdout -def output_path(data, scope): +def output_path(data, scope, root=None): """Build the leaf-page destination from the recording file's relative path.""" relative = data.get("path") require(relative, "录入数据缺少 path") @@ -854,7 +839,8 @@ def output_path(data, scope): ) relative_path = Path(*recorded_path.parts) return ( - Path("skills/tsl-api-reference/references/codegen") + (Path(root) if root is not None else Path()) + / "skills/tsl-api-reference/references/codegen" / scope / relative_path.with_suffix(".md") ) @@ -902,14 +888,14 @@ def gather_directory_inputs(directory, fmt): return inputs -def prepare_input(in_path, input_format, scope): +def prepare_input(in_path, input_format, scope, root=None): data = load_entries(in_path, input_format) try: rendered = render_page(data) except GenerationError as exc: raise GenerationError(f"录入数据校验失败:{exc}") from exc try: - out_path = output_path(data, scope) + out_path = output_path(data, scope, root) except GenerationError as exc: raise GenerationError(f"输出路径无效:{exc}") from exc try: @@ -941,11 +927,14 @@ def find_output_collisions(prepared): def main(argv=None): if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8") parser = ChineseArgumentParser( description="从 YAML/JSON 录入文件生成 TSL API 文档", usage=( - "%(prog)s [--help] [--scope SCOPE] [--format {json,yaml}] " - "(--file INPUT_FILE | --dir INPUT_DIR | INPUT_FILE)" + "%(prog)s [--help] [--root ROOT_DIR] [--scope SCOPE] " + "[--format {json,yaml}] " + "(--file INPUT_FILE | --dir INPUT_DIR)" ), add_help=False, allow_abbrev=False, @@ -955,12 +944,6 @@ def main(argv=None): action="help", help="显示本帮助并退出(不提供 -h 短选项)", ) - parser.add_argument( - "legacy_input", - nargs="?", - metavar="INPUT_FILE", - help="已废弃,请使用 --file;暂时兼容 YAML/JSON 录入文件路径", - ) parser.add_argument( "--file", dest="input_file", @@ -973,6 +956,11 @@ def main(argv=None): metavar="INPUT_DIR", help="批量生成目录中的直属 YAML/JSON 录入文件", ) + parser.add_argument( + "--root", + metavar="ROOT_DIR", + help="包含 skills 目录的项目根目录(默认:当前工作目录)", + ) parser.add_argument( "--scope", type=scope_name, @@ -986,16 +974,16 @@ def main(argv=None): ) args = parser.parse_args(argv) - input_modes = (args.legacy_input, args.input_file, args.input_dir) + input_modes = (args.input_file, args.input_dir) if sum(value is not None for value in input_modes) != 1: - parser.error("必须且只能指定一种输入方式:INPUT_FILE、--file 或 --dir") + parser.error("必须且只能指定一种输入方式:--file 或 --dir") is_batch = args.input_dir is not None try: if is_batch: input_paths = gather_directory_inputs(Path(args.input_dir), args.format) input_format = None else: - in_path = Path(args.input_file or args.legacy_input) + in_path = Path(args.input_file) if not in_path.is_file(): die(f"输入文件不存在或不是普通文件:{in_path}") input_paths = [in_path] @@ -1008,7 +996,9 @@ def main(argv=None): input_errors = [] for in_path in input_paths: try: - prepared.append(prepare_input(in_path, input_format, args.scope)) + prepared.append( + prepare_input(in_path, input_format, args.scope, args.root) + ) except GenerationError as exc: input_errors.append((in_path, exc)) diff --git a/tools/tsl-codegen/tests/test_enrich_metadata.py b/tools/tsl-codegen/tests/test_enrich_metadata.py deleted file mode 100644 index 883111e4..00000000 --- a/tools/tsl-codegen/tests/test_enrich_metadata.py +++ /dev/null @@ -1,778 +0,0 @@ -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( - "

" - + title - + "

简述
" - + f"
{description}
", - 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&2\nexit 9\n", - encoding="utf-8", - ) - fake_npx.chmod(0o755) + if os.name == "nt": + fake_npx = bin_dir / "npx.cmd" + fake_npx.write_text( + "@echo off\r\necho formatter-failed 1>&2\r\nexit /b 9\r\n", + encoding="utf-8", + ) + else: + fake_npx = bin_dir / "npx" + fake_npx.write_text( + "#!/bin/sh\necho formatter-failed >&2\nexit 9\n", + encoding="utf-8", + ) + fake_npx.chmod(0o755) env = os.environ.copy() env["PATH"] = str(bin_dir) @@ -884,6 +935,46 @@ class DocGenCliTest(unittest.TestCase): self.assertIn("范例02:多行输出", text) self.assertIn("// 输出:\n// first\n// second", text) + def test_singular_example_is_rejected_without_overwrite(self): + self.write_input( + { + "module": "项目 / 旧示例格式", + "path": "base/singular_example", + "declarations": [ + { + "kind": "function", + "name": "demo", + "signature": "demo()", + "desc": "示例函数。", + "returns": "nil", + "example": "return demo();", + } + ], + } + ) + + self.assert_rejected_without_overwrite( + "base/singular_example", "存在未知字段:example" + ) + + def test_singular_method_example_is_rejected_without_overwrite(self): + self.write_class_member_input( + { + "kind": "method", + "name": "Run", + "visibility": "public", + "binding": "instance", + "signature": "Run()", + "desc": "运行。", + "example": "return self.Run();", + }, + "base/singular_method_example", + ) + + self.assert_rejected_without_overwrite( + "base/singular_method_example", "存在未知字段:example" + ) + def test_top_level_enum_sections_are_plain_text_not_emphasis_headings(self): self.write_input( {