♻️ refactor(tsl-api): tighten docs and retire stale tests

This commit is contained in:
csh
2026-08-21 10:11:48 +08:00
parent 2e8c46e152
commit 5484fc7519
24 changed files with 505 additions and 2700 deletions
-3
View File
@@ -28,9 +28,6 @@ docs/superpowers/
# Local agent instructions
/AGENTS.md
# Agent 测试结果
test/agent/result/
__pycache__/
*.pyc
+1
View File
@@ -3,3 +3,4 @@ node_modules
tmp
reports
skills/thirdparty
/AGENTS.md
+5
View File
@@ -18,6 +18,11 @@ description: "当编写、修改或审查 TSL 代码,需要确认 API、class
[`workflows/class-and-framework.md`](workflows/class-and-framework.md)。
- 按中文业务含义查表、数据源或字段:读取
[`workflows/dictionary-lookup.md`](workflows/dictionary-lookup.md)。
- 涉及行业分类、基金分类或分类属性代码(`SWHY` / `CAPCHY` / `CSRCHY` / `YHFL` /
`TSJJ02` / `TSJJ03`)的代码体系与取数路径:读取
[`workflows/classification-codes.md`](workflows/classification-codes.md)。
- 需要先取一批证券代码(板块成分、指数成分、指定日在市列表、沪深港通名单):读取
[`workflows/code-sequences.md`](workflows/code-sequences.md)。
具体参数、输出字段和退出码以所选脚本当前的 `--help` 为权威。
`<this-skill-dir>` 替换为包含本 `SKILL.md` 的目录。
@@ -59,7 +59,7 @@ return ContinuedMatchOfNDay(@close(), 3);
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------- |
| ----------- | ------ | -------------------- |
| `exp_1` | string | 字符串。统计表达式1 |
| `exp_2` | string | 字符串。统计表达式2 |
| `n` | int | 整数。天数 |
@@ -165,7 +165,7 @@ N日线性衰减加权平均,与系统参数(股票,日期,周期,复权
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------ |
| ----------- | ------ | -------------------- |
| `exp` | string | 字符串。统计表达式 |
| `n` | int | 整数。天数 |
| `cache_str` | string | 可选。字符串,缓存串 |
@@ -294,7 +294,7 @@ N日最高点到当前的周期数。N日表示市场交易日,当日最低为
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------ |
| ----------- | ------ | -------------------- |
| `exp` | string | 字符串。统计表达式 |
| `n` | int | 整数。天数 |
| `cache_str` | string | 可选。字符串,缓存串 |
@@ -423,7 +423,7 @@ N日最低点到当前的周期数。N日表示市场交易日,当日最低为
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------ |
| ----------- | ------ | -------------------- |
| `exp` | string | 字符串。统计表达式 |
| `n` | int | 整数。天数 |
| `cache_str` | string | 可选。字符串,缓存串 |
@@ -525,7 +525,7 @@ return OccuredOfNDay(@close(), 10);
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------ |
| ----------- | ------ | -------------------- |
| `exp` | string | 字符串。统计表达式 |
| `n` | int | 整数。天数 |
| `cache_str` | string | 可选。字符串,缓存串 |
@@ -551,7 +551,7 @@ return PercentRankOfNDay(@close(), 10, "");
<!-- tags: 数据仓库 向导函数 N 日 -->
| 参数 | 类型 | 说明 |
| ----------- | ------ | ------------------ |
| ----------- | ------ | -------------------- |
| `exp` | string | 字符串。统计表达式 |
| `n` | int | 整数。天数 |
| `cache_str` | string | 可选。字符串,缓存串 |
@@ -131,7 +131,7 @@ Return BondBasisPointValue(Endt, MV);
<!-- tags: 金融 债券 债券敏感性指标 返回 获取 TS -->
| 参数 | 类型 | 说明 |
| ----------------- | -------- | -------- |
| ----------------- | -------- | ---------------------------------- |
| `settlement_date` | datetime | 结算日期 |
| `option` | int | 可选。是否行权,默认 0(持有到期) |
@@ -234,7 +234,7 @@ return bondMacaulayDuration(endt); // 麦考利久期,返回3.12
<!-- tags: 金融 债券 债券敏感性指标 返回 获取 TS -->
| 参数 | 类型 | 说明 |
| ---------------- | -------- | -------- |
| ---------------- | -------- | ---------------------------------- |
| `settlementdate` | datetime | 结算日 |
| `option` | int | 可选。是否行权,默认 0(持有到期) |
@@ -994,7 +994,7 @@ return FundZjRatio(endt);
<!-- tags: 金融 基金 基金收益率 分级基金 折价率 返回 获取 -->
| 参数 | 类型 | 说明 |
| ----------- | -------- | ------------------------------------------- |
| ----------- | -------- | ------------------------------------------ |
| `end_t` | datetime | 截止日期 |
| `fund_type` | integer | 基金类别;原始文档示例中 `2` 表示分级 B 类 |
@@ -196,7 +196,7 @@ return op_hv("SZ000002", 20210101T, -6);
<!-- tags: 金融 期权 基本函数 隐含波动率 Greeks Delta Gamma Theta Vega Rho -->
| 参数 | 类型 | 说明 |
| --------------- | ------- | ---------------------------------------------------- |
| --------------- | ------- | --------------------------------------------------- |
| `option_type` | integer | 期权类型,0 为看涨期权,1 为看跌期权 |
| `s` | real | 现货价格 |
| `x` | real | 行权价 |
@@ -92,7 +92,7 @@ return bondFutureBasicindicators2(endt, -1, 1, 0);
<!-- tags: 金融 专题函数 衍生品分析 期货专项 常用国债期货基本指标 基于债券估值 返回 获取 -->
| 参数 | 类型 | 说明 |
| --------------- | ----------- | ----------------------------- |
| --------------- | ----------- | ---------------------------------- |
| `end_t` | date | 日期,截止日 |
| `bond_id` | string | 字符串,债券代码 |
| `return_option` | user_define | 用户自定义, 返回结果选项 |
@@ -9,7 +9,7 @@
<!-- tags: 数学算法 压缩估计 均值 -->
| 参数 | 类型 | 说明 |
| -------- | ------ | ------------------------------------------------------------------------------- |
| -------- | ------ | -------------------------------------------------------------------------- |
| `data` | array | 二维数据表,T×N,T 期样本数量,N 个股票 |
| `b_type` | string | 目标均值向量:b0 零向量、b1 均值、b2 波动率加权均值、b3 样本均值的均方误差 |
@@ -56,11 +56,11 @@
- 农产品生产者价格表统计的是农产品价格的变化情况。
- 区别于农产品生产者价格(简表):
- 1. 本表获取历史数据更多,数据开始时间为 2003 年一季度,简表的数据开始时间为 2007 年一季度;
- 2. 简表含有部分生活常见的农产品(与食用相关)价格数据,包含本季同比和累计同比两种类型数据,本表统计的是涵盖农林牧渔业的部分农产品的价格,范围更广,多包含林业等与工业材料相关产品的价格数据,只含有本季同比的数据;
- 3. 简表记录数据的公布时间,精确到秒,此表仅记录日期;
- 4. 简表数据公布会早于此表;
- 5. 没有简表中的累计同比数据。
- 1\. 本表获取历史数据更多,数据开始时间为 2003 年一季度,简表的数据开始时间为 2007 年一季度;
- 2\. 简表含有部分生活常见的农产品(与食用相关)价格数据,包含本季同比和累计同比两种类型数据,本表统计的是涵盖农林牧渔业的部分农产品的价格,范围更广,多包含林业等与工业材料相关产品的价格数据,只含有本季同比的数据;
- 3\. 简表记录数据的公布时间,精确到秒,此表仅记录日期;
- 4\. 简表数据公布会早于此表;
- 5\. 没有简表中的累计同比数据。
- 1、数据开始时间:2003年一季度
- 2、数据更新频率:季度
@@ -111,9 +111,9 @@
- 中国从1992年1季度开始到2015年2季度,采用累计核算方式核算季度GDP,即分别计算各年1季度,1-2季度,1-3季度和1-4季度的GDP数据,1-4季度 GDP初步核算即为年度GDP初步核算。从2015年3季度开始改为分季核算方式,即分别计算各年1季度,2季度,3季度和4季度的GDP数据,累计数据通过当季数据相加得到。
- 此表为核算后数据。
- 该表与国内生产总值(简表)区别:
- 1. 数据获取历史周期更长,数据开始时间为1992年一季度;
- 2.包含数据类别更多,统计项目更多;
- 3.本表为核算调整后的数据;
- 1\. 数据获取历史周期更长,数据开始时间为1992年一季度;
- 2\.包含数据类别更多,统计项目更多;
- 3\.本表为核算调整后的数据;
- 1、数据开始时间:1992年一季度
- 2、数据更新频率:季度
@@ -211,10 +211,10 @@
- 居民消费价格主要介绍的是居民消费价格分类及其指数的变化情况。
- 区别于居民消费价格(简表)
- 1. 数据获取历史周期更长,本表数据开始时间 1987 年一月,简表数据开始为 2007 年一月;
- 2. 简表重点于消费价格的细类划分,把居民消费分为八个大类再分别统计其子类的数据,本表主要表现城乡消费模式的差异,分别统计全国、城市、农村主要大类产品的价格数据。
- 3. 简表记录数据的公布时间,精确到秒,此表仅记录日期;
- 4. 简表数据公布会早于此表;
- 1\. 数据获取历史周期更长,本表数据开始时间 1987 年一月,简表数据开始为 2007 年一月;
- 2\. 简表重点于消费价格的细类划分,把居民消费分为八个大类再分别统计其子类的数据,本表主要表现城乡消费模式的差异,分别统计全国、城市、农村主要大类产品的价格数据。
- 3\. 简表记录数据的公布时间,精确到秒,此表仅记录日期;
- 4\. 简表数据公布会早于此表;
- 1、数据开始时间:1987年一月
- 2、数据更新频率:月度
@@ -0,0 +1,167 @@
# 分类属性代码(行业与基金分类)
天软把「个券归属会随时间变更」的分类维护成**分类属性**,用一套属性代码
(如 `SWHY210000`)标识。分类目录本身和个券归属分别放在不同的字典表里,
取数入口也不同。本页收拢这类分类的代码体系与取数路径。
本页是手写整理页,不由字典生成器产出;`references/data_dictionary/` 下的页面
是生成产物,不要把这里的内容写回那些页面。
## 目前维护的分类属性
| 资产 | 所属属性代码 | 分类名称 | 级数 | 备注 |
| ---- | ------------ | ------------------ | ---- | --------------------------- |
| 股票 | `SWHY` | 申万行业 | 三级 | |
| 股票 | `CAPCHY` | 中上协行业 | 二级 | 2024-02-19 起接替证监会行业 |
| 股票 | `CSRCHY` | 证监会行业 | 二级 | **2024-02-19 起不再维护** |
| 基金 | `YHFL` | 银河基金分类 | 二级 | 数据起始 2021-01-04 |
| 基金 | `TSJJ02` | 投资风格 | 二级 | |
| 基金 | `TSJJ03` | 净值增长率计算方法 | 二级 | |
属性代码的构成是「所属属性代码 + 层级编号」,例如 `SWHY``SWHY210000`(申万采掘,
一级)→ 其下二级、三级继续细分;`CSRCHY``CSRCHYA`(农、林、牧、渔业)→
`CSRCHYA01`(农业)。
## 两类表:分类目录 vs 个券归属
分类目录(有哪些分类、上下级关系、启用与剔除日期)和个券归属(某只券在某段时间属于
哪个分类)是两张不同的表,访问代码不同:
| 表 ID | 表名 | 访问代码 | 用途 |
| ----- | --------------------- | ------------------------------------- | ------------------ |
| 138 | 股票.股票行业分类信息 | **属性代码**,如 `SWHY``SWHY110000` | 股票分类目录 |
| 355 | 基金.基金分类信息 | **属性代码**,如 `YHFL``YHFL010000` | 基金分类目录 |
| 356 | 基金.基金分类 | **基金代码**,如 `OF000316` | 单只基金的分类归属 |
138 与 355 的字段结构一致:`属性代码``属性名称``级数``上级属性代码`
`上级属性名称``入选日期``剔除日期``最新标识``所属属性代码`
传错访问代码是这里最常见的错误:把基金代码传给 355、或把属性代码传给 356,都不会报错,
只会返回空表。字段清单与取数示例见字典页,用
`dictionary_lookup.py --query 股票行业分类信息` / `--query 基金分类信息` 取回。
## 取分类目录
按属性代码取某个分类节点及其记录:
```tsl
// 申万行业分类目录(全部层级)
return select * from infotable 138 of "SWHY" end;
```
`getbk(板块名)` 可以一次取回某个分类下的全部属性代码,再喂给 `infotable`
```tsl
// 申万行业的全部分类属性代码,按级数分组展示
codes := getbk("申万行业");
t := select * from infotable 138 of codes end;
return select ['级数'], select * from thisgroup end as '分类列表'
from t group by ['级数'] order by ['级数'] end;
```
`getbk("属性代码")` 是跨资产的总入口,同时覆盖股票与基金两侧:
```tsl
codes := getbk("属性代码");
t1 := select "股票" as '资产', * from infotable 138 of codes end;
t2 := select "基金" as '资产', * from infotable 355 of codes end;
return t1 union t2;
```
`getbk` 取的是**最新**成分,且入参是板块名称而非代码;签名与板块名约束见
`lookup.py --name getbk`
## 沿层级向下走
分类属性代码后缀 `.N` 表示「该属性下的所有下级分类属性代码」:
```tsl
// 指定日所有申万一级行业代码
return getbkbydate("SWHY.N", 20210423T);
// 指定一级行业下的二级行业代码
return getbkbydate("SWHY210000" $ ".N", 20210423T);
```
三级同理,用二级代码加 `.N`。也有直接返回下级列表的函数,签名用
`lookup.py --name stockIndustryLowerArr` / `--name fundYHIndustryArr` 取回。
## 取分类成分
不带 `.N` 时,`getBkByDate` 把属性代码当分类节点,返回该分类在指定日的成分券:
```tsl
return getbkbydate("SWHY740000", 20220101T); // 申万煤炭成分股
return getbkbydate("CAPCHYD", 20240219T); // 中上协:电力、热力、燃气及水
return getbkbydate("YHFL030000", 20220101T); // 银河债券型成分基金
return getbkbydate("TSJJ0201", 20220101T); // 投资风格:股票型
```
`getBkByDate` 的字典条目只描述了指数代码入参,分类属性代码入参同样成立;两种入参
共用一个函数。签名与 `extype` 参数见 `lookup.py --name getBkByDate`
## 行业分类代码 ≠ 行业指数代码
同一个申万行业有两套代码,用途不同,混用会取到空结果:
| 用途 | 代码形态 | 示例 |
| -------------------- | ------------------------ | ----------------------------- |
| 行业**分类**属性代码 | `SWHY` + 层级编号 | `SWHY210000` 申万采掘 |
| 行业**指数**代码 | `SWHY00000N`N 为级数) | `SWHY000001` 申万一级行业指数 |
取行业指数代码序列走指数侧:
```tsl
// 指定日所有申万一级行业指数代码
return getbkbydate("SWHY000001", 20190201T);
// 最新的行业指数序列也可按板块名取
return getbk("申万一级行业指数");
```
二级、三级分别对应 `SWHY000002` / `SWHY000003`,板块名分别是
`申万二级行业指数` / `申万三级行业指数`。代码转名称用
`lookup.py --name stockName`
## 反查:个券在指定日属于哪个分类
申万各级有成对的名称/编码查询函数,与系统参数「证券」「日期」相关:
```tsl
setSysParam(pn_stock(), "SZ000002");
setSysParam(pn_date(), 20200101T);
return array("一级名称": stockSWIndustryName1(),
"一级编码": stockSWIndustryID1(),
"二级名称": stockSWIndustryName2(),
"二级编码": stockSWIndustryID2(),
"三级名称": stockSWIndustryName3(),
"三级编码": stockSWIndustryID3());
```
批量反查配合 `spec(...)` 逐券切换系统参数:
```tsl
endt := 20240103T;
stocks := getbkbydate("SH000016", endt);
setSysParam(pn_date(), endt);
return select thisRow as "sid",
spec(stockSWIndustryName1(), thisRow) as "一级行业名称",
spec(stockSWIndustryID1(), thisRow) as "一级行业编码"
from stocks end;
```
这六个函数无参数,签名逐个用 `lookup.py --name stockSWIndustryName1` 等确认。
按板块分类批量反查另有 `stocksbklist`,其 `bktype` 是整数板块类别,取值不在本页
事实范围内,用前先确认。
## 其它取数代码
少量非个券维度的数据也用代码位取:`HG000001` 宏观数据表格,`HG000001``HG000004`
分别对应港股通(沪)、沪股通、港股通(深)、深股通方向。其余特别数据(如国债收益率
曲线)按字典页或专题文档确认,不在本页枚举。
## 事实边界
本页只给代码体系与取数路径。函数签名、参数类型、返回形态一律以
`lookup.py --name` 的条目正文为准,本页示例不作为签名依据。具体分类下有哪些属性代码
会随上游调整,不在本页硬编码;用上面的 `getbk` + `infotable` 组合在运行时取回当前列表。
@@ -0,0 +1,194 @@
# 证券代码序列取数
「先拿到一批证券代码,再逐个取指标」是天软里最常见的起手动作。代码序列有三条互不
等价的取数路径,选错路径会静默拿到错误的样本集:
| 路径 | 函数 | 时点语义 | 入参 |
| ----------------- | -------------- | ------------------ | -------------------------- |
| 板块最新成分 | `getbk` | **最新**,不带日期 | 板块中文名 |
| 板块历史成分 | `getAbkbyDate` | 指定日 | 板块中文名(仅限部分板块) |
| 指数/分类历史成分 | `getBkByDate` | 指定日 | 指数代码或分类属性代码 |
`getbk` 没有日期入参,拿到的永远是当前成分;做历史回测时用它会引入前视偏差。
签名与各自的适用范围用 `lookup.py --name getbk` / `--name getAbkbyDate` /
`--name getBkByDate` 取回。
本页是手写整理页,不由字典生成器产出。
## 板块名是复合的
多个板块名用英文分号拼接,一次传入:
```tsl
return getbk("深证A股;上证A股"); // 只要主板
return getbk("A股;暂停上市;终止上市"); // 含退市与暂停上市
return getbk("港股;终止上市港股"); // 全部港股
return getbk("上市指数;退市指数"); // 全部指数
return getbk("上证基金;深证基金"); // 在市基金交易代码
```
`A股` 本身就是复合板块,等于 `深证A股;上证A股;创业板;科创板;北证A股`。需要单独一个
板块时直接写该板块名,不要在 `A股` 结果上二次过滤。
板块划分会随上游调整:港股在 2026-06-25 之前全部在「港股」板块内,之后拆出
「终止上市港股」。依赖板块名的代码在跨越这类调整时要复核。
具体有哪些板块名不在本页事实范围内;`getbk` 要求名称与板块管理中的名称完全一致,
先在客户端板块管理里确认。
## 指定日的市场板块成分
`getAbkbyDate(bk_name, endt)` 按板块中文名取指定日成分,并剔除暂停上市:
```tsl
return getAbkbyDate("A股", 20200210T);
return getAbkbyDate("深证A股;上证A股;北证A股", 20200210T);
```
它只支持市场板块(`A股``上证A股``深证A股``北证A股``创业板``科创板`
`中小企业板`),其它板块名不支持。
另一条等价路径是用天软自编的市场板块**指数**代码走 `getBkByDate`
| 代码 | 板块 | 代码 | 板块 |
| ----------- | ------- | ----------- | ------- |
| `TSI000001` | A股 | `TSI000006` | 北证A股 |
| `TSI000002` | 上证A股 | `TSI000007` | B股 |
| `TSI000003` | 科创板 | `TSI000008` | 上证B股 |
| `TSI000004` | 深证A股 | `TSI000009` | 深证B股 |
| `TSI000005` | 创业板 | | |
```tsl
return getbkbydate("TSI000001", 20230810T); // A股板块 20230810 成分
```
## 指数成分必须用指数代码
指数成分来源于指数成份变更表(`infotable 752` 指数.指数成份),与板块来源不同,
只能用指数代码取:
```tsl
return getbkbydate("SH000300", 20210908T); // 沪深300 指定日成分
```
板块名传给 `getBkByDate` 不会报错,只会返回空表。字段清单用
`dictionary_lookup.py --query 指数成份` 取回。
## 行业分类与行业指数是两套代码
`getbkbydate("SWHY740000", ...)` 取的是**行业分类**成分(来源:个股所属行业),
`getbkbydate("SW801150", ...)` 取的是**行业指数**成分(来源:指数成份表)。两者
样本可能不同。先用 `SWHY00000N` 拿到该级全部行业指数代码,再逐个取成分:
```tsl
idx := getbkbydate("SWHY000001", 20190201T); // 所有申万一级行业指数代码
return getbkbydate(idx[0], 20190201T); // 其中一个的成分股
```
分类属性代码体系见 [`classification-codes.md`](classification-codes.md)。
## 沪深港通
沪深港通合资格名单也记在 `infotable 752`,用四个方向虚拟代码取:
| 代码 | 方向 | 代码 | 方向 |
| ---------- | ------------ | ---------- | ------------ |
| `HG000001` | 港股通(沪) | `HG000003` | 港股通(深) |
| `HG000002` | 沪股通 | `HG000004` | 深股通 |
```tsl
// 指定日北向(A股)合资格名单
return getbkbydate("HG000002", 20240701T) union2 getbkbydate("HG000004", 20240701T);
// 指定日南向(港股)合资格名单
return getbkbydate("HG000001", 20240701T) union2 getbkbydate("HG000003", 20240701T);
// 曾经进过港股通的全部港股代码
return sselect distinct ["证券代码"]
from infotable 752 of array("HG000001", "HG000003") end;
```
## 基金:在市与运作状态是两回事
板块决定「是否上市」,运作状态要另外筛:
```tsl
return getbk("上市基金"); // 当前在市
return getbk("已退市基金"); // 已退市
return getbkall("普通股票型"); // 某分类全部,含已停止运作
return fundOperateFundsbyBkname("上市基金;已退市基金", 20200924T); // 指定日仍在运作
return fundsOperateFunds(getbkall("股票型"), 20200101T); // 对给定列表筛运作中
```
`getBkAll` 返回板块全部样本含退市,但对一批板块不适用(A股类、指数成份类、概念板块
等),限制清单见 `lookup.py --name getBkAll` 的条目正文。
按基金分类取指定日成分优先走分类属性代码:
```tsl
return getbkbydate("TSJJ0201", 20220101T); // 投资风格:股票型
```
一级市场代码与二级市场(交易)代码不同:`getbk("上证基金;深证基金")` 取的是交易代码,
两者可通过基金基本信息表里的交易代码字段互转。
## 债券
```tsl
return getbk("深交所债券;上交所债券;银行间债券"); // 当前在市全部债券
return getbk("可转债"); // 当前在市可转债
return getbk("可转债;退市可转债"); // 含退市
return getbkall("可转债"); // 同上,走 getBkAll
return bondConvertibleBonds2(); // 专用函数:所有可转债
return cb_getTradeMarkByDate(20210813T); // 指定日有交易的转债
```
券种板块名(`国债``企业债` 等)走同一套 `getbk` 路径。两个专用函数的签名见
`lookup.py --name bondConvertibleBonds2` / `--name cb_getTradeMarkByDate`
## 期货
期货有「合约」和「品种」两层,代码序列要分清取哪一层:
```tsl
return getbk("上市期货"); // 当前在市合约
return getbk("上市期货;退市期货"); // 含退市合约
return getbk("期货品种代码"); // 品种代码(CU、IF 这类)
return getbk("期货品种代码;退市期货品种代码");
return getbk("期货主力"); // 主力线;期货连续/次主力/指数线同理
return getFuturesBkbydate("上市期货;退市期货", 20251218T); // 指定日在市合约
return getFuturesBkbydate("郑州商品交易所;中国金融期货交易所", 20240508T);
```
`getFuturesBkbydate` 的板块名可以是交易所名、期货类别(`股指期货``商品期货`
`农产品期货`)或品种名,签名见 `lookup.py --name getFuturesBkbydate`
品种代码与主力/次主力/指数线/连续代码的对照关系在 `infotable 708`
(期货.期货品种代码对照表),任一形态的代码都能查到其余形态;访问代码与字段用
`dictionary_lookup.py --query 期货品种` 取回。按品种取全部合约要先把品种代码换成
品种名称再走 `getBkAll`
## 期权
```tsl
return getbk("上市期权"); // 当前在市合约
return getbk("ETF期权"); // 按标的类型
return getbk("华泰柏瑞沪深300ETF期权"); // 按标的名称
```
## 其它代码位
`HG000001` 也用作宏观数据表格的取数代码位(如国内生产总值 816 表、居民消费价格
822 表)。其余特别数据(国债收益率曲线等)按字典页确认。
## 事实边界
本页只给取数路径与代码位体系。函数签名、参数类型、返回形态一律以
`lookup.py --name` 的条目正文为准。板块名清单、分类代码清单会随上游调整,不在本页
硬编码,用运行时取回。
FAQ 中出现但当前 API 索引查不到的名称(`GetETFListByDate``GetFuturesID`)不作为
可用 API;需要时先用 `lookup.py --kw` 确认存在再使用,查不到就按 SKILL.md 的
CHECKPOINT 停止。上表中 `getbk` 系列、`getAbkbyDate``getBkByDate``getBkAll`
`getFuturesBkbydate``fundOperateFundsbyBkname``fundsOperateFunds`
`bondConvertibleBonds2``cb_getTradeMarkByDate` 均已在索引中核对存在。
+5 -6
View File
@@ -21,7 +21,8 @@
- 键必须与 `references/` 下的文件名完全一致;每个参考页至少一条短语。
键写错或参考页改名后 `_intent_score` 会静默返回 0 分,该页失去自然语言
入口。`lookup.py --check` 负责拦截页键漂移,行为回归测试负责拦截排序漂移。
入口。`lookup.py --check` 负责拦截页键漂移;查询排序以 lookup 实现和实际
查询结果为准。
## Skill 维护与校验
@@ -36,14 +37,12 @@ Section ID 来自标题下的显式 `<!-- section-id: ... -->` 元数据,不
```bash
python skills/tsl-syntax-reference/scripts/lookup.py --check
python -m unittest test.test_tsl_syntax_reference -v
```
`--check` 会校验 `page_intent_aliases` 的键与 `references/` 参考页一一对应:
键指向不存在的页、或某页没有自然语言入口都会报错。新增参考页时必须同时
在这里补一条页级意图短语。它只做结构检查,不验证自然语言排序;原始 alias、
助词变体、弱命中和已知真实问法由专属测试覆盖
在这里补一条页级意图短语。它只做结构检查,不验证自然语言排序;自然语言
排序和真实问法应通过实际查询验证
`--check` 还覆盖显式 ID、标题层级、quickstart 派生与 Owner Section、代码块身份、
本地链接和大页粒度;它不证明事实语义、检索排序或示例运行结果。仓库级 unittest
负责 CLI、排序与退出码行为。
本地链接和大页粒度;它不证明事实语义、检索排序或示例运行结果。
+18 -20
View File
@@ -1,21 +1,22 @@
# 🧪 Playbook 测试套件
本目录包含 Playbook 项目的测试,用于验证 CLI、模板与文档链接
本目录包含 Playbook 项目的可执行行为测试。
## 📋 目录结构
```txt
test/
├── README.md # 本文件:测试文档
├── test_playbook.py # snapshot/subtree 参数化完整部署测试
├── test_template_contracts.py # 模板内容、占位符、文案契约测试
├── test_commit_message_policy.py # 提交信息校验器和 CI 委托行为
├── test_gitea_fetch_ci_logs.py # Gitea CI 日志采集行为
├── test_main_loop_cli.py # ticket 调度、隔离、证据与集成测试
├── agent/ # Agent 题面/运行时验证测试定义
├── test_thirdparty_skills_pipeline.py # thirdparty skills 流水线配置与同步产物测试
├── test_tsl_playbook_sync.py # TSL Playbook 构建与同步边界测试
├── test_tsl_syntax_reference.py # TSL 语法文档结构与检索测试
── integration/ # 集成测试
└── check_doc_links.py # 文档链接有效性检查
├── test_main_loop_scheduler.py # 全局调度器和依赖图行为
├── test_playbook.py # snapshot/subtree 参数化完整部署测试
├── test_thirdparty_skills_pipeline.py # thirdparty skills 流水线配置与同步行为
├── test_tsl_api_framework.py # TSL API 索引和 Framework Profile 行为
── test_tsl_playbook_sync.py # TSL Playbook 构建与同步行为
└── integration/ # 文档完整性检查
└── check_doc_links.py # 本地 Markdown 链接目标检查
```
## 🚀 快速开始
@@ -24,10 +25,10 @@ test/
# 进入 playbook 根目录
cd /path/to/playbook
# 1. 运行 Python 测试(test/ 下的 test_*.py
# 运行 Python 测试(test/ 下的 test_*.py
python -X utf8 -B -m unittest discover -s test -p "test_*.py" -v
# 2. 运行文档链接检查
# 文档链接检查
python test/integration/check_doc_links.py
```
@@ -51,20 +52,17 @@ python test/integration/check_doc_links.py
- rules、memory bank、prompts、standards 和 skills 同步
- 重复执行的幂等性与自定义文件保留
### 2. 模板合同测试 (`test_template_contracts.py`)
随 Python 测试检查通用模板的关键 marker、占位符和流程合同。
### 3. 主循环测试 (`test_main_loop_cli.py`)
### 2. 主循环测试 (`test_main_loop_cli.py`)
使用临时 Git 仓库验证 ticket 队列、串行与 worktree 隔离、并发 claim、heartbeat/reclaim、
验证与 review 证据绑定,以及 ticket/feature 两级集成。
### 4. TSL 契约测试
### 3. TSL 和同步行为测试
- `test_tsl_api_framework.py`:验证 API 索引、class 查询和 Framework Profile 行为
- `test_tsl_playbook_sync.py`:验证 TSL Playbook 构建和同步文件边界
- `test_tsl_syntax_reference.py`:验证语法文档结构、section ID 与检索结果
- `test_thirdparty_skills_pipeline.py`:验证 thirdparty manifest、workflow 和同步产物
### 5. 文档链接检查 (integration/)
### 4. 文档链接检查 (`integration/`)
扫描 `docs/` 与模板文件中的本地链接,确保引用路径有效。
扫描仓库中的 Markdown 文件,确保本地文档引用的目标路径有效。
-296
View File
@@ -1,296 +0,0 @@
# TSL Agent 测试定义
你是 TSL agent 测试执行方。
阅读本 README 后,先向用户询问本机 `tsl` 可执行文件路径。
用户提供路径后,优先自动识别被测 agent 名称;无法可靠识别时,向用户确认一次
agent 短名。随后严格按本文的标准测试流程执行。
轮次边界必须严格执行:
- 第一轮只允许生成首答、落盘、运行验证和记录评分;禁止修复,禁止把运行错误回传给被测 agent,禁止修改第一轮结果文件,也禁止测试执行方在命令失败后自动编辑文件。
- 本轮计划测试的所有 case 完成第一轮评分后,才允许进入第二轮修复。
- 第二轮只修复第一轮已经记录为 `fail` 的 case;每修复某个 case 一次,只增加该 case 的 `repair_count`
这个目录包含 TSL 语法的 agent 测试用例定义。当前评分以运行时验证为准:
生成的 `.tsl``.tsf` 能按标准流程执行通过即为通过,执行报错、超时或文件类型错误即为失败。
## 测试定义
- `prompts_zh.md`:给被测 agent 的题面文件。
- 包含 100 个唯一测试用例。
- 只包含题面,不包含答案、评分细则或事实来源映射。
- 除非题面明确要求 `.tsf`,默认都要求生成 `.tsl`
不再维护单独的 rubric 文件。不要用人工答案表给被测 agent 提供额外线索。
## 测试产物
所有测试结果都落盘为 `.tsl``.tsf` 文件,不保存 agent 回答 markdown。
- 存放目录:`test/agent/result/<agent>/<YYYYMMDD>/`
- `<agent>` 由测试执行方优先自动识别。
- 识别来源优先使用被测 agent 的产品名、命令名、SDK provider 名或当前运行器已知的 agent 身份。
- 无法从运行器上下文、命令名或已知 provider 可靠识别时,向用户确认一次 agent 短名;不要猜测。
- 目录名统一转成小写短名,只使用 ASCII 字母、数字、短横线、下划线或点。
- 命名格式:
- `.tsl``tsl-001.tsl`
- `.tsf``tsl-037/<TopLevelName>.tsf`
- `.tsf` 调用验证脚本:`tsl-037/verify.tsl`
- 修复轮次按 case 计数:`repair-1/tsl-001.tsl``repair-1/tsl-037/<TopLevelName>.tsf`
- 示例:
- `result/claude/20260610/tsl-001.tsl`
- `result/claude/20260610/tsl-037/<TopLevelName>.tsf`
- `result/claude/20260610/tsl-037/verify.tsl`
被测 agent 禁止读取、请求、搜索或引用 `result/`
## 目录结构
```text
test/agent/
├── README.md
├── prompts_zh.md
└── result/
└── claude/
└── 20260610/
├── tsl-001.tsl
├── tsl-037/
│ ├── <TopLevelName>.tsf
│ └── verify.tsl
└── repair-1/
├── tsl-001.tsl
└── tsl-037/
├── <TopLevelName>.tsf
└── verify.tsl
```
## 标准测试流程
### 1. 准备测试
阅读本 README 后,测试执行方必须在开始执行用例前询问并记录本机 `tsl`
可执行文件路径。该路径不写入配置文件,只在本轮运行时验证中使用。
`TSL_HOME` 记为 `tsl` 可执行文件所在目录;如果用户提供的是 TSL 根目录,
则以用户提供的目录作为 `TSL_HOME`
```text
TSL executable path: /path/to/tsl
```
优先自动识别被测 agent 名称并记为 `AGENT``AGENT` 不是普通配置项;如果无法从
当前运行器上下文、命令名或已知 provider 可靠识别,则在开始执行用例前向用户确认一次
agent 短名;不要猜测。
```bash
AGENT="<auto-detected-agent>"
DATE=$(date +%Y%m%d)
mkdir -p "test/agent/result/$AGENT/$DATE"
```
### 2. 逐题生成文件
阅读 `prompts_zh.md`,然后按 case 逐题执行测试。
对于每个测试用例:
1. 为当前 case 启动全新的被测 agent 会话。测试执行方可以保持同一个会话,
但被测 agent 不能在同一个会话里连续回答多个 case。
2. 只发送 `prompts_zh.md` 中的 `Runner Setup``Answer Rules` 加单个题面。
3. 记录 agent 生成的第一次完整代码并立即落盘。
4. 落盘后冻结该 case 的第一轮结果文件;第一轮不允许 agent 根据运行结果或评分反馈修订答案。
5. 被测 agent 不得主动读取、请求、搜索或引用任何评分材料。
Codex 作为被测 agent 时:
- CLI 自动化:每个 case 单独执行一次 `codex exec`,不要使用 `codex exec resume`
- CLI 自动化如果不想保留会话记录,可使用 `codex exec --ephemeral`
- Codex App 或 IDE:每个 case 新建一个 thread,不要在同一个 thread 里继续下一个 case。
- Codex SDK/MCP:每个 case 调用一次新的 thread/start;不要用同一个 thread 连续 run 多个 case。
任何被测 agent 都一样:第一轮运行时验证输出不能回传给被测 agent。第一轮发现错误时,
只记录 `fail`,不得修复当前文件,不得让被测 agent 重新回答,不得把修改后的文件计入
第一轮成绩。测试执行方自己的自动修复/纠错行为在第一轮也必须关闭。如果工具在看到
第一轮运行错误后自动修改结果文件,必须按首次落盘文件评分;无法恢复首次落盘文件时,
该 case 标记为 `invalid`。第二轮修复阶段是例外,见“自动修复轮次”。
评分材料包括:
- `result/`
- 运行时验证日志
- 其他 agent 的输出文件
- 对比报告或人工备注
若被测 agent 主动接触评分材料,该 case 或本轮测试应标记为 `invalid`
不计入有效成绩。
推荐的运行器设置:
```text
You are in this repository. When asked to write TSL, write valid TSL/TSF; do not
infer syntax from Pascal, TypeScript, JavaScript, Python, or SQL.
```
文件落盘规则:
- 默认每个 case 保存为 `.tsl`,例如 `test/agent/result/<agent>/<YYYYMMDD>/tsl-001.tsl`
- 只有题面明确要求输出 `.tsf` 时,才保存为 `.tsf`,并放入对应 case 目录,
例如 `test/agent/result/<agent>/<YYYYMMDD>/tsl-037/<TopLevelName>.tsf`
- `.tsf` 文件名不能使用 case 编号;文件基名必须与第一个顶层声明同名。
测试执行方应按被测 agent 代码中的第一个顶层声明命名文件,不为了凑文件名改写代码。
- 任何 `unit ... end.` 文件只能保存为 `.tsf`,不能保存为 `.tsl`
- 如果 agent 输出 markdown code fence,测试执行方只提取代码内容保存到文件,不保存回答 markdown。
### 3. 运行时验证
运行时验证是标准测试流程的一部分。保存 `.tsl``.tsf` 文件后,由测试执行方
逐个验证。第一轮验证必须在被测 agent 会话之外执行,不要把 stdout/stderr 或错误日志
发送回被测 agent;即使第一轮发现错误,也只记录评分,不修复文件。第二轮自动修复阶段
按“自动修复轮次”规则处理。
`.tsl` 验证规则:
```bash
cd test/agent/result/<agent>/<YYYYMMDD>
tsl tsl-001.tsl
tsl tsl-002.tsl
```
`.tsf` 验证规则:
1. 将 case 目录中的 `.tsf` 文件复制到 `TSL_HOME/funcext/tmp/`
2. 在同一个 case 目录中编写 `verify.tsl` 调用脚本。
调用脚本应按被测 agent 实际生成的函数名、unit 名或类型名来调用。
验证脚本必须实际调用 `.tsf` 暴露的功能,不能只输出成功标记。
3. 调用脚本在所有验证调用成功后,用 TSL 输出语句最后输出一行 `__TSL__AGENT__OK__`
4. 执行 `tsl verify.tsl`
5. 验证完成后删除 `TSL_HOME/funcext/tmp/`
```bash
cd test/agent/result/<agent>/<YYYYMMDD>/tsl-037
mkdir -p "$TSL_HOME/funcext/tmp"
cp ./*.tsf "$TSL_HOME/funcext/tmp/"
tsl verify.tsl
rm -rf "$TSL_HOME/funcext/tmp"
```
`funcext/tmp` 由本流程独占。开始验证前不要在该目录保留人工文件;结束后必须清理。
### 4. 第一轮评分规则
- `pass`:按标准流程执行,最后一个非空输出行是 `__TSL__AGENT__OK__`
- `fail`:超时、缺少文件、文件类型错误、`.tsf` 调用脚本失败,
`.tsf` 文件名与第一个顶层声明不一致,`.tsf` 无法被 TSL 加载/编译,
或最后一个非空输出行不是 `__TSL__AGENT__OK__`
- `invalid`:被测 agent 主动读取、请求、搜索或引用评分材料。
TSL 可能在打印错误后仍返回退出码 0,因此不能只看 `$LASTEXITCODE`
运行时评分以成功标记为准:脚本没有正常执行到最后,就不会输出最后的
`__TSL__AGENT__OK__`
最终成绩只统计有效 case
```text
pass_rate = pass / (pass + fail)
```
不由被测 agent 自评。
### 5. 自动修复轮次
本轮计划测试的所有 case 完成第一轮评分后,可以对 `fail` 的 case 启动第二轮自动修复。
修复规则:
1. 第一轮期间禁止修复;只有进入第二轮后,第一轮已经记录为 `fail` 的 case 才可以修复。
2. 修复阶段可以把当前文件内容和运行时验证输出发给被测 agent。
3. 每次 agent 产出修复版本,都保存到新的修复目录,不覆盖第一轮文件。
4. 第一次修复保存到 `repair-1/`,第二次修复保存到 `repair-2/`,依此类推。
5. `repair_count` 按 case/file 单独记录;修复 `tsl-019.tsl` 只增加 `tsl-019` 的次数,
不影响其他失败 case。
6. 修复版本仍按同样的运行时验证规则执行,最后一个非空输出行必须是 `__TSL__AGENT__OK__`
示例:
```text
test/agent/result/opencode/20260623/
├── tsl-019.tsl # 第一轮原始输出
├── repair-1/
│ └── tsl-019.tsl # 第 1 次修复
└── repair-2/
└── tsl-019.tsl # 第 2 次修复
```
修复统计:
```text
case_id = tsl-019
first_pass = 第一轮是否 pass
repair_pass = 修复后是否 pass
repair_count = 该 case 的修复版本数量
```
如果第一轮已经 `pass``repair_count = 0`。如果多次修复仍失败,
该 case 的 `repair_count` 记录实际修复次数,`repair_pass = false`
### 6. 对比分析
不同 agent 的输出直接按目录对比:
```bash
diff -r test/agent/result/claude/20260617 \
test/agent/result/gpt4/20260617
```
建议人工记录汇总表,但不要把汇总表放入被测 agent 可读上下文。
```markdown
| Agent | 测试日期 | 有效用例 | Pass | Fail | Invalid | Pass rate |
|-------|---------|---------:|-----:|-----:|--------:|----------:|
| Claude | 2026-06-17 | 100 | 96 | 4 | 0 | 96% |
| GPT-4 | 2026-06-17 | 100 | 88 | 12 | 0 | 88% |
```
## 版本控制策略
提交到 git
```text
test/agent/README.md
test/agent/prompts_zh.md
```
不提交:
```text
test/agent/result/
```
## 测试用例覆盖
`prompts_zh.md` 包含 100 个测试用例,覆盖:
| 语法点 | 测试用例数 |
| ----------- | ---------- |
| 基础脚本 | 10+ |
| 控制流 | 10+ |
| 函数调用 | 10+ |
| `.tsf`/Unit | 4+ |
| 集合与查询 | 10+ |
| 表达式 | 10+ |
| 类与对象 | 15+ |
| 运行时特性 | 10+ |
| 高级边界 | 10+ |
## 最佳实践
- 测试用例应该对所有 agent 都适用。
- 不要添加特定 agent 的专属测试。
- 关注 TSL 语法本身,而非 agent 特性。
- 每轮测试使用独立日期目录。
- 保留第一次输出作为基线,不根据运行反馈让同一个 agent 修订答案。
- 定期对比不同 agent 的通过率和常见运行错误。
**维护者**TSL Team
**支持的 Agent**:所有支持 TSL 的 agent
-879
View File
@@ -1,879 +0,0 @@
# TSL Agent Prompt Set (ZH)
本文件只包含发送给被测 agent 的题面。
## Runner Setup
```text
你正在编写 TSL 代码。
不要从 Pascal、TypeScript、JavaScript、Python 或 SQL 推断 TSL 语法或函数。
```
## Answer Rules
```text
每个 case 独立作答。
除非题面明确要求输出 .tsf,否则默认输出可执行 .tsl 文件内容。
题面模拟普通用户的需求,不要复制文档中的样例代码。
请按题面里的名称、数据和业务动作重新编写。
如果输出 .tsl,请在题面要求的业务输出全部完成后,用 TSL 输出语句最后输出一行 __TSL__AGENT__OK__
如果输出 .tsf,必须是 TSL 能从 funcext 加载的有效函数扩展或 unit 文件;
不要在 .tsf 中加入成功标记,成功标记由验证脚本输出。
只输出代码,不要解释。
```
## Prompts
### TSL-001: 订单折后金额
```text
请写一份 .tsl 代码:
一个订单里,单价是 19,数量是 6,优惠金额是 14。
请算出这行订单最后要收多少钱,并输出结果。
```
### TSL-002: 分页信息
```text
请写一份 .tsl 代码:
一共有 47 条记录,每页放 10 条。
请输出能装满的页数,以及最后一页还剩多少条。
```
### TSL-003: 订单编号
```text
请写一份 .tsl 代码:
把文字 "Order" 和编号 "42" 合成一个订单编号,然后输出它。
```
### TSL-004: 简单扣费
```text
请写一份 .tsl 代码:
账户余额是 12,本次扣费是 3。
请输出扣费后的余额。不要提前列出额外的变量清单。
```
### TSL-005: 固定单价
```text
请写一份 .tsl 代码:
某个商品固定单价是 9,本次买了 4 个。
请把固定单价作为常量保存,再输出总价。
```
### TSL-006: 严格模式库存
```text
请写一份 .tsl 代码:
这份脚本要开启显式变量模式。
请准备一个库存数 stock,把它设为 18,然后输出它。
```
### TSL-007: 读取分数
```text
请写一份 .tsl 代码:
有一组分数:15、30、45。
请输出第一项和最后一项。
```
### TSL-008: 读取编码字符
```text
请写一份 .tsl 代码:
编码是 "ZX9"。
请输出第一个字符和第三个字符。
```
### TSL-009: 空列表检查
```text
请写一份 .tsl 代码:
准备一个空列表 bucket。
请检查 bucket 的第一个位置是不是 nil,并输出检查结果。
```
### TSL-010: 文本数字参与计算
```text
请写一份 .tsl 代码:
有一个文本形式的数字 "58"。
请先把它变成整数,再减去 8,并输出结果。
```
### TSL-011: 温度标签
```text
请写一份 .tsl 代码:
temperature 是 32。
如果温度大于等于 30level 就是 "hot";否则 level 是 "normal"。
请最后输出 level。
```
### TSL-012: 余额审核
```text
请写一份 .tsl 代码:
balance 是 80。
如果 balance 大于 50,请先输出 "allow",再扣掉 10
否则输出 "deny"。最后输出 balance。
```
### TSL-013: 偶数累加
```text
请写一份 .tsl 代码:
请把 2、4、6、8 加起来并输出。
要求用带步长的循环完成。
```
### TSL-014: 权重检查
```text
请写一份 .tsl 代码:
weights 里有 3、5、9。
请逐个处理,每行输出“当前位置”和“这个位置的值”的乘积。
```
### TSL-015: 累加到上限
```text
请写一份 .tsl 代码:
从 n = 1 开始累加。
当 n 大于 4 时停止,最后输出累计结果。
```
### TSL-016: 跳过指定数字
```text
请写一份 .tsl 代码:
处理 1 到 5。
遇到 3 时跳过它,只累加其他数字,最后输出结果。
```
### TSL-017: 倒计时
```text
请写一份 .tsl 代码:
从 countdown = 4 开始倒数,一直到 0 停止。
请使用 repeat until,并输出最后的 countdown。
```
### TSL-018: 状态码
```text
请写一份 .tsl 代码:
status_code 是 2。
如果是 1,输出 "new";如果是 2 或 3,输出 "active"
其他情况输出 "closed"。
```
### TSL-019: 等级文字
```text
请写一份 .tsl 代码:
grade 是 3。
请用 case 表达式得到一段等级文字:1 是 "low"2 到 4 是 "mid",其他是 "high"。
最后输出这段等级文字。
```
### TSL-020: 捕获错误信息
```text
请写一份 .tsl 代码:
故意抛出 "network" 这个错误。
捕获它后,请输出错误对象里的错误信息。
```
### TSL-021: 清理动作
```text
请写一份 .tsl 代码:
先输出 "open"。
无论前面是否正常结束,最后都要输出 "close"。
请用 try/finally 完成。
```
### TSL-022: 计算数量乘积
```text
请写一份 .tsl 代码:
请做一个“计算两个整数乘积”的功能,名字由你自己取。
脚本里请用 6 和 8 调用它,并输出结果。
```
### TSL-023: 调整额度
```text
请写一份 .tsl 代码:
请做一个“把额度增加 5”的动作,名字由你自己取。
脚本里 quota 一开始是 10,调用后输出 quota。
```
### TSL-024: 默认手续费
```text
请写一份 .tsl 代码:
请做一个“计算手续费后金额”的功能,名字由你自己取。
如果没有传金额,就按 100 处理;最后结果是金额加 6。
请分别输出不传金额和传 20 时的结果。
```
### TSL-025: 下一批编号
```text
请写一份 .tsl 代码:
请做一个“取得下一批编号”的功能,名字由你自己取。
起始编号是整数,默认是 10,结果也是整数。
它要返回起始编号加 1。请输出默认情况下的结果。
```
### TSL-026: 区间组装
```text
请写一份 .tsl 代码:
请做一个“组装左右边界”的功能,名字由你自己取,把两个边界值组成一个两项列表。
调用时请用命名方式传入:right 是 90,left 是 40。
最后输出这两项。
```
### TSL-027: 跳过中间值
```text
请写一份 .tsl 代码:
请做一个“检查中间位置”的功能,名字由你自己取,里面要输出第二个位置 b 是不是 nil。
调用时只给第一个和第三个位置传值,故意跳过 b。
```
### TSL-028: 按名字调用功能
```text
请写一份 .tsl 代码:
请做一个“三位数字编码”的功能,名字由你自己取。
它把三个输入算成 a * 100 + b * 10 + c。
脚本里请通过 call 按这个名字调用它,并输出结果。
```
### TSL-029: 多个数字相乘
```text
请写一份 .tsl 代码:
请做一个“多个数字相乘”的功能,名字由你自己取。
它可以接收任意多个数字,并把它们全部相乘。
请用 2、3、4 调用,并输出结果。
```
### TSL-030: 看到多少输入
```text
请写一份 .tsl 代码:
请做一个“统计收到多少输入”的功能,名字由你自己取。
前两个位置是固定的,后面还能继续接收更多值。
它要输出 ParamCount * 100 + RealParamCount 的结果。
请用 5、6、7 调用。
```
### TSL-031: 保存一个小计算
```text
请写一份 .tsl 代码:
把一个“小计算”保存到你自己命名的变量里。这个小计算接收两个值,结果是两者相乘。
请通过 call 调用这个变量,并用 4 和 9 输出结果。
```
### TSL-032: 把计算交给别人执行
```text
请写一份 .tsl 代码:
请做一个“执行后再翻倍”的功能,名字由你自己取,它接收另一个计算。
这个功能要先用 3 调用收到的计算,再把得到的结果乘以 2。
调用它时,直接给它一个“把输入加 5”的匿名计算,并输出结果。
```
### TSL-033: 查找已有功能
```text
请写一份 .tsl 代码:
请做一个“把输入乘以 3”的功能,名字由你自己取。
脚本里找到这个名字对应的函数值,再用支持的函数值调用方式处理 7,并输出结果。
```
### TSL-034: 传递当前功能
```text
请写一份 .tsl 代码:
请做一个“把输入平方”的功能,名字由你自己取。
再做一个“调用传入计算”的功能,名字也由你自己取,它接收一个计算和一个值,并通过 call 执行。
脚本里取得平方功能的函数值,并输出调用结果。
```
### TSL-035: 函数里使用规则包
```text
请写一份 .tsl 代码:
请做一个“读取远程费率”的功能,名字由你自己取。
这个功能一进入函数体就要使用一个费率相关的 unit,然后返回该 unit 中读取费率动作的结果。
```
### TSL-036: 脚本开头使用规则包
```text
请写一份 .tsl 代码:
脚本需要先使用两个你自己命名的规则包:一个处理价格,一个处理税费。
之后调用一个“计算最终金额”的动作,并输出结果。
```
### TSL-037: 手续费扩展
```text
请写一份 .tsf 代码:
提供一个“计算服务费”的功能,名字由你自己取,接收金额。
金额大于 1000 时,费用是 amount div 20;否则费用是 30。
不要写可直接运行的脚本入口。
```
### TSL-038: 发票规则包
```text
请写一份 .tsf 代码:
请做一个“发票规则”unit,unit 名由你自己取。
外部只需要能调用一个“默认额度”功能,并得到 500。
请写成完整的 unit 文件。
```
### TSL-039: 运费规则包
```text
请写一份 .tsf 代码:
请做一个“运费规则”unit,unit 名由你自己取。
外部只能调用一个“计算可计费重量”的功能。
内部可以有一个不对外公开的小工具,用来把负数修正为 0。
可计费重量要返回修正后的毛重减去皮重。
```
### TSL-040: 会员积分规则包
```text
请写一份 .tsf 代码:
请做一个“会员积分规则”unit,unit 名由你自己取。
外部可以调用“基础分”和“最终积分”两个功能。
level 大于等于 3 时基础分是 20,否则是 5。
最终积分的结果是金额 div 10 加上基础分。
```
### TSL-041: 商品信息表
```text
请写一份 .tsl 代码:
准备一条商品信息,里面有 sku = "B17"qty = 12。
请输出 sku 和 qty。
```
### TSL-042: 只取第一个成绩
```text
请写一份 .tsl 代码:
有一组成绩 88、99、100。
请只取第一个值放到 first_mark,然后输出 first_mark。
```
### TSL-043: 读取两行数据
```text
请写一份 .tsl 代码:
有两行数据:(8, 1) 和 (9, 2)。
请把两行分别取出来,再输出每行的两个值。
```
### TSL-044: 检查是否存在
```text
请写一份 .tsl 代码:
先检查 5 是否在 2、5、8 这一组数字里,并输出结果。
再检查整行 (2, 5) 是否在两行数据 (1, 1)、(2, 5) 里面,并输出结果。
```
### TSL-045: 两组行数据对比
```text
请写一份 .tsl 代码:
left_rows 是 (1, "a")、(2, "b")。
right_rows 是 (2, "b")、(3, "c")。
请分别算出合并、交集、左边独有、两边不重叠的结果,并输出每个结果的大小。
```
### TSL-046: 只保留允许状态
```text
请写一份 .tsl 代码:
有一批订单,每行包含订单号和状态。
再准备一份允许状态列表。
请只保留状态命中的订单,并输出保留下来的行数。
```
### TSL-047: 排除禁止状态
```text
请写一份 .tsl 代码:
有一批任务,每行包含任务号和状态。
再准备一份禁止状态列表。
请排除命中的任务,并输出剩余行数。
```
### TSL-048: 查询商品数量
```text
请写一份 .tsl 代码:
有一批商品数据,每行包含商品编号和数量。
请用 TS-SQL 只取数量大于 3 的行,并输出结果行数。
```
### TSL-049: 筛选并排序成绩
```text
请写一份 .tsl 代码:
有一批姓名和分数。
请用 TS-SQL 只保留分数大于等于 60 的人,并按分数从高到低排序。
最后输出第一行的姓名。
```
### TSL-050: 部门金额汇总
```text
请写一份 .tsl 代码:
有一批部门和金额数据。
请用 TS-SQL 按部门汇总金额,并输出汇总后的行数。
```
### TSL-051: 客户订单关联
```text
请写一份 .tsl 代码:
有一组客户数据和一组订单数据,它们通过客户编号关联。
请用 TS-SQL 得到客户名称和订单金额,并输出关联后的行数。
```
### TSL-052: 多种数字写法
```text
请写一份 .tsl 代码:
有几个配置值:0x2A、0b1010、0o12、200L、6E2。
请分别输出它们对应的类型判断结果。
```
### TSL-053: 日期和时间
```text
请写一份 .tsl 代码:
有一个日期 20240618T,还有一个带时间的值 20240618.1530T。
请分别输出日期字符串和时间字符串。
```
### TSL-054: 数字真假判断
```text
请写一份 .tsl 代码:
先输出 true 和 false。
再分别判断 4、0、-2 在 if 里会走哪个分支,并输出分支标记。
```
### TSL-055: Nil 参与计算
```text
请写一份 .tsl 代码:
请输出 ifNil(nil) 的结果。
再输出 nil + 5、5 + nil,并检查 nil + nil 是不是 nil。
```
### TSL-056: 复数信息
```text
请写一份 .tsl 代码:
准备两个复数:8 + 6j 和 complex(3, -4)。
请输出第一个复数的实部、虚部,并输出第二个值是不是复数。
```
### TSL-057: 批次报告
```text
请写一份 .tsl 代码:
一批货有 17 件,每箱装 5 件;优先级是 3;距离是 9,基准是 4;ratio_base 是 7。
请输出完整箱数、剩余件数、优先级的四次方、距离与基准的按位关系结果,
以及 ratio_base 与 3 的左除计算结果。
```
### TSL-058: 补货数量
```text
请写一份 .tsl 代码:
最低库存是 20,当前库存是 14。
如果库存不足,reorder_count 就是最低库存减当前库存;否则是 0。
请用条件表达式得到 reorder_count,并输出它。
```
### TSL-059: 候补分数
```text
请写一份 .tsl 代码:
primary_score 是 12missing_score 是 0,候补值是 99。
请用省略真值的条件表达式分别得到两个最终分数,并输出。
```
### TSL-060: 发货判断
```text
请写一份 .tsl 代码:
paid 是 trueblocked 是 falseamount 是 120。
请用 and、or、not 判断是否允许发货,并输出结果。
```
### TSL-061: 权限掩码
```text
请写一份 .tsl 代码:
read_write 是 6audit_exec 是 3。
请输出共同权限、合并权限、差异权限,以及 read_write 取反后的结果。
```
### TSL-062: 计数调整
```text
请写一份 .tsl 代码:
counter 一开始是 5。
先加 4,再自增一次,最后输出 counter。
```
### TSL-063: 安全读取订单金额
```text
请写一份 .tsl 代码:
maybe_order 是 nil。
请安全读取 maybe_order.total,并输出读取结果是不是 nil。
```
### TSL-064: 阈值范围
```text
请写一份 .tsl 代码:
请判断 3、8、20 是否满足从小到大的连续比较,并输出结果。
```
### TSL-065: 三组指标逐项比较
```text
请写一份 .tsl 代码:
有三组指标:2、4、6;3、5、7;4、6、8。
请逐项判断第一组是否小于第二组、第二组是否小于第三组,
并输出得到的三个结果。
```
### TSL-066: 发票号格式
```text
请写一份 .tsl 代码:
发票号是 "INV-2024-07"。
请判断它是不是以 "INV-" 开头,并且后面包含数字,然后输出结果。
```
### TSL-067: 带注释的小脚本
```text
请写一份 .tsl 代码:
请包含两种 TSL 支持的注释写法。
然后把 daily_total 设为 31,并输出它。
```
### TSL-068: 编译时模式选择
```text
请写一份 .tsl 代码:
如果定义了 FAST_MODE,就输出 "fast";否则输出 "safe"。
请用条件编译完成。
```
### TSL-069: 临时上下文
```text
请写一份 .tsl 代码:
请使用运行时 with 单星块。
在临时上下文里设置一个字段,并在块内输出这个字段。
```
### TSL-070: 嵌套上下文
```text
请写一份 .tsl 代码:
请使用运行时 with 双星块。
在嵌套上下文里读取外层字段,并输出组合结果。
```
### TSL-071: 带超时的远程式调用
```text
请写一份 .tsl 代码:
准备一个“慢速评分”的功能,名字由你自己取,它返回 12。
请用网格调用方式调用它,并设置超时时间,然后输出得到的结果。
```
### TSL-072: 全局缓存
```text
请写一份 .tsl 代码:
把 "session_limit" 这个全局缓存设为 300。
再读取它并输出,同时输出这个缓存是否存在。
```
### TSL-073: 调试结果
```text
请写一份 .tsl 代码:
请做一个“检查金额”的功能,名字由你自己取。
如果输入小于 0,请用 debugReturn 返回调试值;否则返回原值。
请用 -1 调用它并输出结果。
```
### TSL-074: 简单计时
```text
请写一份 .tsl 代码:
请用内置计时开始和结束功能包住一个小循环,并输出计时结果。
```
### TSL-075: 兜底跳转
```text
请写一份 .tsl 代码:
flag 是 false。
如果 flag 为 false,就跳到 fallback 标签。
fallback 里输出 "fallback"。
```
### TSL-076: 账户卡片
```text
请写一份 .tsl 代码:
我需要一种“账户卡片”对象,类型名由你自己取。
它要有一个公开字段,用来保存持有人姓名,字段名也由你自己取。
脚本里创建一张卡,把持有人设为 "Lina",然后输出这个字段。
```
### TSL-077: 仪表盒
```text
请写一份 .tsl 代码:
我需要一种“仪表盒”对象,类型名由你自己取。
创建它时传入初始读数,保存到 reading。
它有一个“前进一步”的动作,动作名由你自己取,每次让读数增加 2,并给出新的读数。
脚本里创建一个初始读数为 10 的对象,输出调用这个动作后的结果。
```
### TSL-078: 设备总数
```text
请写一份 .tsl 代码:
我需要一种“设备计数器”类型,类型名由你自己取,它有一个所有对象共享的 total_count。
脚本里把 total_count 设为 77,然后输出它。
```
### TSL-079: 汇率工具
```text
请写一份 .tsl 代码:
我需要一种“汇率计算”类型,类型名由你自己取,上面直接提供一个“倍率相乘”的功能。
它返回 a * b。
脚本里通过类型本身调用这个功能处理 4 和 5,并输出结果。
```
### TSL-080: 货架编码
```text
请写一份 .tsl 代码:
我需要一种“货架盒”对象,类型名由你自己取。
它内部有一个保存货架编码的字段,字段名由你自己取。
对外通过一个属性读写这个编码,属性名也由你自己取。
脚本里创建对象,把编码设为 "S-8",然后通过属性输出编码。
```
### TSL-081: 客户显示名
```text
请写一份 .tsl 代码:
我需要一种“客户名称”对象,类型名由你自己取。
它内部有一个 string 类型字段,用来保存原始姓名,字段名由你自己取。
对外通过一个带类型的属性访问显示姓名,属性名也由你自己取。
脚本里设置并输出这个显示姓名属性。
```
### TSL-082: 两种渲染方式
```text
请写一份 .tsl 代码:
我需要一种“格式化工具”对象,类型名由你自己取。
它有两个同名动作,动作名由你自己取:一个接收一个值,一个接收两个值。
脚本里分别调用这两种动作,并输出结果。
```
### TSL-083: 自行车对象
```text
请写一份 .tsl 代码:
我需要一种“交通工具基础对象”,类型名由你自己取。
它有一个说明类别的动作,动作名由你自己取,结果是 "vehicle"。
再做一种“自行车对象”,类型名也由你自己取,继承前面的基础对象,
并增加一个说明轮子数量的动作,动作名由你自己取,结果是 2。
脚本里创建自行车对象,输出类别和轮子数量。
```
### TSL-084: 折扣价格标签
```text
请写一份 .tsl 代码:
我需要一种“价格基础对象”,类型名由你自己取。
它有一个可被子类型改写的标签动作,动作名由你自己取,默认结果是 "base"。
再做一种“折扣价格对象”,类型名也由你自己取,把这个标签动作的结果改成 "discount"。
脚本里创建折扣价格对象并输出标签。
```
### TSL-085: 追加消息
```text
请写一份 .tsl 代码:
我需要一种“基础消息对象”,类型名由你自己取。
它有一个给出文字的动作,动作名由你自己取,结果是 "base"。
再做一种“提醒消息对象”,类型名也由你自己取。
它的文字动作要先调用父级同名动作,再追加 "-alert"。
脚本里输出提醒消息对象的文字。
```
### TSL-086: 类外补充方法
```text
请写一份 .tsl 代码:
我需要一种“票据打印器”,类型名由你自己取。
类型里面只先写出一个“打印编号”动作的名字,动作名由你自己取。
请在类型外面补上这个动作的内容,让它给出 "T-100"。
脚本里创建对象并输出结果。
```
### TSL-087: 按名称创建对象
```text
请写一份 .tsl 代码:
我需要一种“动态盒子”对象,类型名由你自己取。
它有一个给出数值的动作,动作名由你自己取,结果是 64。
脚本里请通过字符串里的类型名创建这个对象,并输出这个数值。
```
### TSL-088: 临时句柄清理
```text
请写一份 .tsl 代码:
我需要一种“临时句柄”对象,类型名由你自己取。
它被销毁时要输出 "released"。
脚本里创建它,然后把引用设为 nil。
```
### TSL-089: 外部评分函数
```text
请写一份 .tsl 代码:
有一个外部提供的“评分”功能,TSL 中使用的名字由你自己取,它接收一个值并给出结果。
这里只需要写出它和外部功能的连接写法,不需要调用。
```
### TSL-090: 外部日志动作
```text
请写一份 .tsl 代码:
有一个外部提供的“写日志”动作,TSL 中使用的名字由你自己取,它接收一段文本。
这里只需要写出它和外部动作的连接写法,不需要调用。
```
### TSL-091: 包装原生函数指针
```text
请写一份 .tsl 代码:
假设 native_ptr 已经保存了一个原生函数指针。
请按 TSL 支持的方式把它包装成可调用对象,并演示传入两个数字调用。
```
### TSL-092: 后台任务
```text
请写一份 .tsl 代码:
准备一个后台任务,名字由你自己取,里面输出 "work"。
请用线程相关写法启动它,并保持代码尽量小。
```
### TSL-093: FMArray 尺寸
```text
请写一份 .tsl 代码:
创建一个 2 行 3 列的 FMArray。
请输出它是不是 FMArray,并输出行数、列数和总尺寸。
```
### TSL-094: 矩阵大小
```text
请写一份 .tsl 代码:
准备一个两行三列的数字矩阵。
请输出它的行数、列数和总尺寸。
```
### TSL-095: 金额对象相加
```text
请写一份 .tsl 代码:
我需要一种“金额盒子”对象,类型名由你自己取,里面有 value。
请让两个这种对象可以直接相加,相加后得到新的同类对象。
脚本里创建两个对象,并输出相加后的 value。
```
### TSL-096: 区分局部和全局功能
```text
请写一份 .tsl 代码:
写一个局部功能,名字故意和某个系统功能一样。
脚本里请分别调用局部功能和全局/系统功能,并输出两次结果。
```
### TSL-097: 关闭默认写回
```text
请写一份 .tsl 代码:
关闭默认的输入写回行为。
请做一个“双值触碰”动作,名字由你自己取:普通输入不要写回,明确标记为 var 的输入要写回。
脚本调用后输出两个变量,证明只有 var 那个改变了。
```
### TSL-098: In 和 Out
```text
请写一份 .tsl 代码:
关闭默认的输入写回行为。
请做一个“同步几个值”的动作,名字由你自己取,接收三个值,并只让 out 标记的值写回。
脚本调用时使用 in 和 out 前缀,然后输出三个变量。
```
### TSL-099: 提前退出
```text
请写一份 .tsl 代码:
请做一个“保护性计算”的功能,名字由你自己取。
如果输入小于 0,就直接退出;否则给出输入乘以 2 的结果。
脚本里分别用 -1 和 6 调用它,并输出两次结果。
```
### TSL-100: 订单汇总
```text
请写一份 .tsl 代码:
有一批订单行,每行包含商品名、数量、单价。
请做一个“计算单行金额”的功能,名字由你自己取。
遍历所有订单行,数量为 0 的行跳过,其他行累计金额。
如果总金额大于 100,输出 "bulk",否则输出 "normal"。
最后输出总金额。
```
-29
View File
@@ -8,7 +8,6 @@ delegate rather than re-derive rules from that document.
import ast
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -18,11 +17,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = ROOT / "skills" / "commit-message"
VALIDATOR = SKILL_ROOT / "scripts" / "validate_commit_message.py"
POLICY = SKILL_ROOT / "references" / "commit_policy.json"
CI_ENTRY = ROOT / ".gitea" / "ci" / "commit_message_lint.py"
SPEC = ROOT / "docs" / "common" / "commit_message.md"
TABLE_ROW_RE = re.compile(r"^\|(?P<type>[^|]*)\|(?P<emoji>[^|]*)\|")
def run(script, *args, env=None):
@@ -37,31 +32,7 @@ def run(script, *args, env=None):
)
def spec_type_emoji_mapping():
mapping = {}
for line in SPEC.read_text(encoding="utf-8").splitlines():
match = TABLE_ROW_RE.match(line.strip())
if not match:
continue
type_cell = re.search(r"`([a-z][a-z0-9-]*)`", match.group("type"))
emoji_cell = re.search(r"`(:[a-z0-9_+-]+:)`", match.group("emoji"))
if type_cell and emoji_cell:
mapping[type_cell.group(1)] = emoji_cell.group(1)
return mapping
class CommitPolicySingleOwnerTests(unittest.TestCase):
def test_policy_and_spec_table_agree(self):
policy_types = json.loads(POLICY.read_text(encoding="utf-8"))["types"]
spec_types = spec_type_emoji_mapping()
self.assertTrue(spec_types, f"no type/emoji table parsed from {SPEC}")
self.assertEqual(
policy_types,
spec_types,
"commit_policy.json and docs/common/commit_message.md disagree; "
"update both when adding or renaming a type",
)
def test_ci_entry_point_does_not_reimplement_rules(self):
source = CI_ENTRY.read_text(encoding="utf-8")
self.assertIn("validate_commit_message.py", source)
-745
View File
@@ -1,745 +0,0 @@
import argparse
import importlib.util
import re
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
TEMPLATES = ROOT / "templates"
MAIN_LOOP_ROOT = ROOT / "skills" / "cook-it-through"
MAIN_LOOP_SKILL = MAIN_LOOP_ROOT / "SKILL.md"
MAIN_LOOP_SESSION_BOUNDARY = MAIN_LOOP_ROOT / "rules" / "session-boundary.md"
MAIN_LOOP_WORKFLOWS = {
name: MAIN_LOOP_ROOT / "workflows" / f"{name}.md"
for name in (
"single-session",
"feature-planning",
"ticket-execution",
"feature-integration",
)
}
MAIN_LOOP_SCRIPTS = MAIN_LOOP_ROOT / "scripts"
MAIN_LOOP_SCRIPT = MAIN_LOOP_SCRIPTS / "main_loop.py"
_MAIN_LOOP_SPEC = importlib.util.spec_from_file_location(
"playbook_main_loop_contracts", MAIN_LOOP_SCRIPT
)
assert _MAIN_LOOP_SPEC and _MAIN_LOOP_SPEC.loader
MAIN_LOOP = importlib.util.module_from_spec(_MAIN_LOOP_SPEC)
sys.modules[_MAIN_LOOP_SPEC.name] = MAIN_LOOP
_MAIN_LOOP_SPEC.loader.exec_module(MAIN_LOOP)
def subcommand_parsers() -> dict[str, argparse.ArgumentParser]:
parser = MAIN_LOOP.build_parser()
for action in parser._subparsers._group_actions: # noqa: SLF001
if isinstance(action, argparse._SubParsersAction): # noqa: SLF001
return dict(action.choices)
raise AssertionError("main_loop.py exposes no subcommands")
def required_flags(parser: argparse.ArgumentParser) -> set[str]:
return {
option
for action in parser._actions # noqa: SLF001
if action.required
for option in action.option_strings
if option.startswith("--")
}
def option_action(
parser: argparse.ArgumentParser, option: str
) -> argparse.Action:
for action in parser._actions: # noqa: SLF001
if option in action.option_strings:
return action
raise AssertionError(f"{parser.prog} exposes no {option}")
def normalized_prose(text: str) -> str:
return " ".join(text.split())
def isolation_choices() -> set[str]:
claim = subcommand_parsers()["claim"]
for action in claim._actions: # noqa: SLF001
if "--isolation" in action.option_strings:
return set(action.choices or ())
raise AssertionError("claim exposes no --isolation choices")
def installed_skills() -> set[str]:
names: set[str] = set()
for base in (ROOT / "skills", ROOT / "skills" / "thirdparty"):
if not base.is_dir():
continue
for path in base.iterdir():
if path.is_dir() and (path / "SKILL.md").is_file():
names.add(path.name)
return names
def rules_text() -> str:
return (TEMPLATES / "AGENT_RULES.template.md").read_text(encoding="utf-8")
def main_loop_skill_text() -> str:
return MAIN_LOOP_SKILL.read_text(encoding="utf-8")
def main_loop_session_boundary_text() -> str:
return MAIN_LOOP_SESSION_BOUNDARY.read_text(encoding="utf-8")
def main_loop_workflow_text(name: str) -> str:
return MAIN_LOOP_WORKFLOWS[name].read_text(encoding="utf-8")
def main_loop_instruction_paths() -> tuple[Path, ...]:
return (
MAIN_LOOP_SKILL,
MAIN_LOOP_SESSION_BOUNDARY,
*MAIN_LOOP_WORKFLOWS.values(),
)
def main_loop_bundle_text() -> str:
return "\n".join(
path.read_text(encoding="utf-8") for path in main_loop_instruction_paths()
)
def section(text: str, heading: str, until: str) -> str:
return text.split(heading, 1)[1].split(until, 1)[0]
def headings(text: str) -> list[str]:
return re.findall(r"^#{2,3} (.+)$", text, re.MULTILINE)
# Tokens written in backticks that name something other than a skill. Machine
# state, CLI vocabulary and harness commands are derived from the implementation
# where possible so the allowlist stays small and reviewable.
NON_SKILL_BACKTICK_TOKENS = frozenset(
{
"base",
"claude",
"clear",
"codex",
"command",
"commit",
"compact",
"main",
"pass",
"pass-with-notes",
"passed",
"output",
"result",
"released",
"report",
"spec",
"standards",
}
)
LEGACY_FLOW_TERMS = (
"using-superpowers",
"brainstorming",
"writing-plans",
"executing-plans",
"docs/superpowers/",
"plan-status",
"旧开发流程目录",
"## 禁止旧流程",
"旧开发队列",
"单文件开发计划主链",
"active-context.md",
"progress.md",
"decisions.md",
)
class TemplateContractsTests(unittest.TestCase):
def test_templates_define_only_the_matt_ticket_workflow(self):
combined = "\n".join(
[
*(path.read_text(encoding="utf-8") for path in sorted(TEMPLATES.rglob("*.md"))),
main_loop_bundle_text(),
]
)
for required in (
"setup-matt-pocock-skills",
"grill-with-docs",
"to-spec",
"to-tickets",
".scratch/",
"main_loop.py claim",
"main_loop.py integrate",
):
self.assertIn(required, combined)
for legacy in LEGACY_FLOW_TERMS:
self.assertNotIn(legacy, combined)
def test_project_templates_drop_legacy_language_placeholders(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
self.assertNotIn("{{MAIN_LANGUAGE}}", templates_readme)
self.assertNotIn("{{LANGUAGE_1}}", templates_readme)
self.assertNotIn("docs/workflows/", templates_readme)
self.assertNotIn("templates/workflows/", templates_readme)
self.assertNotIn("docs/superpowers/", templates_readme)
self.assertNotIn(".scratch/<feature>/spec.md", templates_readme)
self.assertIn("`.scratch/<feature>/spec.md`", main_loop_bundle_text())
self.assertNotIn("docs/prompts/", templates_readme)
agents_template = (TEMPLATES / "AGENTS.template.md").read_text(
encoding="utf-8"
)
self.assertNotIn("{{MAIN_LANGUAGE}}", agents_template)
self.assertIn("AGENT_RULES.md", agents_template)
self.assertIn("memory-bank/project-brief.md", agents_template)
self.assertIn("memory-bank/tech-context.md", agents_template)
self.assertIn("memory-bank/system-patterns.md", agents_template)
self.assertIn(".scratch/queue.md", agents_template)
self.assertNotIn("docs/prompts/", agents_template)
tech_context_template = (
TEMPLATES / "memory-bank" / "tech-context.template.md"
).read_text(encoding="utf-8")
self.assertNotIn("{{MAIN_LANGUAGE}}", tech_context_template)
self.assertNotIn("{{LANGUAGE_1}}", tech_context_template)
self.assertNotIn("**主要语言**", tech_context_template)
self.assertIn("## 不可假设项", tech_context_template)
project_brief_template = (
TEMPLATES / "memory-bank" / "project-brief.template.md"
).read_text(encoding="utf-8")
self.assertIn("## 成功定义", project_brief_template)
def test_agents_template_nests_agents_block_inside_framework_block(self):
agents_template = (TEMPLATES / "AGENTS.template.md").read_text(
encoding="utf-8"
)
lines = [line.strip() for line in agents_template.splitlines()]
for marker in (
"<!-- playbook:framework:start -->",
"<!-- playbook:framework:end -->",
"<!-- playbook:agents:start -->",
"<!-- playbook:agents:end -->",
):
self.assertIn(marker, lines, msg=f"missing marker: {marker}")
framework_start = lines.index("<!-- playbook:framework:start -->")
framework_end = lines.index("<!-- playbook:framework:end -->")
agents_start = lines.index("<!-- playbook:agents:start -->")
agents_end = lines.index("<!-- playbook:agents:end -->")
nesting_reason = (
"sync_agents_template replaces the whole framework block; the agents "
"sub-block must stay inside it so preserve_agents_subblock can carry "
"the deployed language rules across the replacement"
)
self.assertLess(framework_start, agents_start, msg=nesting_reason)
self.assertLess(agents_start, agents_end, msg=nesting_reason)
self.assertLess(agents_end, framework_end, msg=nesting_reason)
def test_templates_readme_separates_ownership_from_deployment_config(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
classification = section(templates_readme, "## 模板分类", "## 模板说明")
categories = [h for h in headings(classification) if h[:2] in ("1.", "2.", "3.")]
self.assertEqual(len(categories), 3, msg=f"unexpected categories: {categories}")
self.assertIn("项目新增的 `memory-bank/*`", classification)
self.assertIn(
"playbook:rules:start/end",
classification,
msg="ownership of AGENT_RULES.md is split by the marker block; the "
"classification has to say which side the project owns",
)
self.assertNotIn("[sync_", classification)
self.assertNotIn("force", classification)
self.assertNotIn("no_backup", classification)
def test_templates_readme_separates_state_source_and_protocol_authority(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
normalized = normalized_prose(templates_readme)
self.assertIn("`.scratch/` 是唯一机器状态源", normalized)
self.assertIn(
"由第一方 `cook-it-through` Skill 权威定义",
normalized,
)
self.assertNotIn("preserve_agents_subblock()", templates_readme)
self.assertNotIn("四个入口按成本递增", templates_readme)
self.assertIsNone(
re.search(r"\*\*最后更新\*\*\d{4}-\d{2}-\d{2}", templates_readme),
msg="templates README must not carry a hand-maintained update date",
)
def test_templates_readme_documents_skill_exclusion_boundary(self):
templates_readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
deployment = normalized_prose(
section(templates_readme, "## 部署", "## 正式开发流程")
)
layout = normalized_prose(
section(
templates_readme,
"## `playbook.py` 部署后结构",
"## 正式流程运行后按需产生的结构",
)
)
self.assertIn("启用了 `[sync_rules]`", deployment)
self.assertIn("安装集合必须包含 `cook-it-through`", deployment)
self.assertIn('`mode = "all"` 时不得通过 `exclude` 排除', deployment)
self.assertIn(
"只有不部署官方 `AGENT_RULES.md` 且不使用正式工程主链的安装场景,才可以排除该 skill",
deployment,
)
self.assertIn("同时启用 `[sync_rules]` 时,必须遵守上文", layout)
def test_memory_bank_contains_only_stable_project_knowledge(self):
memory_templates = {
path.name for path in (TEMPLATES / "memory-bank").glob("*.template.md")
}
self.assertEqual(
memory_templates,
{
"project-brief.template.md",
"tech-context.template.md",
"system-patterns.template.md",
},
)
boundary = normalized_prose(main_loop_session_boundary_text())
planning = main_loop_workflow_text("feature-planning")
execution = main_loop_workflow_text("ticket-execution")
for path in (
"memory-bank/project-brief.md",
"memory-bank/tech-context.md",
"memory-bank/system-patterns.md",
):
self.assertIn(path, boundary)
for required in (
"已经验证且可复现",
"重新发现成本高",
"不能从代码直接看出",
"下一 session 仍需要",
"关键取舍及理由写入 `docs/adr/`",
"`handoff` 产物写入 OS 临时目录",
):
self.assertIn(required, boundary)
self.assertIn("进入 `grill-with-docs` 前", planning)
self.assertIn("领取后实现前", execution)
def test_prompt_templates_are_not_part_of_the_workflow(self):
self.assertFalse(TEMPLATES.joinpath("prompts").exists())
def test_agent_rules_routes_main_loop_work_to_the_firstparty_skill(self):
rules = rules_text()
normalized = normalized_prose(rules)
self.assertIn("{{PLAYBOOK_ROOT}}", rules)
self.assertNotIn("{{PLAYBOOK_SCRIPTS}}", rules)
self.assertEqual(
headings(rules),
["优先级", "沟通", "项目边界", "工作流入口"],
msg="always-loaded rules must remain a thin workflow bootstrap",
)
self.assertIn("必须加载 `cook-it-through`", rules)
self.assertIn(
"或读取/修改 `.scratch` 中的 queue、ticket、heartbeat、integration 状态前",
normalized,
)
self.assertIn("该 skill 独占", rules)
self.assertIn("主循环执行引擎随该 skill 安装", normalized)
self.assertIn("`.agents/index.md`", rules)
self.assertNotIn("**Blocked by:**", rules)
self.assertNotIn("## 主循环命令", rules)
self.assertLessEqual(len(rules.splitlines()), 50)
self.assertLessEqual(len(rules.encode("utf-8")), 5_000)
for legacy in LEGACY_FLOW_TERMS:
self.assertNotIn(legacy, rules)
def test_cook_it_through_skill_owns_the_ticket_contract(self):
rules = rules_text()
bundle = main_loop_bundle_text()
planning = main_loop_workflow_text("feature-planning")
readme = (TEMPLATES / "README.md").read_text(encoding="utf-8")
skills_readme = (ROOT / "skills/README.md").read_text(encoding="utf-8")
self.assertFalse((ROOT / "docs/common/main-loop-ticket-contract.md").exists())
self.assertEqual(
{
path.relative_to(MAIN_LOOP_ROOT).as_posix()
for path in MAIN_LOOP_ROOT.rglob("*")
if path.is_file() and "__pycache__" not in path.parts
},
{
"SKILL.md",
"rules/session-boundary.md",
"workflows/single-session.md",
"workflows/feature-planning.md",
"workflows/ticket-execution.md",
"workflows/feature-integration.md",
"scripts/main_loop.py",
"scripts/main_loop_scheduler.py",
},
)
self.assertFalse((ROOT / "scripts/main_loop.py").exists())
self.assertFalse((ROOT / "scripts/main_loop_scheduler.py").exists())
for required in (
"`TicketId`qualified `feature-slug/NN`",
"`FeatureIntegrationId``feature-slug@integrated`",
"**Blocked by:** None",
"**Blocked by:** feature-a/01; feature-b@integrated",
"同批重复 `--feature`",
"hard cut",
):
self.assertIn(required, planning)
self.assertIn("第三方 `to-tickets` 只定义通用 tracker 行为", planning)
self.assertIn("最终机器校验边界", planning)
self.assertIn("手工修改 ticket `Status`", bundle)
self.assertIn("<COOK_IT_THROUGH_ROOT>/scripts/main_loop.py", bundle)
self.assertNotIn("<PLAYBOOK_SCRIPTS>", bundle)
self.assertIn("`cook-it-through`", rules)
self.assertNotIn("**Blocked by:**", rules)
self.assertIn("只在第一方 `skills/cook-it-through/` 定义", readme)
self.assertNotIn("main-loop-ticket-contract.md", readme)
self.assertNotIn("**Blocked by:** feature-a/01; feature-b@integrated", readme)
for public_readme in (readme, skills_readme):
self.assertNotIn("main_loop.py", public_readme)
self.assertNotIn("--isolation", readme)
def test_cook_it_through_uses_routed_progressive_disclosure(self):
skill = main_loop_skill_text()
bundle = main_loop_bundle_text()
description = next(
line for line in skill.splitlines() if line.startswith("description:")
)
self.assertLessEqual(len(skill.splitlines()), 85)
self.assertLessEqual(len(skill.encode("utf-8")), 8_000)
self.assertLessEqual(sum(len(p.read_text().splitlines()) for p in main_loop_instruction_paths()), 330)
self.assertIn("main_loop.py <command> --help", skill)
self.assertNotIn("```bash", bundle)
self.assertNotIn("入口 1", description)
for command in subcommand_parsers():
self.assertNotIn(command, description)
for negative_boundary in ("纯 TSL 语法/API 查询", "commit message", "远端 Gitea CI"):
self.assertIn(negative_boundary, description)
for path in main_loop_instruction_paths()[1:]:
relative = path.relative_to(MAIN_LOOP_ROOT).as_posix()
other_text = "\n".join(
candidate.read_text(encoding="utf-8")
for candidate in main_loop_instruction_paths()
if candidate != path
)
self.assertIn(relative, other_text, msg=f"unrouted instruction file: {relative}")
self.assertNotIn("FILL:", bundle)
def test_cook_it_through_keeps_irrecoverable_red_lines_resident(self):
skill = normalized_prose(main_loop_skill_text())
rules = rules_text()
for required in (
"禁止手工修改 ticket `Status`",
"禁止伪造或复用证据 artifact",
"integration dependency 不可见时禁止继续",
"禁止 stash、reset 或覆盖其他 session 改动",
"远程 tracker、独立 clone 或跨机器状态",
):
self.assertIn(required, skill)
for migrated_rule in (
"main-loop:ticket-state",
"integration frontier",
"远程 tracker、独立 clone、跨机器状态",
):
self.assertNotIn(migrated_rule, rules)
def test_gitignore_template_tracks_scratch_and_ignores_only_runtime(self):
template = (TEMPLATES / "gitignore.template").read_text(encoding="utf-8")
for durable_rule in ("!/.scratch/", "!/.scratch/**"):
self.assertIn(durable_rule, template)
for runtime_rule in (
"/.scratch/*.lock",
"/.scratch/worktrees/",
"/.scratch/**/*.tmp",
):
self.assertIn(runtime_rule, template)
self.assertNotIn("是否纳入版本控制由项目决定", rules_text())
def test_cook_it_through_routes_entries_and_blast_radius_floor(self):
skill = main_loop_skill_text()
entries = section(skill, "## 任务路由", "## 常驻红线")
entry_headings = [h for h in headings(entries) if h.startswith("入口 ")]
self.assertEqual(
entry_headings,
[
"入口 1:直接执行",
"入口 2:单切片改动",
"入口 3:已明确预期行为的 bug",
"入口 4:新 feature 或设计变更",
],
)
self.assertEqual(entries.count("**升级条件**"), 2)
self.assertIn("AGENT_RULES.local.md", entries)
self.assertIn("高爆炸半径路径", entries)
self.assertIn("构建、CI 或分发配置", entries)
self.assertIn("最低入口 2", entries)
self.assertIn("入口 1 不加载按需文件", entries)
entry_two = normalized_prose(section(entries, "### 入口 2", "### 入口 3"))
entry_four = normalized_prose(entries.split("### 入口 4", 1)[1])
self.assertIn("不属于入口 3", entry_two)
self.assertIn("入口 4", entry_two)
self.assertIn("边界不清时先按入口 2 起步", entry_four)
def test_cook_it_through_routes_single_session_work(self):
workflow = main_loop_workflow_text("single-session")
for required in (
"当前 `HEAD` 为 review fixed point",
"`<fixed-point>` 作为 `code-review` 的 fixed point",
"仅运行 Standards axis",
"`diagnosing-bugs` 完整执行 Phase 1-6",
"improve-codebase-architecture",
):
self.assertIn(required, workflow)
self.assertNotIn("`<fixed-point>...HEAD`", workflow)
bug = section(workflow, "## 入口 3", "## 完成与升级")
self.assertNotIn("grill-with-docs", bug)
def test_cook_it_through_routes_feature_planning_and_onramps(self):
planning = main_loop_workflow_text("feature-planning")
for required in (
"`wayfinder`",
"`to-spec -> to-tickets`",
"`research`",
"先进入 `grill-with-docs`",
"首次运行 `setup-matt-pocock-skills`",
"seam confirmation 在 `to-spec` 与 `tdd`",
"`tdd` 不得在未经确认的 seam 上开始",
):
self.assertIn(required, planning)
self.assertNotIn("`prototype`", planning)
def test_cook_it_through_commits_planning_baseline_before_claim(self):
planning = main_loop_workflow_text("feature-planning")
ordered_steps = (
"-> to-spec",
"-> to-tickets",
"-> main_loop.py enqueue",
"-> 提交 planning baseline",
"-> main_loop.py claim",
)
positions = [planning.index(step) for step in ordered_steps]
self.assertEqual(positions, sorted(positions))
for durable_input in (
"`.scratch/<feature>/spec.md`",
"`.scratch/<feature>/issues/*.md`",
"`.scratch/queue.md`",
):
self.assertIn(durable_input, planning)
self.assertIn("任何 claim 前", planning)
self.assertIn("不隐式提交", planning)
def test_cook_it_through_defines_unattended_fallback(self):
planning = main_loop_workflow_text("feature-planning")
execution = main_loop_workflow_text("ticket-execution")
self.assertIn("尚未 claim ticket 时", planning)
self.assertIn("to-questionnaire", planning)
self.assertIn(".scratch/questions/<slug>.md", planning)
self.assertIn("从 `grill-with-docs` 恢复", normalized_prose(planning))
self.assertIn("finish --result blocked", execution)
def test_cook_it_through_binds_state_and_evidence_to_claim(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
for key in (
"FEATURE",
"TICKET",
"CONTROL_ROOT",
"STATE_ROOT",
"WORKSPACE",
"BRANCH",
"BASE",
"ISOLATION",
):
self.assertIn(f"`{key}`", execution)
self.assertIn('--state-root "<PROJECT_ROOT>/.scratch"', execution)
self.assertIn('--repo-root "<PROJECT_ROOT>"', execution)
self.assertNotIn("--repo-root .", execution)
self.assertIn("把该 `FEATURE_HEAD` 合入 ticket branch", execution)
def test_cook_it_through_defines_ticket_execution_adapter(self):
execution = main_loop_workflow_text("ticket-execution")
ordered_steps = (
"读取已领取 ticket 的 spec",
"按 `tdd`",
"提交全部实现",
"运行 `code-review` 的 Standards/Spec",
"结构化证据调用 main_loop.py finish",
)
positions = [execution.index(step) for step in ordered_steps]
self.assertEqual(positions, sorted(positions))
for required in (
"fixed point",
"`<STATE_ROOT>/<feature>/spec.md`",
"`<STATE_ROOT>/<feature>/issues/<NN>-*.md`",
"零个未解决的硬 finding",
"不给 pass/fail 判定",
"不得填写 pass",
):
self.assertIn(required, execution)
def test_cook_it_through_defines_lease_and_stuck_ticket_recovery(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
for required in (
"固定为 30 分钟",
"每 10 分钟",
"`reclaim` 只接管 stale 的 `claimed`",
"release-ticket",
"claim 环境准备失败",
"会占住该 ticket",
"原 `BASE`",
"blocked/skipped 必须给 reason",
):
self.assertIn(required, execution)
def test_cook_it_through_stops_on_integration_visibility_retry(self):
execution = main_loop_workflow_text("ticket-execution")
for key in (
"TICKET",
"DEPENDENCY",
"INTEGRATION_COMMIT",
"WORKSPACE",
"BRANCH",
"BRANCH_HEAD",
"TICKET_BRANCH",
"TICKET_BRANCH_HEAD",
"SYNC_BRANCH",
"MAIN_BRANCH",
"MAIN_HEAD",
"SYNC_COMMAND",
):
self.assertIn(f"`{key}`", execution)
self.assertIn("任一字段缺失", execution)
self.assertIn("取得正式 assignment 前不得继续", execution)
def test_cook_it_through_requires_fresh_evidence_artifacts(self):
bundle = main_loop_bundle_text()
execution = main_loop_workflow_text("ticket-execution")
integration = main_loop_workflow_text("feature-integration")
for required in (
"fresh UTF-8 JSON artifact",
"finish --help",
".scratch/<feature>/evidence/",
"无法证明命令真的执行过",
):
self.assertIn(required, execution)
for required in ("三个独立门禁,不能互相替代", "integrate --help"):
self.assertIn(required, integration)
self.assertIn("禁止伪造或复用证据 artifact", bundle)
for required in ("output_sha256", "report_sha256"):
self.assertIn(required, MAIN_LOOP.EVIDENCE_HELP)
self.assertIn("--main-verified", required_flags(subcommand_parsers()["integrate"]))
def test_cook_it_through_commits_final_state_after_integration(self):
integration = main_loop_workflow_text("feature-integration")
self.assertLess(
integration.index("main_loop.py integrate"),
integration.index("提交 final workflow state"),
)
for durable_path in (
"`.scratch/<feature>/`",
"`.scratch/queue.md`",
"`.main-loop.json`",
):
self.assertIn(durable_path, integration)
self.assertIn("不要运行 `git add .scratch`", integration)
self.assertIn("不得 amend/squash", integration)
self.assertIn("`MAIN_INTEGRATION_COMMIT`", integration)
def test_cook_it_through_orders_phase_boundary_options_and_reload(self):
boundary = normalized_prose(main_loop_session_boundary_text())
ordered_options = (
"继续当前 session",
"使用 `clear`",
"使用 `handoff`",
"交给 subagent",
"使用 `compact`",
)
positions = [boundary.index(option) for option in ordered_options]
self.assertEqual(positions, sorted(positions))
for required in (
"下一阶段需要当前 session 作为 primary source",
"约 150k tokens",
"`handoff` 解决的是可移植性",
"重新加载 `SKILL.md` 与当前路由文件",
"不得只依据 `status` 输出继续",
"`domain-modeling`",
"`codebase-design` 只作词汇来源",
):
self.assertIn(required, boundary)
def test_cook_it_through_delegates_command_semantics_to_help(self):
skill = main_loop_skill_text()
bundle = main_loop_bundle_text()
parsers = subcommand_parsers()
self.assertIn("main_loop.py <command> --help", skill)
self.assertIsNone(
re.search(r"\|\s*`?main_loop\.py (?:enqueue|status|claim|finish)", bundle),
msg="command responsibility tables duplicate argparse help",
)
for command, parser in parsers.items():
self.assertIn(f"main_loop.py {command}", bundle)
self.assertTrue(parser.description, msg=f"thin help for {command}")
for flag in required_flags(parser):
action = option_action(parser, flag)
self.assertNotIn(action.help, (None, argparse.SUPPRESS))
documented = set(re.findall(r"main_loop\.py ([a-z][a-z-]*)", bundle))
self.assertEqual(documented - set(parsers), set())
def test_cook_it_through_documents_executable_state_transitions(self):
execution = normalized_prose(main_loop_workflow_text("ticket-execution"))
finish = subcommand_parsers()["finish"]
result = option_action(finish, "--result")
self.assertEqual(
set(result.choices or ()),
{"resolved", "blocked", "released", "skipped"},
)
self.assertIn("blocked/skipped 必须给 reason", execution)
release_ticket = subcommand_parsers()["release-ticket"]
release_options = {
option
for action in release_ticket._actions # noqa: SLF001
for option in action.option_strings
}
self.assertNotIn("--repo-root", release_options)
self.assertNotIn("--owner", release_options)
def test_workflow_instructions_only_reference_installed_skills(self):
instructions = "\n".join((rules_text(), main_loop_bundle_text()))
skills = installed_skills()
machine_vocabulary = (
set(subcommand_parsers())
| set(MAIN_LOOP.ALLOWED_STATUSES)
| isolation_choices()
| NON_SKILL_BACKTICK_TOKENS
)
referenced = {
token
for token in re.findall(r"`([a-z][a-z0-9-]+)`", instructions)
if token not in machine_vocabulary
}
self.assertTrue(referenced)
self.assertEqual(sorted(referenced - skills), [])
def test_cook_it_through_limits_state_to_shared_local_markdown(self):
skill = normalized_prose(main_loop_skill_text())
self.assertIn("local Markdown tracker", skill)
self.assertIn("远程 tracker、独立 clone 或跨机器状态", skill)
self.assertNotIn("必须改用具备远程", skill)
if __name__ == "__main__":
unittest.main()
-8
View File
@@ -15,7 +15,6 @@ TSL_SYNC_WORKFLOW = ROOT / ".gitea" / "workflows" / "sync-tsl-playbook.yml"
LEGACY_WORKFLOW = ROOT / ".gitea" / "workflows" / "update-thirdparty-superpowers.yml"
UPDATE_SCRIPT = ROOT / ".gitea" / "ci" / "update_thirdparty_skills.sh"
SYNC_SCRIPT = ROOT / ".gitea" / "ci" / "sync_thirdparty_skills.sh"
SKILLS_MD = ROOT / "SKILLS.md"
def load_manifest() -> dict:
@@ -235,13 +234,6 @@ class ThirdpartySkillsPipelineTests(unittest.TestCase):
self.assertNotIn("COMMIT_AUTHOR_EMAIL", text)
self.assertNotIn("@local", text)
def test_skills_doc_points_to_generic_thirdparty_sources(self):
text = SKILLS_MD.read_text(encoding="utf-8")
# Check that third-party skills section exists (without enforcing exact heading format)
self.assertIn("thirdparty", text.lower())
self.assertIn("skills/thirdparty/", text)
self.assertNotIn("Third-party Skills (superpowers)", text)
def test_manifest_declares_unique_thirdparty_source_lists(self):
source_lists = [entry["source_list"] for entry in load_manifest()["sources"]]
self.assertEqual(len(source_lists), len(set(source_lists)))
-647
View File
@@ -1,647 +0,0 @@
import importlib.util
import re
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = ROOT / "skills" / "tsl-syntax-reference"
LOOKUP_PATH = SKILL_ROOT / "scripts" / "lookup.py"
SKILL_PATH = SKILL_ROOT / "SKILL.md"
CI_PATH = ROOT / ".gitea" / "workflows" / "checks.yml"
PREPARE_PATH = ROOT / ".gitea" / "workflows" / "prepare.yml"
NODE_VERSION_PATH = ROOT / ".node-version"
def load_lookup_module():
spec = importlib.util.spec_from_file_location("tsl_syntax_lookup", LOOKUP_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {LOOKUP_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
lookup = load_lookup_module()
def write_reference(directory: Path, name: str, content: str) -> Path:
page = directory / name
page.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8", newline="\n")
return page
def run_lookup(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(LOOKUP_PATH), *args],
cwd=ROOT,
capture_output=True,
text=True,
)
class TslSyntaxReferenceTests(unittest.TestCase):
def test_bundled_references_pass_structural_check(self):
self.assertEqual([], lookup.validate_references())
def test_bundled_sections_use_unique_explicit_ids(self):
sections = lookup.load_sections()
ids = [section.id for section in sections]
self.assertEqual(len(ids), len(set(ids)))
self.assertTrue(ids)
for section_id in ids:
with self.subTest(section_id=section_id):
self.assertRegex(section_id, r"^syntax-\d{2}-\d{3}$")
def test_explicit_section_id_survives_heading_rename(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
page = write_reference(
references,
"99_fixture.md",
"""
# Fixture
## 本篇职责
<!-- section-id: syntax-99-001 -->
测试职责
## 原标题
<!-- section-id: syntax-99-002 -->
事实正文
""",
)
before = lookup.load_sections(references)[1].id
page.write_text(
page.read_text(encoding="utf-8").replace("## 原标题", "## 新标题"),
encoding="utf-8",
newline="\n",
)
after = lookup.load_sections(references)[1].id
self.assertEqual("syntax-99-002", before)
self.assertEqual(before, after)
def test_missing_explicit_section_id_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"99_fixture.md",
"""
# Fixture
## 本篇职责
测试职责
""",
)
messages = [item.message for item in lookup.validate_references(references)]
self.assertTrue(any("缺少显式 section ID" in message for message in messages))
def test_duplicate_explicit_section_id_fails_check_and_loading(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"99_fixture.md",
"""
# Fixture
## 本篇职责
<!-- section-id: syntax-99-001 -->
测试职责
## 重复 ID
<!-- section-id: syntax-99-001 -->
事实正文
""",
)
messages = [item.message for item in lookup.validate_references(references)]
with self.assertRaisesRegex(lookup.ReferenceStructureError, "重复 section ID"):
lookup.load_sections(references)
self.assertTrue(any("重复 section ID" in message for message in messages))
def test_orphan_identity_line_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"99_fixture.md",
"""
# Fixture
## 本篇职责
<!-- section-id: syntax-99-001 -->
测试职责
## 示例
<!-- section-id: syntax-99-002 -->
代码块身份可直接照写示例
""",
)
messages = [item.message for item in lookup.validate_references(references)]
self.assertTrue(any("孤立的代码块身份" in message for message in messages))
def test_h5_and_h6_headings_fail_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"99_fixture.md",
"""
# Fixture
## 本篇职责
<!-- section-id: syntax-99-001 -->
测试职责
##### 不会被索引的标题
隐藏事实
""",
)
messages = [item.message for item in lookup.validate_references(references)]
self.assertTrue(any("H5/H6" in message for message in messages))
def test_quickstart_rule_drift_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"01_quickstart.md",
"""
# Quickstart
## 本篇职责
<!-- section-id: syntax-01-001 -->
派生摘要
## 语言核心事实速查
<!-- section-id: syntax-01-002 -->
<!-- quickstart-rule: assignment -->
- 普通赋值使用 `=`
""",
)
write_reference(
references,
"02_topic.md",
"""
# Topic
## 本篇职责
<!-- section-id: syntax-02-001 -->
完整事实源
## 核心规则
<!-- section-id: syntax-02-002 -->
<!-- quickstart-rule: assignment -->
- 普通赋值使用 `:=`
""",
)
messages = [item.message for item in lookup.validate_references(references)]
self.assertTrue(
any("派生摘要规则与专题事实不一致" in message for message in messages)
)
def test_complete_quickstart_contract_detects_rule_deleted_from_both_pages(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
quickstart = write_reference(
references,
"01_quickstart.md",
"""
# Quickstart
## 本篇职责
<!-- section-id: syntax-01-001 -->
派生摘要
## 语言核心事实速查
<!-- section-id: syntax-01-002 -->
没有任何规则
""",
)
pages = {quickstart: quickstart.read_text(encoding="utf-8").splitlines()}
messages = [
item.message
for item in lookup._quickstart_rule_problems(
pages,
require_complete=True,
)
]
self.assertTrue(any("assignment" in message for message in messages))
def test_quickstart_owner_drift_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
write_reference(
references,
"01_quickstart.md",
"""
# Quickstart
## 本篇职责
<!-- section-id: syntax-01-001 -->
派生摘要
## 语言核心事实速查
<!-- section-id: syntax-01-002 -->
<!-- quickstart-rule: assignment -->
- 普通赋值使用 `:=`
Owner Section`syntax-02-999`
""",
)
write_reference(
references,
"02_topic.md",
"""
# Topic
## 本篇职责
<!-- section-id: syntax-02-001 -->
完整事实源
## 核心规则
<!-- section-id: syntax-02-002 -->
<!-- quickstart-rule: assignment -->
- 普通赋值使用 `:=`
""",
)
messages = [item.message for item in lookup.validate_references(references)]
self.assertTrue(any("Owner Section 不存在" in message for message in messages))
def test_quickstart_routes_every_derived_rule_to_its_owner_section(self):
quickstart = run_lookup("--section", "syntax-01-002")
self.assertEqual(0, quickstart.returncode, quickstart.stderr)
owner_ids = set(
re.findall(r"Owner Section`(syntax-\d{2}-\d{3})`", quickstart.stdout)
)
self.assertEqual(
{
"syntax-02-002",
"syntax-03-002",
"syntax-03-004",
"syntax-05-002",
"syntax-06-004",
"syntax-08-002",
"syntax-09-002",
},
owner_ids,
)
for owner_id in owner_ids:
with self.subTest(owner_id=owner_id):
owner = run_lookup("--section", owner_id)
self.assertEqual(0, owner.returncode, owner.stderr)
def test_oversized_leaf_section_fails_structure_check(self):
with tempfile.TemporaryDirectory() as temp_dir:
references = Path(temp_dir)
long_body = "\n".join(f"事实行 {index}" for index in range(181))
write_reference(
references,
"99_fixture.md",
"# Fixture\n\n"
"## 本篇职责\n\n"
"<!-- section-id: syntax-99-001 -->\n\n"
"测试职责。\n\n"
"## 过长事实段\n\n"
"<!-- section-id: syntax-99-002 -->\n\n"
f"{long_body}\n",
)
result = run_lookup(
"--check",
"--references-dir",
str(references),
)
self.assertEqual(1, result.returncode)
self.assertIn("叶子 Section 正文超过 180 行", result.stderr)
def test_missing_references_are_installation_errors_for_every_action(self):
with tempfile.TemporaryDirectory() as temp_dir:
missing = Path(temp_dir) / "missing"
actions = (
("--map",),
("--query", "数组下标", "--mode", "explain"),
("--section", "syntax-03-002"),
("--check",),
)
for action in actions:
with self.subTest(action=action):
result = run_lookup(
*action,
"--references-dir",
str(missing),
)
self.assertEqual(1, result.returncode)
self.assertEqual("", result.stdout)
self.assertIn("参考目录不可用", result.stderr)
def test_documented_array_index_query_ranks_basic_array_section_first(self):
for query in ("数组下标", "请帮我解释数组的下标"):
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertEqual("03_values_and_literals.md", result.matches[0].section.page.name)
self.assertEqual("核心规则", result.matches[0].section.heading_path[-1])
def test_documented_write_and_diagnose_queries_rank_expected_sections(self):
write_result = lookup.query_sections("命名参数", "write", limit=1)
diagnose_result = lookup.query_sections(
"invalid statement 声明区",
"diagnose",
limit=1,
)
self.assertEqual(
["syntax-02-002", "syntax-01-002"],
[section.id for section in write_result.prelude],
)
self.assertEqual("syntax-05-008", write_result.matches[0].section.id)
self.assertEqual("syntax-02-006", diagnose_result.matches[0].section.id)
def test_object_and_class_queries_return_focused_sections(self):
cases = {
"成员访问可见性": "syntax-08-013",
"类外实现": "syntax-08-014",
"固定索引 property": "syntax-08-015",
"参数化 property": "syntax-08-016",
"方法隐藏 hide": "syntax-08-017",
"调用父类 inherited": "syntax-08-018",
"析构 destroy": "syntax-08-019",
}
for query, expected_id in cases.items():
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertTrue(result.matches, query)
self.assertEqual(expected_id, result.matches[0].section.id)
section = run_lookup("--section", expected_id)
self.assertEqual(0, section.returncode, section.stderr)
self.assertLessEqual(len(section.stdout.splitlines()), 186)
def test_external_call_queries_retrieve_platform_and_abi_boundaries(self):
cases = {
"动态库常驻": "syntax-17-012",
"外部出参": "syntax-17-013",
"Linux 动态加载": "syntax-17-014",
"函数指针释放": "syntax-17-008",
}
for query, expected_id in cases.items():
with self.subTest(query=query):
result = lookup.query_sections(query, "write", limit=1)
self.assertEqual(expected_id, result.matches[0].section.id)
def test_index_origin_variants_rank_value_rules_first(self):
for query in ("下标从几开始", "下标是从几开始", "请问下标是从几开始"):
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertEqual("03_values_and_literals.md", result.matches[0].section.page.name)
self.assertEqual("核心规则", result.matches[0].section.heading_path[-1])
def test_string_literal_queries_rank_generation_policy_first(self):
for query in ("普通中文字符串", "原始字符串 %%", "字符串 U L 前缀"):
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertEqual("syntax-03-004", result.matches[0].section.id)
def test_string_literal_generation_policy_is_in_quickstart_and_detail(self):
quickstart = run_lookup("--section", "syntax-01-002")
detail = run_lookup("--section", "syntax-03-004")
self.assertEqual(0, quickstart.returncode, msg=quickstart.stderr)
self.assertEqual(0, detail.returncode, msg=detail.stderr)
for output in (quickstart.stdout, detail.stdout):
self.assertIn("普通单行文本默认使用", output)
self.assertIn("不得因为内容是中文、非 ASCII 或较长", output)
self.assertIn("不能因为内容是中文就自动添加", output)
self.assertIn('普通中文内容优先直接写成 `"中文内容"`', output)
def test_every_curated_page_alias_still_ranks_its_page_first(self):
for page_name, aliases in lookup.PAGE_INTENT_ALIASES.items():
for alias in aliases:
with self.subTest(page=page_name, alias=alias):
result = lookup.query_sections(alias, "explain", limit=1)
self.assertTrue(result.matches, alias)
self.assertEqual(page_name, result.matches[0].section.page.name)
def test_weak_only_query_remains_a_no_match(self):
result = run_lookup("--query", "只能确认", "--mode", "explain")
self.assertEqual(2, result.returncode)
self.assertIn("only weak candidates", result.stderr)
def test_strong_candidates_are_ranked_before_weak_diagnose_boosts(self):
result = lookup.query_sections("变量赋值", "diagnose", limit=1)
self.assertTrue(result.matches)
self.assertFalse(result.matches[0].weak)
self.assertNotEqual("syntax-04-008", result.matches[0].section.id)
def test_page_intent_aliases_score_once_per_page(self):
result = lookup.query_sections("高性能矩阵 fmarray", "explain", limit=3)
self.assertTrue(result.matches)
self.assertEqual("syntax-22-004", result.matches[0].section.id)
for match in result.matches:
self.assertNotIn("intent=160", match.reasons)
self.assertIn("intent=80", result.matches[0].reasons)
def test_program_is_a_searchable_language_keyword(self):
result = lookup.query_sections("PROGRAM", "explain", limit=1)
self.assertTrue(result.matches)
self.assertEqual("syntax-02-009", result.matches[0].section.id)
def test_maintenance_html_comments_do_not_leak_into_lookup_output(self):
result = run_lookup("--query", "PROGRAM", "--mode", "explain")
self.assertEqual(0, result.returncode)
self.assertNotIn("prettier-ignore", result.stdout)
def test_new_language_gaps_have_stable_retrieval_entries(self):
cases = {
"全局变量": "syntax-04-009",
"运行时常量": "syntax-04-010",
"静态计算": "syntax-06-016",
"只计算一次": "syntax-06-016",
"指定系统函数": "syntax-05-013",
"运算符优先级": "syntax-06-015",
"内存上限": "syntax-14-011",
"嵌套注释": "syntax-15-005",
"JOIN 第二张表当前行": "syntax-13-010",
"按行广播": "syntax-11-011",
}
for query, expected_id in cases.items():
with self.subTest(query=query):
result = lookup.query_sections(query, "explain", limit=1)
self.assertTrue(result.matches, query)
self.assertEqual(expected_id, result.matches[0].section.id)
def test_batch_section_retrieval_is_atomic(self):
ids = [section.id for section in lookup.load_sections()[:2]]
success = run_lookup("--section", *ids)
failure = run_lookup("--section", ids[0], "syntax-99-999")
self.assertEqual(0, success.returncode)
self.assertTrue(all(section_id in success.stdout for section_id in ids))
self.assertEqual(2, failure.returncode)
self.assertEqual("", failure.stdout)
self.assertIn("section not found: syntax-99-999", failure.stderr)
def test_structural_metadata_is_not_exposed_in_lookup_output(self):
sections = {section.id: section for section in lookup.load_sections()}
candidates = lookup.render_candidates(
lookup.query_sections("命名参数", "write", limit=1)
)
quickstart = lookup.render_section(sections["syntax-01-002"])
for output in (candidates, quickstart):
with self.subTest(output=output[:40]):
self.assertNotIn("<!-- section-id:", output)
self.assertNotIn("<!-- quickstart-rule:", output)
def test_skill_contract_uses_extracted_queries_and_scope_checks(self):
skill = SKILL_PATH.read_text(encoding="utf-8")
help_result = run_lookup("--help")
self.assertEqual(0, help_result.returncode, help_result.stderr)
help_text = help_result.stdout
self.assertNotIn("## 构造查询词", skill)
self.assertNotIn("用户怎么说就怎么传", skill)
self.assertIn("从用户原话提取", help_text)
self.assertIn("保留原写法但不传完整用户句", help_text)
self.assertIn("不传完整用户句", help_text)
self.assertIn("代码交付只要依赖这些未验证事实", skill)
self.assertIn("不声称签名、行为、可用性或输出", skill)
self.assertIn("纯语法说明", skill)
self.assertIn("按「缺口时停止」处理", skill)
def test_help_owns_cli_details_and_skill_defers_to_it(self):
skill = SKILL_PATH.read_text(encoding="utf-8")
result = run_lookup("--help")
self.assertEqual(0, result.returncode, result.stderr)
for text in (
"一次 --query 只覆盖一个语法要素",
"Required: yes",
"Owner Section",
"混合候选",
"180 行粒度上限",
"退出码",
):
with self.subTest(text=text):
self.assertIn(text, result.stdout)
self.assertIn("构造任一命令前先运行", skill)
self.assertIn("scripts/lookup.py --help", skill)
self.assertIn("不用于查询 API 签名", skill)
self.assertIn("不用于判断解释器、平台或运行方式", skill)
def test_skill_is_self_contained_and_does_not_reference_host_rules(self):
skill = SKILL_PATH.read_text(encoding="utf-8")
for forbidden in (
"AGENTS.md",
"AGENT_RULES",
".agents/",
"CONTEXT.md",
"memory-bank",
"docs/",
"tsl-api-reference",
"data/README.md",
"项目脚本",
"项目文档",
"CI",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, skill)
self.assertNotRegex(skill, r"\[[^\]]+\]\([^)]*\.md[^)]*\)")
self.assertEqual(
{"SKILL.md"},
set(re.findall(r"[A-Za-z0-9_./-]+\.md", skill)),
)
def test_ci_runs_syntax_structure_and_format_gates(self):
workflow = CI_PATH.read_text(encoding="utf-8")
prepare = PREPARE_PATH.read_text(encoding="utf-8")
self.assertIn(
"npm ci --ignore-scripts --no-audit --no-fund --no-bin-links",
workflow,
)
self.assertIn("skills/tsl-syntax-reference/scripts/lookup.py --check", workflow)
self.assertIn(
"node_modules/prettier/bin/prettier.cjs --check skills/tsl-syntax-reference",
workflow,
)
self.assertIn("[unzip]=\"unzip\"", prepare)
self.assertIn('FNM_BIN: "/data/bin/fnm"', prepare)
self.assertIn('FNM_DIR: "/data/fnm"', prepare)
self.assertIn('"$FNM_BIN" install "$node_version"', prepare)
self.assertNotIn('[node]="nodejs"', prepare)
self.assertNotIn('[npm]="npm"', prepare)
self.assertIn('"$FNM_BIN" use --install-if-missing "$node_version"', workflow)
self.assertIn("node --version", workflow)
self.assertRegex(
NODE_VERSION_PATH.read_text(encoding="utf-8").strip(),
r"^\d+\.\d+\.\d+$",
)
if __name__ == "__main__":
unittest.main()
@@ -602,6 +602,11 @@ def _markdown_cell(value: str) -> str:
return value.replace("|", "\\|").replace("\n", "<br>")
def _render_note(note: str) -> str:
"""Keep source numbering as text instead of creating a nested list."""
return re.sub(r"^(\s*\d+)[.]", r"\1\\.", note)
def render_dictionary_page(page: DictionaryPage) -> str:
path = " / ".join((*page.source_path, page.title))
lines = [f"# 天软数据字典 / {path}", "", f"类型:{page.kind}", "", "## 表信息", ""]
@@ -632,7 +637,7 @@ def render_dictionary_page(page: DictionaryPage) -> str:
if page.notes or page.table.update_info:
lines.extend(["", "## 数据说明", ""])
for note in (*page.notes, *page.table.update_info):
lines.append(f"- {note}")
lines.append(f"- {_render_note(note)}")
if page.examples:
lines.extend(["", "## 取数示例", ""])
for index, example in enumerate(page.examples, start=1):
+43
View File
@@ -1,3 +1,4 @@
import importlib.util
import json
import subprocess
import sys
@@ -11,6 +12,8 @@ try:
except ImportError:
yaml = None
BS4_AVAILABLE = importlib.util.find_spec("bs4") is not None
TOOL_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = TOOL_ROOT.parents[1]
@@ -18,6 +21,7 @@ CONVERT = TOOL_ROOT / "scripts" / "convert_tsf.py"
GENERATE = TOOL_ROOT / "scripts" / "generate.py"
LINT = TOOL_ROOT / "scripts" / "lint.py"
BUILD_INDEX = TOOL_ROOT / "scripts" / "build_index.py"
BUILD_DICTIONARY = TOOL_ROOT / "scripts" / "build_dictionary.py"
LOOKUP = REPO_ROOT / "skills" / "tsl-api-reference" / "scripts" / "lookup.py"
STANDARD = TOOL_ROOT / "STANDARD.md"
README = TOOL_ROOT / "README.md"
@@ -29,6 +33,18 @@ API_WORKFLOW = (
)
def load_build_dictionary():
spec = importlib.util.spec_from_file_location(
"tsl_codegen_build_dictionary", BUILD_DICTIONARY
)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {BUILD_DICTIONARY}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class UnifiedPipelineTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
@@ -38,6 +54,33 @@ class UnifiedPipelineTest(unittest.TestCase):
def tearDown(self):
self.temp_dir.cleanup()
@unittest.skipUnless(BS4_AVAILABLE, "beautifulsoup4 is not installed")
def test_dictionary_notes_escape_source_numbering(self):
module = load_build_dictionary()
page = module.DictionaryPage(
kind="table",
scope="macro",
title="示例",
source_path=("宏观",),
source_file="example.html",
table=module.DictionaryTable(
name="示例",
table_id="1",
extract_method="",
access_code="",
update_info=("1、更新频率:月度",),
),
fields=(),
notes=("1. 第一条说明", "2. 第二条说明"),
examples=(),
)
rendered = module.render_dictionary_page(page)
self.assertIn("- 1\\. 第一条说明", rendered)
self.assertIn("- 2\\. 第二条说明", rendered)
self.assertIn("- 1、更新频率:月度", rendered)
def run_command(self, *args, cwd=None):
return subprocess.run(
[sys.executable, *map(str, args)],