🔧 chore(integration): merge CI maintenance updates

This commit is contained in:
csh
2026-08-20 16:13:17 +08:00
14 changed files with 892 additions and 366 deletions
+2 -2
View File
@@ -14,8 +14,8 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
schedule: schedule:
# thirdparty 快照每日轮询:Prepare 定时成功后update-thirdparty-skills 通过 workflow_run 触发 # 北京时间(UTC+8)每日 06:00Prepare 成功后由 workflow_run 触发 thirdparty 快照更新
- cron: "17 3 * * *" - cron: "0 22 * * *"
concurrency: concurrency:
group: prepare-${{ github.repository }} group: prepare-${{ github.repository }}
+104 -185
View File
@@ -1,231 +1,150 @@
--- ---
name: gitea-fix-ci name: gitea-fix-ci
description: "Use when a user asks to debug or fix failing Gitea Actions, Gitea PR checks, or CI workflow runs for a Gitea-hosted repository. Also triggers on Chinese phrasing such as CI 挂了/流水线红了/构建失败/工作流失败." description: "Diagnose failing, skipped, or missing Gitea/Forgejo Actions and PR checks, including prepare→workflow_run trigger chains, then produce an evidence-backed repair plan before changing code. Use for CI 挂了/流水线红了/构建失败/工作流失败 on Gitea-hosted repositories. Do not use for GitHub/GitLab or external CI, local-only test/lint requests, or cases with neither remote access nor supplied logs."
--- ---
# Gitea Fix CI # Gitea Fix CI
## 概述 ## 核心约束
从 pull request 或 workflow run 诊断失败的 Gitea Actions,提取最小可用的失败上下文, - 把 run、job 和日志当证据;未取证前不猜根因、不改代码。
然后在改代码之前先给出修复计划。核心原则:CI 日志是证据,不要仅凭红色状态臆测原因 - 先制定最小修复计划,在显式检查点等待授权
- 区分本地修改、push、重跑 workflow、runner/secret 操作;一种授权不包含另一种。
- 取证脚本只读取 Gitea Actions,不分类、不修复、不改变远端状态。
本 skill 不是独立执行器。它指导你在当前工作区中使用随附的取证脚本 随附脚本位于 `scripts/fetch_ci_logs.py`。全局选项必须放在子命令前:
`scripts/fetch_ci_logs.py``tea`、本地 `git` 和 Gitea API。
取证阶段(step 2-4)优先使用随附脚本,把「探测版本 → 找失败 run → 列 job →
下载失败 job 日志」这段易记错 API 路径的逻辑交给它。脚本仅取证到日志,分类、
修复计划与改代码仍由你按本文档执行。将 `<skill-dir>` 替换为包含本 `SKILL.md`
的目录:
```bash ```bash
# 方式一(优先):通过环境变量提供 API token python <skill-dir>/scripts/fetch_ci_logs.py version
export GITEA_TOKEN=<token> python <skill-dir>/scripts/fetch_ci_logs.py --json runs --limit 20
python <skill-dir>/scripts/fetch_ci_logs.py version # 探测实例版本并确认连通性 python <skill-dir>/scripts/fetch_ci_logs.py --json jobs <run-id>
python <skill-dir>/scripts/fetch_ci_logs.py runs --status failure --branch <branch> python <skill-dir>/scripts/fetch_ci_logs.py --json logs <job-id> --tail 80
python <skill-dir>/scripts/fetch_ci_logs.py jobs <run-id> # 标注 failure 的 job
python <skill-dir>/scripts/fetch_ci_logs.py logs <job-id> # 日志存临时文件并回显尾部
# 方式二:显式复用当前仓库的 Git HTTP 凭据
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential version
python <skill-dir>/scripts/fetch_ci_logs.py --use-git-credential runs --status failure
``` ```
`base-url`、owner 和 repo 默认从 git remote 推导。认证规则如下: 认证优先级为 `GITEA_TOKEN` → 显式 `--use-git-credential` → 匿名访问。只在 HTTPS
同源请求中发送认证 header;拒绝跨源请求与跨源重定向。不得把 token、用户名、密码
- 设置了 `GITEA_TOKEN` 时始终优先使用它,即使同时传入 `--use-git-credential` 放进 argv、日志或对话
- 只有显式传入 `--use-git-credential` 才会非交互调用 `git credential fill`,并将
当前仓库对应的用户名和密码用于 HTTP Basic 认证;脚本不会静默读取 Git 凭据。
- 未设置 token 且未传入该参数时按匿名方式访问,私有仓库通常会返回 401。
- 所有携带凭据的请求必须使用 HTTPS;认证 header 只发送到配置的 Gitea 同源地址,
跨源请求和跨源重定向会被拒绝。凭据不会出现在命令参数、日志或异常文本中。
### 取证路径选择
在 steps 24 沿同一条路径完成 run、job 与日志取证,不要在路径之间无依据跳转:
| 触发条件 | 取证路径 |
| --- | --- |
| 随附脚本可运行且 Actions API 可用 | 使用 `fetch_ci_logs.py``version``runs``jobs``logs` |
| 脚本不可用,但 `tea actions` 或 Actions API 可用 | 使用下文对应的 `tea` 命令或 REST 端点 |
| Actions API 返回 404,旧版实例仍有 Actions web UI | 使用 legacy web 页面定位 run、失败 job index 与日志 |
| 无法远端访问,但用户提供日志 | 以粘贴日志为证据,并明确标注远端 run/job 未核验 |
## 适用场景
- Gitea 托管的仓库出现失败的 Gitea Actions 或 PR checks
- 用户要求检查某个失败的 workflow run、job 或 CI 状态
- 用户要求在 push、更新分支或更新 pull request 之后修复 CI
- 本地测试通过,但远端 Gitea Actions 失败
## 不适用场景
- 仓库并非托管在 Gitea 或 Forgejo 兼容的基础设施上
- 失败属于外部 CI 服务,只是从 Gitea 链接出去
- 用户只想在本地跑测试或 lint
- 没有凭据、token 或网络访问权限,且用户也未提供失败的日志文本
## 输入 ## 输入
- 仓库路径,默认当前工作区 - 当前仓库路径和 remote,默认使用当前工作区`origin`
- Gitea base URL 和仓库 owner/name,尽量从 `git remote -v` 获取 - PR 编号、分支、完整 commit SHA、workflow run ID 或 job ID
- Pull request 编号、分支、commit SHA 或 workflow run ID - Gitea base URL 与 owner/repo;优先从 remote 推导
- 认证方式:`tea` 登录 profile、用于 API 请求的 `GITEA_TOKEN`,或通过 - `GITEA_TOKEN`、已配置的 `tea` profile,或显式 Git HTTP 凭据授权
`--use-git-credential` 显式读取的当前仓库 Git HTTP 凭据 - 无法访问远端时,由用户提供的最小 CI 日志
- 若无法远端访问,则由用户粘贴的 CI 日志
## 适用边界
适用于 Gitea/Forgejo 托管的 Actions、PR checks 和 workflow_run 触发链;入口可以是
PR、run、job、commit SHA 或用户提供的 CI 日志。
不适用于 GitHub/GitLab、仅从 Gitea 链出的外部 CI、只想本地跑测试/lint,或既无远端
访问也无用户日志的请求。
## 取证路径
先选择一条路径,再读取对应文件;不要把互斥路径的细节全部载入:
| 触发条件 | 必须读取 | 下一步 |
| ----------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------- |
| 脚本可运行且 Actions API 可用 | [`workflows/actions-api.md`](workflows/actions-api.md) | 用 workflow、事件、状态和时间遍历直接 run 或 `workflow_run` 链 |
| 脚本不可用但 `tea actions` 可用 | [`workflows/fallback.md`](workflows/fallback.md) | 使用 tea 定位 run/job/log |
| Actions API 返回 404,旧实例仍有 Actions web UI | [`workflows/fallback.md`](workflows/fallback.md) | 从 run 页面选择失败 job index |
| 只有用户粘贴的日志 | [`workflows/fallback.md`](workflows/fallback.md) | 标记远端 run/job 未核验 |
## 流程 ## 流程
1. **基线本地状态** 1. **记录基线**
- 记录 `git status --short`当前分支和最新 commit SHA。 运行 `git status --short`,记录当前分支、HEAD 和 `git remote -v`。通过已知实例或
-`git remote -v` 识别 remote URL 和 owner/repo `/api/v1/version` 确认 remote 属于 Gitea/Forgejo。取证期间不修改文件
- 继续之前先确认 remote 是 Gitea/Forgejo:对照已知的 Gitea 实例核对 host
或探测 `/api/v1/version`。若 remote 是 GitHub、GitLab 或其他服务,按"不适用场景"停止。
- 收集 CI 证据期间不要修改文件。
2. **验证 Gitea 访问** 2. **选择路径并定位 run**
- 优先运行 `fetch_ci_logs.py version` 探测实例版本并确认脚本能连通实例。脚本从 按上表读取一个 workflow 文件并执行。入口是 PR 时,先读取仓库 workflow 的 `on:`
remote 推导 base URL,认证按 `GITEA_TOKEN` → 显式 `--use-git-credential` 段,判断失败检查是直接触发,还是由 Prepare 等上游 workflow 经 `workflow_run`
匿名的顺序选择。注意:`version` 只读 `/api/v1/version`,成功仅代表连通与鉴权 触发。不得默认所有 run 都保存 PR 分支或 PR head SHA。
可用,**不代表 Actions API 一定可用**。Actions 端点是否存在需在后续
`runs`/`jobs` 步骤中实际验证;旧版 Gitea(见下)会在此才暴露 404。
- 选择 `tea` 路径时,先确认已安装并已认证:
- `tea login list`
- 使用前先运行 `tea actions --help` 确认已安装的 `tea` 版本存在 `actions`
子命令;并非所有版本都带这些子命令。
- `tea actions runs list --status failure --branch <branch>`
- `tea actions runs view <run-id>`
- `tea actions runs logs <run-id> --job <job-id>`
- `tea pulls view <pr>`
-`tea` 不可用,或已安装版本缺少 Actions 命令,则直接使用 Gitea API。
- 当 API 行为不明确时,检查 `/api/v1/version``/swagger.v1.json`。较旧的
Gitea1.21 及更早)在 web UI 中提供 Actions 页面,但不提供 workflow
run/job/log 的 API 端点;应对照上报的版本确认可用性,而不是想当然。
- 绝不打印 token、用户名或密码。API token 只通过环境变量传递;Git 凭据只通过
`git credential fill` 的标准输入/输出在脚本进程内传递,不放入 argv。
3. **定位失败的 workflow run** 区分 `failure``cancelled``skipped` 和缺失:
- 若入口是 PR 而非 run ID,先把 PR 解析成 run:取 PR 的 head 分支与 head SHA - 有明确 run ID 时直接查该 run。
`fetch_ci_logs.py``runs` 支持 `--branch`/`--sha`;手动则 - 直接触发的 PR workflow 优先按完整 PR head SHA 过滤;只有实例已验证
`GET /api/v1/repos/<owner>/<repo>/pulls/<pr>``head.ref`/`head.sha`), `head_branch` 语义时才把 PR 分支作为辅助过滤条件。
再按该 SHA 过滤 runs。PR 页面的 checks 列表可能聚合多个 workflow,逐一定位到 - `workflow_run` 链先定位上游 run,再判断是否应存在下游 run。
具体失败 run,不要假设只有一个 - 上游失败或取消时,证据优先在上游日志;下游通常为 `skipped`,也可能缺失
- 优先用脚本:`fetch_ci_logs.py runs --status failure --branch <branch>`
(也支持 `--sha`/`--event`/`--limit`),已知 run ID 时用
`fetch_ci_logs.py jobs <run-id>` 直接列出各 job 并标注失败项。
- 使用 `tea`、API 或 web 路径且已知 run ID 时,直接获取该 run。
- 否则按分支、事件、状态或 commit SHA 过滤,列出最近的 workflow runs。
- API 模式:
- `GET /api/v1/repos/<owner>/<repo>/actions/runs`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>/jobs`
- 较旧 Gitea 的 web 回退:
- `GET /<owner>/<repo>/actions`
- 解析 `/<owner>/<repo>/actions/runs/<run>` 链接和状态标签。
- 区别对待 `failure``cancelled` 和缺失的必需 checks。被取消(cancelled)的
job 可能需要重跑或排查队列,而非改代码。
4. **获取 job 日志** 3. **获取最小日志证据**
- 优先用脚本:`fetch_ci_logs.py logs <job-id>`。它把日志存到受跟踪源码路径 先列 jobs,再下载失败 job 日志;取消或基础设施场景读取最后执行或被取消的相关
之外的临时文件、只回显尾部若干行,并打印临时文件路径供进一步查看 job。保存完整日志到受跟踪源码路径之外,只回显首个可操作错误块及必要上下文。
`--tail N` 调整行数,`--json` 输出结构化结果)。 记录 workflow、job、run URL、事件、状态、分支和 SHA。日志缺失时明确报告,不编造
- 使用 API 路径时,对每个失败的 job 下载日志: 原因。
- `GET /api/v1/repos/<owner>/<repo>/actions/jobs/<job_id>/logs`
- 在较旧 Gitea 的 web 回退(无 job-log API)下,按 UI 的 job index 下载日志:
- `GET /<owner>/<repo>/actions/runs/<run>/jobs/<job-index>/logs`
- 先打开 run 页面读取每个 job 的状态;取状态为 `failure` 的 job 的 index
不要默认用 index `0`。失败的 job 很少是第一个,盲目用 index `0` 通常会返回
某个通过的 job 的日志。只有当 run 页面不暴露每个 job 的状态时,才回退到逐一
扫描 index。
- 大日志保存到受跟踪源码路径之外的临时文件。
- 提取首个可操作的错误块、其上下文命令、job 名、workflow 名、run URL、分支和 SHA。
- 若日志缺失,明确报告,而不是编造原因。
5. **分类失败** 4. **分类失败**
- 代码/测试失败:断言失败、编译错误、lint 错误、类型错误 - **代码/测试失败**:断言、编译、lint 类型错误
- 环境失败:缺失 secret、runner 镜像、依赖安装、网络、缓存、权限或服务启动 - **环境失败**:secret、镜像、依赖、网络、缓存、权限或服务启动
- 工作流失败:YAML 非法、语法不支持、触发条件错误、路径错误、分支/ref 假设错误 - **工作流失败**YAML、触发条件、路径ref 或事件假设错误
- 基础设施失败:runner 离线、队列卡住、run 被取消、超时 - **基础设施失败**runner 离线、队列取消、超时
- 把日志直接显示的事实与根因假设分开。一个断言、编译错误或心跳中断只能证明其 - **上游门禁未通过**:Prepare 等上游 `failure`/`cancelled`,导致下游 `skipped` 或缺失
直接症状;在读取相关源码、配置或运行环境前,不把默认值、依赖版本、runner
资源等假设写成已确认根因。
6. **制定修复计划** 把日志直接显示的事实与根因假设分开。断言、编译错误或心跳中断只证明直接症状;
读取相关源码、配置或运行环境前,不把默认值、版本或资源假设写成已确认根因。
- 用确切的 job/run 标识总结失败证据。 5. **制定修复计划**
- 提出与证据匹配的最小代码或工作流改动;证据尚未区分多个可能根因时,先计划
检查对应源码、配置或环境,不预选修复。
- 本地验证优先复用失败 workflow/job 的原命令。若只能运行不同命令,明确标为
“近似验证”,说明与 CI 命令、环境或依赖的差异,并保留远端复检路径。
7. **🔴 CHECKPOINT · 🛑 STOP:等待修复授权** 用确切 run/job 标识总结证据,提出与证据匹配的最小修改。优先复用失败 job 的原命令
做本地验证;只能运行不同命令时标为“近似验证”,说明差异并保留远端复检路径。
展示计划后停止。只有用户明确批准具体动作,才进入实施:批准修改本地文件不等于 6. **🔴 CHECKPOINT · 🛑 STOP:等待修复授权**
批准 push 或重跑 workflow;批准重跑不等于批准重启/重新注册 runner、修改 secret
或其它基础设施状态。授权范围不明确时保持只读取证,不修改代码、workflow 或外部
状态。
8. **批准后实施** 展示计划后停止。批准修改本地文件不等于批准 push 或重跑;批准重跑不等于批准
重启/重新注册 runner、修改 secret 或其它基础设施状态。授权不明确时保持只读取证。
- 只应用已批准的修复。 7. **批准后实施**
- 运行最接近复现该失败 job 的本地命令。
- 若失败仅涉及工作流,验证工作流文件语法及其引用的路径或脚本。
9. **复检** 只应用已批准的修复,运行最接近 CI 的本地命令。工作流改动需验证语法、触发条件、
引用路径和事件字段。
- 告诉用户需要在 Gitea 中 push 或重跑什么。 8. **复检**
- 若获准,使用 Gitea API 检查重跑状态。
- 最终输出必须区分本地验证与远端 CI 状态。 区分本地验证与远端状态。说明需要 push 或重跑什么;只有获得相应授权后才执行或
查询重跑。
## 输出约定 ## 输出约定
- `Target:` 仓库、分支/SHA、PR 或 run ID - `Target:` 仓库、PR、分支/SHA 或 run ID
- `Failed CI:` workflow、job、状态run URLAPI 路径 - `Failed CI:` workflow、job、状态run URL/API 路径
- `Evidence:` 精简的日志片段、分类、已确认事实与待验证的根因假设 - `Evidence:` 最小日志片段、分类、已确认事实与待验证假设
- `Plan:` 提出的检查或修复、本地验证(原命令或标注近似)、远端复检 - `Plan:` 最小修复、本地验证方式与远端复检
- `Changes:` 批准后改的文件 - `Changes:` 批准后改的文件
- `Result:` 已运行的本地检查与剩余远端状态 - `Result:` 已运行检查与剩余远端状态
## 成功标准 ## 成功标准
- 失败分析基于 Gitea Actions 的 run/job 数据或粘贴日志 - 分析基于 Gitea run/job/log 或明确标注的粘贴日志
- 修复计划指明其针对的确切 workflow run 或 job - 计划指确切 workflow run/job 或确切的上游门禁
- 计划批准前不发生任何代码或工作流改动 - 计划批准前不修改本地文件或远端状态
- 验证区分本地命令与远端 Gitea Actions 结果 - 不泄露凭据或整段私有日志
- 不回显 token;私有日志只保留可操作的片段,不整段外泄 - 最终结果区分本地验证、推断关联与远端已核验状态
## 危险信号Red Flags ## 危险信号
出现以下情况说明流程走偏,停下纠正而非继续: - 未下载日志就断言原因或修改代码
-`status=completed` 当成功,而不看 `conclusion`
- **凭红色状态臆测原因**:还没下载 job 日志就断言失败原因或动手改代码。 - 按 PR 分支或 PR SHA 过滤 `workflow_run` 下游 run
- **把症状写成根因**:仅凭单个断言、编译错误或最后一行日志,就断言具体默认值、 - Prepare 失败后继续寻找不存在的“下游失败日志”,忽略 `skipped`/缺失
依赖版本、配置路径或 runner 资源是根因。 - 在 web 回退中盲取 job index `0`
- **误读双字段结果**:把 `status`(生命周期)当成结果判定。Gitea Actions 沿 - `version` 成功当成 Actions API 一定可
GitHub 兼容的双字段模型——`status=completed` 只表示跑完了,真正的成败在 -`cancelled`、runner 或队列问题当代码 bug
`conclusion``failure`/`success`/`cancelled`)。判定失败必须看有效结果, - 静默读取凭据、使用 HTTP、跨源发送认证 header
而非 `status=completed` 就当通过。 - 越权 push、重跑、改 secret 或操作 runner
- **盲取 job index `0`**:在 web 回退下不看每个 job 状态就用 index `0` 下载日志;
失败的 job 很少是第一个,通常会误取到某个通过 job 的日志。
- **把 `version` 成功当作 Actions API 可用**`version` 只探连通与鉴权,Actions
端点可能仍返回 404(旧版 Gitea)。
- **把 `cancelled`/基础设施问题当代码 bug 修**:被取消、runner 离线、队列卡住、
超时应重跑或排查环境,不是改代码。
- **回显 token 或整段私有日志**:只保留可操作的最小片段。
- **静默读取或降级传输凭据**:Git 凭据必须由 `--use-git-credential` 显式启用;
token 和 Git 凭据都不得通过 HTTP 或跨源重定向发送。
## 失败处理 ## 失败处理
- 401 且未使用认证时,通过环境提供 `GITEA_TOKEN` 或显式传入 - 401提供 `GITEA_TOKEN` 或显式使用 `--use-git-credential`;已认证仍 401 时检查凭据
`--use-git-credential`;401 且已使用认证时检查凭据是否有效,不要在对话中索要 secret - 403:补足仓库和 Actions 读取权限,不降级为匿名
- 403 表示所选凭据缺少仓库或 Actions 读取权限;补足权限,而不是切换到匿名访问 - 404:核对 owner/repo、endpoint 和版本;旧版 Actions API 转 web 回退
- 404 优先核对 owner/repo、端点和 Gitea 版本;旧版本缺少 Actions API 时使用 web 回退 - 下游候选无法与上游可靠关联:报告“关联未证实”,使用日志中的 ref/SHA 或用户提供的 run URL
- 若 Gitea 版本缺少 Actions API 端点,请用户提供相关日志文本或从浏览器复制的 job 日志 - 外部 CI:报告外部 URL 后停止本 skill
- 若失败的 check 归属外部 CI 服务,报告外部 URL 并在证据收集处停止 - 只有基础设施问题:计划重跑或排查 runner,不修改源码
- 若失败仅为基础设施问题,建议重跑/排查 runner,而非改代码
+49 -9
View File
@@ -466,14 +466,30 @@ def _outcome(item: dict[str, Any]) -> str:
return (conclusion or status).lower() return (conclusion or status).lower()
def _workflow_name(run: dict[str, Any]) -> str:
workflow = run.get("name") or run.get("workflow_id") or ""
if workflow:
return str(workflow)
path = str(run.get("path") or "")
return path.rsplit("@", 1)[0] if path else ""
def _run_row(run: dict[str, Any]) -> dict[str, Any]: def _run_row(run: dict[str, Any]) -> dict[str, Any]:
workflow = _workflow_name(run)
return { return {
"id": run.get("id"), "id": run.get("id"),
"name": run.get("name") or run.get("workflow_id") or "", "name": workflow,
"workflow": workflow,
"path": run.get("path") or "",
"title": run.get("display_title") or "",
"outcome": _outcome(run), "outcome": _outcome(run),
"event": run.get("event") or "", "event": run.get("event") or "",
"branch": run.get("head_branch") or run.get("branch") or "", "branch": run.get("head_branch") or run.get("branch") or "",
"sha": (run.get("head_sha") or run.get("commit_sha") or "")[:12], "sha": run.get("head_sha") or run.get("commit_sha") or "",
"started_at": run.get("started_at") or "",
"completed_at": run.get("completed_at") or "",
"run_number": run.get("run_number"),
"run_attempt": run.get("run_attempt"),
"url": run.get("html_url") or run.get("url") or "", "url": run.get("html_url") or run.get("url") or "",
} }
@@ -483,24 +499,30 @@ def cmd_runs(
target: RepoTarget, target: RepoTarget,
client: ApiClient, client: ApiClient,
) -> int: ) -> int:
if args.sha and not re.fullmatch(r"[0-9a-fA-F]{40}", args.sha):
raise ConfigError("--sha requires a full 40-character hexadecimal commit SHA")
query: dict[str, str] = {} query: dict[str, str] = {}
if args.branch: if args.branch:
query["branch"] = args.branch query["branch"] = args.branch
if args.event: if args.event:
query["event"] = args.event query["event"] = args.event
if args.status: if args.status and args.status != "all":
query["status"] = args.status query["status"] = args.status
if args.sha: if args.sha:
query["head_sha"] = args.sha query["head_sha"] = args.sha
if args.limit: if args.limit:
query["limit"] = str(args.limit) query["limit"] = str(args.limit)
suffix = "/actions/runs" if args.workflow:
workflow = urllib.parse.quote(args.workflow, safe="")
suffix = f"/actions/workflows/{workflow}/runs"
else:
suffix = "/actions/runs"
if query: if query:
suffix += "?" + urllib.parse.urlencode(query) suffix += "?" + urllib.parse.urlencode(query)
payload = client.get_json(target.repo_path(suffix)) payload = client.get_json(target.repo_path(suffix))
runs = [_run_row(run) for run in _as_list(payload, "workflow_runs")] runs = [_run_row(run) for run in _as_list(payload, "workflow_runs")]
if args.sha: if args.sha:
runs = [row for row in runs if row["sha"].startswith(args.sha[:12])] runs = [row for row in runs if row["sha"].lower() == args.sha.lower()]
runs = runs[: args.limit] if args.limit else runs runs = runs[: args.limit] if args.limit else runs
if args.json: if args.json:
@@ -511,10 +533,20 @@ def cmd_runs(
return 1 return 1
print(f"{len(runs)} run(s):") print(f"{len(runs)} run(s):")
for row in runs: for row in runs:
number = f" number={row['run_number']}" if row["run_number"] else ""
attempt = f" attempt={row['run_attempt']}" if row["run_attempt"] else ""
print( print(
f" run {row['id']} [{row['outcome']}] {row['name']} " f" run {row['id']} [{row['outcome']}] {row['workflow']} "
f"{row['event']} {row['branch']} {row['sha']}" f"{row['event']} {row['branch']} {row['sha'][:12]}"
f"{number}{attempt}"
) )
if row["started_at"] or row["completed_at"]:
print(
f" started={row['started_at'] or '-'} "
f"completed={row['completed_at'] or '-'}"
)
if row["title"]:
print(f" title: {row['title']}")
if row["url"]: if row["url"]:
print(f" {row['url']}") print(f" {row['url']}")
return 0 return 0
@@ -651,10 +683,18 @@ def _build_parser() -> argparse.ArgumentParser:
sub.add_parser("version", help="probe /api/v1/version") sub.add_parser("version", help="probe /api/v1/version")
runs = sub.add_parser("runs", help="list workflow runs") runs = sub.add_parser("runs", help="list workflow runs")
runs.add_argument(
"--workflow",
help="workflow file or ID (for example checks.yml)",
)
runs.add_argument("--branch") runs.add_argument("--branch")
runs.add_argument("--event") runs.add_argument("--event")
runs.add_argument("--status", default="failure") runs.add_argument(
runs.add_argument("--sha") "--status",
default="failure",
help="run outcome/status (default failure; use all to omit this filter)",
)
runs.add_argument("--sha", help="full triggering commit SHA")
runs.add_argument("--limit", type=int, default=20) runs.add_argument("--limit", type=int, default=20)
jobs = sub.add_parser("jobs", help="list jobs of a run, flag failed ones") jobs = sub.add_parser("jobs", help="list jobs of a run, flag failed ones")
+10
View File
@@ -13,5 +13,15 @@
"id": "cancelled-infrastructure-not-code-bug", "id": "cancelled-infrastructure-not-code-bug",
"prompt": "Gitea Actions 页面仍是红色:run 的 status 是 completedconclusion 是 cancelled,日志最后显示 runner lost heartbeat。请修复这个 CI。", "prompt": "Gitea Actions 页面仍是红色:run 的 status 是 completedconclusion 是 cancelled,日志最后显示 runner lost heartbeat。请修复这个 CI。",
"expected": "以 conclusion 而非 status 判断结果,将其分类为取消或基础设施问题;建议重跑并排查 runner/队列,不编造代码原因、不修改源码;最终明确区分本地验证与尚待远端复检的状态。" "expected": "以 conclusion 而非 status 判断结果,将其分类为取消或基础设施问题;建议重跑并排查 runner/队列,不编造代码原因、不修改源码;最终明确区分本地验证与尚待远端复检的状态。"
},
{
"id": "workflow-run-chain-checks-failure",
"prompt": "Gitea 上 PR #42 的 Prepare 已成功,但 Checks 红了。仓库的 checks.yml 由 workflow_run 触发。请定位正确日志并给出修复计划。",
"expected": "先读取 workflow 触发关系并按 PR head SHA 定位 Prepare;不使用 PR 分支或 PR SHA 过滤 workflow_run 下游 run,而是按 checks.yml、event=workflow_run、status=all 和上游完成时间定位候选,再用日志中的实际 ref/SHA 或 PR run URL 核验关联;读取失败 job 日志后形成计划并在修改前暂停。"
},
{
"id": "prepare-gate-failure-skips-downstream",
"prompt": "Gitea 上 PR #43 没看到 ChecksPrepare 是 failure;下游 workflow 由 workflow_run 触发且 job 有 success 门禁。请查清楚。",
"expected": "把问题分类为上游门禁未通过,读取 Prepare 的失败 job 日志;检查下游是否为 skipped 或缺失,但不继续寻找下游失败日志,也不把缺失误报为未知代码错误;区分已核验事实与关联推断,给出计划后等待授权。"
} }
] ]
@@ -0,0 +1,78 @@
# Actions API 取证
仅在 `fetch_ci_logs.py` 可运行且 Gitea Actions API 可用时读取本文件。
## CLI 规则
全局选项放在子命令前:`--remote``--base-url``--owner``--repo`
`--use-git-credential``--json``logs``--tail``--out` 放在 job ID 后。
```bash
python <skill-dir>/scripts/fetch_ci_logs.py --remote origin --json runs --limit 20
python <skill-dir>/scripts/fetch_ci_logs.py --json logs <job-id> --tail 80 --out /tmp/job.log
```
`runs` 默认过滤 `failure`;发现触发链时用 `--status all`,查上游门禁结果时也检查
`cancelled``skipped``--sha` 使用完整 SHA。`--workflow checks.yml` 通过 workflow
专用 endpoint 过滤,并在输出中保留 `path`、时间、run number 与 attempt。
## 直接触发
1.`GET /pulls/<pr>` 取得 `head.ref` 与完整 `head.sha`
2. 按目标 workflow、事件和完整 SHA 列 runs。Gitea 的 PR run 可能保存隐藏 PR ref
`head_branch` 不一定等于 `head.ref`;只有在目标实例实测匹配时才追加 `--branch`
3.`path` 确认 workflow,再列 jobs 和失败日志。
```bash
python <skill-dir>/scripts/fetch_ci_logs.py --json runs \
--workflow <workflow.yml> --event pull_request --status all --sha <full-pr-head-sha>
```
## Prepare → workflow_run 链
先读取仓库 workflow 的 `on:` 段,确认上游 workflow 名与下游文件。
1. **定位上游**:按 PR 完整 head SHA 定位 Prepare 候选;用 workflow path、事件和时间
排除无关 run,记录 run ID、attempt、`conclusion``completed_at`。同一 SHA 有多个
run 或 attempt 时逐个保留,不静默任选。
2. **判断门禁**
- `failure`/`cancelled`:列上游 jobs 并读取上游失败日志;分类为“上游门禁未通过”。
下游通常会产生 `skipped` run/job,也可能缺失,不要寻找下游失败日志。
- `success`:继续查对应下游 workflow。
3. **定位下游候选**:按 workflow 文件、`event=workflow_run``status=all` 列 runs,选择
`started_at` 紧随该上游 attempt 完成时间的候选;重跑上游会生成新一组下游 run。
4. **处理缺失**:上游成功却没有下游候选时,核对默认分支上是否存在并启用了下游
workflow、`workflow_run.workflows` 名称是否与上游一致,以及事件/条件是否排除了该
run。仍无下游时,把“应存在但缺失”作为工作流或 Actions 基础设施证据,不虚构下游
日志。
5. **核验关联**:公开 Gitea run API 不暴露父 run ID 或事件 payload。下游 API 的
`head_branch/head_sha` 属于默认分支 run 本身,不是 PR head。必须用 job 日志中实际
checkout/ref/SHA、PR 页给出的 run URL,或 workflow 自己输出的 provenance 核验。
未核验前把关联标为推断。
```bash
python <skill-dir>/scripts/fetch_ci_logs.py --json runs \
--workflow prepare.yml --event pull_request --status all --sha <full-pr-head-sha>
python <skill-dir>/scripts/fetch_ci_logs.py --json runs \
--workflow checks.yml --event workflow_run --status all --limit 30
```
## Jobs 与日志
```bash
python <skill-dir>/scripts/fetch_ci_logs.py --json jobs <run-id>
python <skill-dir>/scripts/fetch_ci_logs.py --json logs <job-id> --tail 80
```
下载失败 job 日志;取消或基础设施场景读取最后执行或被取消的相关 job。上游门禁场景
只取上游证据。完整日志默认写临时文件,或用 `--out` 指向仓库外路径。提取首个可操作
错误块,不输出凭据或整段私有日志。
## 原始 API
- `/api/v1/version` 只证明实例连通;Actions endpoint 仍需实际调用验证
- API 字段或过滤器不明确时读取 `/swagger.v1.json`,不要猜参数语义
- `GET /api/v1/repos/<owner>/<repo>/actions/runs`
- `GET /api/v1/repos/<owner>/<repo>/actions/workflows/<workflow>/runs`
- `GET /api/v1/repos/<owner>/<repo>/actions/runs/<run>/jobs`
- `GET /api/v1/repos/<owner>/<repo>/actions/jobs/<job_id>/logs`
+37
View File
@@ -0,0 +1,37 @@
# 取证回退路径
仅在脚本不可用、Actions API 返回 404,或只有用户日志时读取本文件。
## tea actions
先确认安装、登录和子命令能力;不同 tea 版本不一定提供 Actions
```bash
tea login list
tea actions --help
tea pulls view <pr>
tea actions runs list --status failure
tea actions runs view <run-id>
tea actions runs logs <run-id> --job <job-id>
```
仍需先读取 workflow `on:` 段。遇到 `workflow_run` 链时,先查 Prepare,再按下游
workflow 和时间定位候选;不要用 PR 分支过滤下游 run。
## Legacy web
Gitea 1.21 及更早可能有 Actions web UI,但没有 run/job/log API
1. 打开 `/<owner>/<repo>/actions`,找到目标 run。
2. 打开 `/<owner>/<repo>/actions/runs/<run>`,读取每个 job 的状态。
3. 选择状态为 `failure` 的 job index,再下载
`/<owner>/<repo>/actions/runs/<run>/jobs/<job-index>/logs`
4. 不要默认 index `0`;只有页面不暴露 job 状态时才逐一扫描。
Prepare 失败时读取 Prepare 页和其失败 job;下游可能显示 `skipped` 或根本不出现在
PR checks 中,不要把它误报为“找不到失败 run”。
## 用户粘贴日志
把日志标记为用户提供的证据,并明确:远端 run/job、workflow、SHA 与状态均未核验。
只引用首个可操作错误块;日志不含 run/job 身份时,不把推测写成事实。
+22 -115
View File
@@ -1,6 +1,6 @@
--- ---
name: tsl-syntax-reference name: tsl-syntax-reference
description: 当用户需要编写、修改、审查或解释 TSL/TSF、TS-SQL、Tinysoft/天软脚本或公式,涉及 `.tsl` / `.tsf` 文件,排查 invalid statement 等语法错误,或核对语言与运行时结构规则时使用 description: "当用户需要编写、修改、审查或解释 TSL/TSF、TS-SQL、Tinysoft/天软脚本或公式,涉及 `.tsl` / `.tsf` 文件,排查 invalid statement 等语法错误,或核对语言与运行时结构规则时使用;包括你自认为已经确定的写法。不用于查询 API 签名或天软数据字典字段,也不用于判断解释器、平台或运行方式。"
--- ---
# TSL Syntax Reference # TSL Syntax Reference
@@ -12,67 +12,15 @@ description: 当用户需要编写、修改、审查或解释 TSL/TSF、TS-SQL
下标起点这类"看起来确定"的规则同样要取回。`<this-skill-dir>` 指本 `SKILL.md` 下标起点这类"看起来确定"的规则同样要取回。`<this-skill-dir>` 指本 `SKILL.md`
所在目录。 所在目录。
检索必须分两步:
1. `--query` 只返回紧凑候选和 Section ID,不返回事实正文。
2. 从候选中选择支持当前结论的章节,再用 `--section` 取回唯一事实正文。
一次 `--query` 只覆盖一个语法要素。一段代码涉及多个要素时(如文件模型、函数
骨架、赋值运算符),逐个要素分别执行 `--query`;不用一个要素的取回结果推断另
一个要素。各要素选定的 Section ID 可以合并成一次 `--section` 批量取回。
根据任务意图选择模式:
```bash ```bash
python <this-skill-dir>/scripts/lookup.py --query "命名参数" --mode write python <this-skill-dir>/scripts/lookup.py --help
python <this-skill-dir>/scripts/lookup.py --query "invalid statement 声明区" --mode diagnose
python <this-skill-dir>/scripts/lookup.py --query "数组下标" --mode explain
``` ```
- `write`:编写或修改代码。 构造任一命令前先运行上面的 `--help`。帮助输出独占动作、mode、参数范围、查询词、
- `diagnose`:定位语法错误或错误写法。 弱命中、概念地图、前置章节、Owner Section、结构校验和退出码的命令契约;不要在本
- `explain`:解释语言规则或代码含义。 文件中推断或复用旧参数。检索必须完成 `--query``--section` 两步,只有
`--section` 返回的正文可作为语法事实。`syntax-01-002` 是派生速查;命中其中规则后,
`--limit N`(1..10,默认 5)控制返回的查询候选条数 按紧邻规则的 `Owner Section` 取回真正拥有该事实的专题 Section
`--mode write` 会在查询候选之前额外附加 `Required: yes` 的前置章节(文件模型与
语言核心事实速查)。它们不占 `--limit` 预算,也不是本次查询的命中结果:先按它们
核对文件模型和硬规则,再从 `Required: no` 的候选里挑选支持当前结论的章节。前置
章节在同一任务内取回一次即可;后续 `--query` 重复列出它们时,不必再次 `--section`
其中 `syntax-01-002` 是从专题页逐条派生的强制速查,不是另一份完整事实源;生成结论
仍要取回拥有该规则的专题 Section。
`--query` 输出候选后,必须执行下面这种精确取回。多个已选定的 Section ID 可以
一次传入:
```bash
python <this-skill-dir>/scripts/lookup.py --section "syntax-05-004" "syntax-02-002"
```
只有 `--section` 返回的章节正文可作为本次任务的语法事实来源;候选摘要和概念地图都不能直接支持代码结论。需要补充时改进查询词再次检索。
## 构造查询词
从用户原话中提取错误原文、TSL 标识符和单一语法要素名;保留这些词在用户原话中的
写法,不传完整句,也不自行翻译成猜测术语。脚本会自动处理两类词,不必手工调整:
- 中文口语词自动扩展到 TSL 术语(如"打印"→输出/writeLn"列表"→数组,
"程序慢"→性能分析)。保留提取词的原写法即可。
- `tsl``tsf``tinysoft``debug``please` 这几个词不参与逐词匹配,
但可能整体把查询导向某个专题页。无结果时靠加这类词补救没有用,改为换更具体的
语法要素名。`program` 是真实 TSL 关键字,会参与精确匹配。
## 弱命中视同无匹配
候选里的 `Weak: yes` 表示该节没有任何强信号命中(意图短语、标题、标识符、
标签),只靠正文低分撞词进入候选。弱候选不作为选择对象:
- 候选全部为弱命中时,lookup 返回 rc=2,按无匹配处理——改进查询词重试,
仍全弱即为事实缺口。
- 个别候选为弱命中、其余为强命中时,只从强命中里挑选。
没有 `Weak` 行的候选也不等于相关:词面命中不代表该节支持当前结论,仍要按
Summary 判断后再取回正文核对。
## 代码块必须标注来源 ## 代码块必须标注来源
@@ -125,14 +73,14 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素
**检索缺口** —— `--query` 改进查询词后仍无匹配;lookup 返回非零状态;`--section` **检索缺口** —— `--query` 改进查询词后仍无匹配;lookup 返回非零状态;`--section`
取回的正文没有支持当前结论;`--section` 正文里目标围栏前没有 `代码块身份:` 行。 取回的正文没有支持当前结论;`--section` 正文里目标围栏前没有 `代码块身份:` 行。
**环境缺口** —— 目标运行时版本不明;项目路径、数据结构或运行参数等项目事实缺失; **范围缺口** —— 请求需要 API 签名、数据字典、解释器或平台可用性、运行结果,或其
`tsl-api-reference` 等依赖的 Skill 不可用 它不属于本 Skill 语法事实的专属知识
缺口时的输出由三部分组成,按此顺序: 缺口时的输出由三部分组成,按此顺序:
1. 已取回并可用的事实,只列 Section ID,不复述正文。 1. 已取回并可用的事实,只列 Section ID,不复述正文。
2. 缺失的具体要素,以及为它试过的查询词。 2. 缺失的具体要素,以及为它试过的查询词。
3. 需要用户提供什么,或需要哪个 Skill、哪份项目文档补齐 3. 需要补齐的具体语法事实,或明确说明该请求超出本 Skill 的范围
不输出包含缺口要素的 TSL 代码,注释掉的、标 TODO 的和「仅供参考」的版本同样不 不输出包含缺口要素的 TSL 代码,注释掉的、标 TODO 的和「仅供参考」的版本同样不
输出。一段代码里只要有一个要素没取回,整段都不给出,不交付「其余部分已验证」的 输出。一段代码里只要有一个要素没取回,整段都不给出,不交付「其余部分已验证」的
@@ -145,69 +93,28 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素
- 用候选摘要或概念地图代替章节正文。 - 用候选摘要或概念地图代替章节正文。
- 给出未标注来源的代码块。 - 给出未标注来源的代码块。
## 查询词无从下手时先取概念地图
若需求只有自然语言、无法从中取出 TSL 概念词或错误文本来构造 `--query`,先运行:
```bash
python <this-skill-dir>/scripts/lookup.py --map
```
它列出全部专题的纯文本职责摘要,用于把需求映射到 TSL 特有概念。地图不含可照写事实;从地图取得概念词后,仍按上面的两步执行 `--query``--section`
## 退出码
| 退出码 | 含义 | 处理方式 |
| ------ | --------------------------------------------------------------------- | ------------------------------------------------------- |
| 0 | 查询或取回成功 | 读取输出并继续两步流程 |
| 1 | `--check` 发现参考页问题,或参考目录内没有可校验的参考页 | 修复参考页,或检查 `--references-dir` 路径与 skill 安装 |
| 2 | 参数不合法、`--query` 无匹配或候选全部为弱命中、`--section` ID 不存在 | 见下 |
`--section` ID 不存在时,脚本会在 stderr 打印最接近的若干 Section ID;从中挑选正确 ID 重试,不要凭猜测拼写 ID。一次传入多个 ID 时只要有一个不存在就不返回任何正文,修正后整批重试。
`--query` 无匹配或候选全部为 `Weak: yes` 时改进查询词重试。仍无强命中即为事实缺口:按上面「缺口时停止」处理。
`--map``--query``--section``--check` 共用安装前置检查。参考目录不存在或没有
参考页时,任何动作都以 rc=1 报告路径/安装错误;这不属于事实零命中,不能靠改查询词
重试。
## 事实边界 ## 事实边界
本 Skill 只拥有 TSL/TSF 的语言语法、文件模型、表达式、控制流、对象、运行时语言 本 Skill 只拥有 TSL/TSF 的语言语法、文件模型、表达式、控制流、对象、运行时语言
结构和 TS-SQL 外形。混合请求先逐项拆分,只回答其中的语法部分。 结构和 TS-SQL 外形。混合请求先逐项拆分,只回答其中的语法部分。
API 名称、签名、参数、返回值、平台 scope、解释器可用性和金融取数事实属于 API 名称、签名、参数、返回值、平台 scope、解释器可用性、运行结果和金融取数事实
`tsl-api-reference` skill。准备面向用户交付的 TSL/TSF 代码时,按下面顺序处理: 属于本 Skill 的语法事实。准备代码时,先区分语言关键字、用户定义符号和未验证调用;
对未验证调用不声称签名、行为、可用性或输出。代码交付只要依赖这些未验证事实,就按
1. 先枚举代码中除语言关键字和用户定义符号外的调用标识符 「缺口时停止」处理,不能凭参考页示例中的调用名称补齐
2. 语言结构逐项用本 Skill 取回;每个 builtin/API 都逐项用 `tsl-api-reference` 取回
名称、签名和目标 scope。
3. 任一 API 依赖没有取回时,整段交付代码按「缺口时停止」处理;不能只给语法已查的
半成品,也不能凭语法页示例中的 API 名称补齐。
纯语法说明可以使用明确标为占位的用户定义调用来展示调用位置,而不必为该占位符查 纯语法说明可以使用明确标为占位的用户定义调用来展示调用位置,而不必为该占位符查
API;但不得声称该占位调用的 API 行为或输出,也不得把真实 builtin/API 当成无需核对 API;但不得声称该占位调用的 API 行为或输出,也不得把真实 builtin/API 当成无需核对
的占位符。解释一旦包含真实 API 的签名、可用性、返回值输出,或代码将面向用户 的占位符。解释一旦包含真实调用的签名、可用性、返回值输出,就属于本 Skill 未覆盖
交付,就必须执行上面的 API 取回步骤 的事实,按「缺口时停止」处理
## 运行方式不属于本 Skill ## 执行边界
TSL 的运行方式、解释器路径、平台检测和环境选择都不是本 Skill 的事实。用户询问 本 Skill 只检索和解释语法事实,不执行 TSL 代码,也不判断解释器、平台、运行方式或
如何运行 TSL,或需要实际执行 TSL 时,读取目标文件附近最近的 `AGENTS.md`、项目 项目配置。未经执行的代码只能描述为“语法已取回、运行结果未验证”;不得把参考页中的
脚本或 CI,并严格照其规定执行 输出片段当成当前代码的实跑结果
本 Skill 只能确认语法外形正确,不能确认代码在目标解释器上可运行。给出未经执行的
代码时,据此区分「语法已取回」与「运行时未验证」。用户未要求实际执行时,解释器
不可用是受支持状态,不构成事实缺口,也不要求提供可重放运行证据。
## 维护校验 ## 维护校验
Section ID 来自标题下的显式 `<!-- section-id: ... -->` 元数据,不从标题派生;改标题 维护本 Skill 的参考页、词表或检索器时,只使用随附 `scripts/lookup.py --check` 做结构
时保留原 ID。新增章节时分配新 ID,不重排或复用旧 ID 校验;命令参数和校验范围以随附脚本的 `--help` 为准
改动本 Skill、参考页、`data/` 词表或 lookup 实现后,先运行
`scripts/lookup.py --check` 校验显式 ID、标题层级、quickstart 派生规则、代码块身份、
链接与词表页覆盖。在本 playbook 仓库维护时,再从仓库根目录运行
`python -m unittest test.test_tsl_syntax_reference -v` 校验检索排序、退出码和 CLI
行为。`--check` 只是结构检查,不证明检索排序、事实语义或示例运行结果。口语同义词
和页级意图短语维护在 `data/lexicon.json`,策展纪律见 `data/README.md`
+14 -1
View File
@@ -23,7 +23,16 @@
键写错或参考页改名后 `_intent_score` 会静默返回 0 分,该页失去自然语言 键写错或参考页改名后 `_intent_score` 会静默返回 0 分,该页失去自然语言
入口。`lookup.py --check` 负责拦截页键漂移,行为回归测试负责拦截排序漂移。 入口。`lookup.py --check` 负责拦截页键漂移,行为回归测试负责拦截排序漂移。
## 改动后的校验 ## Skill 维护与校验
Section ID 来自标题下的显式 `<!-- section-id: ... -->` 元数据,不从标题派生。改标题
时保留原 ID;新增章节时分配新 ID,不重排或复用旧 ID。quickstart 派生规则必须与
专题事实逐字一致,并显式给出可由 `--section` 直接取回的 Owner Section。
大型参考页必须把可检索事实切成受控大小的叶子 Section,避免一次精确取回返回数百行。
`--check` 会校验大页的叶子 Section 粒度;拆分时保留已有 ID,把新主题分配给新 ID。
改动本 Skill、参考页、`data/` 词表或 lookup 实现后运行:
```bash ```bash
python skills/tsl-syntax-reference/scripts/lookup.py --check python skills/tsl-syntax-reference/scripts/lookup.py --check
@@ -34,3 +43,7 @@ python -m unittest test.test_tsl_syntax_reference -v
键指向不存在的页、或某页没有自然语言入口都会报错。新增参考页时必须同时 键指向不存在的页、或某页没有自然语言入口都会报错。新增参考页时必须同时
在这里补一条页级意图短语。它只做结构检查,不验证自然语言排序;原始 alias、 在这里补一条页级意图短语。它只做结构检查,不验证自然语言排序;原始 alias、
助词变体、弱命中和已知真实问法由专属测试覆盖。 助词变体、弱命中和已知真实问法由专属测试覆盖。
`--check` 还覆盖显式 ID、标题层级、quickstart 派生与 Owner Section、代码块身份、
本地链接和大页粒度;它不证明事实语义、检索排序或示例运行结果。仓库级 unittest
负责 CLI、排序与退出码行为。
@@ -19,66 +19,82 @@
<!-- quickstart-rule: assignment --> <!-- quickstart-rule: assignment -->
- 普通变量赋值使用 `:=``=` 在普通表达式里用于比较,不用于赋值。 - 普通变量赋值使用 `:=``=` 在普通表达式里用于比较,不用于赋值。
Owner Section`syntax-06-004`
<!-- quickstart-rule: file-choice --> <!-- quickstart-rule: file-choice -->
- 未给后缀时,入口流程、脚本任务或一次性执行逻辑对应 `.tsl`;可复用交付物(函数、过程、类、模块或扩展文件)对应 `.tsf`;只是脚本内部封装函数或类时,仍按 `.tsl` 处理;仍不明确时向用户确认,不要把脚本入口和可复用模块合并成一个猜测文件。 - 未给后缀时,入口流程、脚本任务或一次性执行逻辑对应 `.tsl`;可复用交付物(函数、过程、类、模块或扩展文件)对应 `.tsf`;只是脚本内部封装函数或类时,仍按 `.tsl` 处理;仍不明确时向用户确认,不要把脚本入口和可复用模块合并成一个猜测文件。
Owner Section`syntax-02-002`
<!-- quickstart-rule: tsl-layout --> <!-- quickstart-rule: tsl-layout -->
- `.tsl` 脚本按两段理解:语句区在前并按顺序执行;声明区在后,可放 `function / procedure``type Name = class`。写 `.tsl` 时先写语句区,需要函数、过程或类时把声明区放在语句区之后。 - `.tsl` 脚本按两段理解:语句区在前并按顺序执行;声明区在后,可放 `function / procedure``type Name = class`。写 `.tsl` 时先写语句区,需要函数、过程或类时把声明区放在语句区之后。
Owner Section`syntax-02-002`
<!-- quickstart-rule: tsf-layout --> <!-- quickstart-rule: tsf-layout -->
-`.tsf` 时只写顶层函数 / 过程 / 类声明,或 `unit`;不要写成会直接顺序执行的脚本入口。 -`.tsf` 时只写顶层函数 / 过程 / 类声明,或 `unit`;不要写成会直接顺序执行的脚本入口。
Owner Section`syntax-02-002`
<!-- quickstart-rule: tsf-filename --> <!-- quickstart-rule: tsf-filename -->
- `.tsf` 文件名(不含扩展名)必须与第一个顶层声明同名;第一个声明可以是同名 `function``type Name = class``unit` - `.tsf` 文件名(不含扩展名)必须与第一个顶层声明同名;第一个声明可以是同名 `function``type Name = class``unit`
Owner Section`syntax-02-002`
<!-- quickstart-rule: function-default --> <!-- quickstart-rule: function-default -->
- 用户提示词里的“函数”默认对应 `function`,不要自动改写成 `procedure` - 用户提示词里的“函数”默认对应 `function`,不要自动改写成 `procedure`
Owner Section`syntax-05-002`
<!-- quickstart-rule: procedure-explicit --> <!-- quickstart-rule: procedure-explicit -->
- `procedure Name(...); begin ... end;` 只在用户明确要求 `procedure` / 过程时生成;不要因为没有返回值就自动改用 `procedure` - `procedure Name(...); begin ... end;` 只在用户明确要求 `procedure` / 过程时生成;不要因为没有返回值就自动改用 `procedure`
Owner Section`syntax-05-002`
<!-- quickstart-rule: class-shape --> <!-- quickstart-rule: class-shape -->
- 类定义统一按 `type Name = class ... end;` 写。 - 类定义统一按 `type Name = class ... end;` 写。
Owner Section`syntax-08-002`
<!-- quickstart-rule: object-creation --> <!-- quickstart-rule: object-creation -->
- 普通本地类实例化默认生成 `new ClassName()``createObject("ClassName")``createObject(ClassType)` 只在字符串类名、类类型变量或跨 `unit` 路径场景生成。 - 普通本地类实例化默认生成 `new ClassName()``createObject("ClassName")``createObject(ClassType)` 只在字符串类名、类类型变量或跨 `unit` 路径场景生成。
Owner Section`syntax-08-002`
<!-- quickstart-rule: unit-shape --> <!-- quickstart-rule: unit-shape -->
- `unit` 是完整的顶层主体;常见完整形态是 `unit Name; interface ... implementation ... end.` - `unit` 是完整的顶层主体;常见完整形态是 `unit Name; interface ... implementation ... end.`
Owner Section`syntax-09-002`
<!-- quickstart-rule: unit-default --> <!-- quickstart-rule: unit-default -->
- 如果没有特殊需求,默认优先用完整形态;简写形态只在不需要显式区分 `interface` / `implementation` 时再用。 - 如果没有特殊需求,默认优先用完整形态;简写形态只在不需要显式区分 `interface` / `implementation` 时再用。
Owner Section`syntax-09-002`
<!-- quickstart-rule: named-arguments --> <!-- quickstart-rule: named-arguments -->
- 调用时支持命名参数,写法是 `name: value` - 调用时支持命名参数,写法是 `name: value`
Owner Section`syntax-05-002`
<!-- quickstart-rule: named-argument-order --> <!-- quickstart-rule: named-argument-order -->
- 一旦某次调用里开始使用命名参数,后面的参数就不能再退回位置参数。 - 一旦某次调用里开始使用命名参数,后面的参数就不能再退回位置参数。
Owner Section`syntax-05-002`
<!-- quickstart-rule: string-literal-default --> <!-- quickstart-rule: string-literal-default -->
- 普通单行文本默认使用 `"..."``'...'`;根据内容选择不冲突的引号,必要时再使用转义或连续同类引号。`%% ...%%` 仅用于多行文本、引号非常密集等原始字符串场景;不得因为内容是中文、非 ASCII 或较长就自动改用 `%%` - 普通单行文本默认使用 `"..."``'...'`;根据内容选择不冲突的引号,必要时再使用转义或连续同类引号。`%% ...%%` 仅用于多行文本、引号非常密集等原始字符串场景;不得因为内容是中文、非 ASCII 或较长就自动改用 `%%`
Owner Section`syntax-03-004`
<!-- quickstart-rule: string-prefix-by-type --> <!-- quickstart-rule: string-prefix-by-type -->
- `U``L` 前缀只由目标字符串类型或已确认的 API 编码要求决定,不能因为内容是中文就自动添加;普通中文内容优先直接写成 `"中文内容"` - `U``L` 前缀只由目标字符串类型或已确认的 API 编码要求决定,不能因为内容是中文就自动添加;普通中文内容优先直接写成 `"中文内容"`
Owner Section`syntax-03-004`
<!-- quickstart-rule: index-origins --> <!-- quickstart-rule: index-origins -->
- `array(...)` 既可以写顺序数组,也可以写字符串键表;顺序数组和 `binary(...)` 二进制缓冲区下标从 `0` 开始,字符串下标从 `1` 开始。 - `array(...)` 既可以写顺序数组,也可以写字符串键表;顺序数组和 `binary(...)` 二进制缓冲区下标从 `0` 开始,字符串下标从 `1` 开始。
Owner Section`syntax-03-002`
## 术语对照 ## 术语对照
@@ -121,7 +121,7 @@
- 字符串字面量、拼接与文本边界见 [03_values_and_literals.md](03_values_and_literals.md);数组扩展和矩阵样数据见 [11_matrix_and_collections.md](11_matrix_and_collections.md)。 - 字符串字面量、拼接与文本边界见 [03_values_and_literals.md](03_values_and_literals.md);数组扩展和矩阵样数据见 [11_matrix_and_collections.md](11_matrix_and_collections.md)。
- `{$ifdef ...}` 能力探测见 [15_lexical_structure_and_compile_options.md](15_lexical_structure_and_compile_options.md),不要写成普通业务逻辑。 - `{$ifdef ...}` 能力探测见 [15_lexical_structure_and_compile_options.md](15_lexical_structure_and_compile_options.md),不要写成普通业务逻辑。
### 基础赋值和条件求值 ## 基础赋值和条件求值
<!-- section-id: syntax-06-006 --> <!-- section-id: syntax-06-006 -->
@@ -195,6 +195,12 @@ writeLn(a);
3 3
``` ```
## 基础算术与比较
<!-- section-id: syntax-06-017 -->
<!-- tags: 基础算术, 基础比较, 点前缀比较, 一元倒数, 加减乘除 -->
基础算术: 基础算术:
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -308,6 +314,12 @@ writeLn(dataType(rb));
- 整型 `a` 和实型 `b` 都可以用 `!` 求倒数。 - 整型 `a` 和实型 `b` 都可以用 `!` 求倒数。
- 上面两个 `dataType(...)` 都输出 `1`,表示结果是实型。 - 上面两个 `dataType(...)` 都输出 `1`,表示结果是实型。
## 逻辑与位运算
<!-- section-id: syntax-06-018 -->
<!-- tags: and or not, 逻辑门, 按位运算, 移位运算 -->
逻辑运算: 逻辑运算:
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -364,6 +376,12 @@ writeLn(2 ror 1);
1 1
``` ```
## 复合赋值与字符串运算
<!-- section-id: syntax-06-019 -->
<!-- tags: 复合赋值, 字符串运算, 字符串连接, like 正则, 原地更新 -->
基础算术复合赋值: 基础算术复合赋值:
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -513,6 +531,12 @@ writeLn("abc" like "a%");
因此 `like` 更接近“正则匹配”,不是 SQL 那套 `%` / `_` 通配语义。 因此 `like` 更接近“正则匹配”,不是 SQL 那套 `%` / `_` 通配语义。
## 自增、自减与 if 表达式
<!-- section-id: syntax-06-020 -->
<!-- tags: 自增, 自减, 前置自增, 前置自减, if 表达式 -->
自增与自减: 自增与自减:
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -567,7 +591,7 @@ writeLn(if 2 > 1 then 2 else 1);
`if condition then true_value else false_value` 必须带 `else`,否则不是本页可照写的表达式形态。 `if condition then true_value else false_value` 必须带 `else`,否则不是本页可照写的表达式形态。
### 运算符优先级 ## 运算符优先级
<!-- section-id: syntax-06-015 --> <!-- section-id: syntax-06-015 -->
@@ -614,7 +638,7 @@ writeLn(flag);
稀有矩阵运算符的详细优先级以其专题页为准;不要用本表外推尚未写入正式文档的符号。 稀有矩阵运算符的详细优先级以其专题页为准;不要用本表外推尚未写入正式文档的符号。
### 静态计算表达式 `static` ## 静态计算表达式 `static`
<!-- section-id: syntax-06-016 --> <!-- section-id: syntax-06-016 -->
@@ -674,7 +698,7 @@ end;
这不是类成员的 `static` 字段;类静态成员见 [08_objects_and_classes.md](08_objects_and_classes.md)。缓存结果具有运行时状态,不要用它保存每次调用都必须重新计算的值。 这不是类成员的 `static` 字段;类静态成员见 [08_objects_and_classes.md](08_objects_and_classes.md)。缓存结果具有运行时状态,不要用它保存每次调用都必须重新计算的值。
### 表达式对象 ## 表达式对象
<!-- section-id: syntax-06-007 --> <!-- section-id: syntax-06-007 -->
@@ -756,7 +780,7 @@ writeLn(result_value);
逗号表达式本身可以作为一个普通子表达式继续参与后续运算。 逗号表达式本身可以作为一个普通子表达式继续参与后续运算。
### 空安全访问 ## 空安全访问
<!-- section-id: syntax-06-008 --> <!-- section-id: syntax-06-008 -->
@@ -803,7 +827,7 @@ writeLn(c?.a?.[1] = nil);
不要从这一段外推成所有深度、所有成员/下标组合都可写。 不要从这一段外推成所有深度、所有成员/下标组合都可写。
### 否定形式运算 ## 否定形式运算
<!-- section-id: syntax-06-009 --> <!-- section-id: syntax-06-009 -->
@@ -833,7 +857,7 @@ end;
1 1
``` ```
### 标量链式比较 ## 标量链式比较
<!-- section-id: syntax-06-010 --> <!-- section-id: syntax-06-010 -->
@@ -861,7 +885,7 @@ writeLn(1 :<> 2 :<> 3);
1 1
``` ```
### 矩阵链式比较 ## 矩阵链式比较
<!-- section-id: syntax-06-011 --> <!-- section-id: syntax-06-011 -->
@@ -893,7 +917,7 @@ writeLn(s[2]);
矩阵链式比较会按元素位置分别得到结果数组,并且可以和标量混用。 矩阵链式比较会按元素位置分别得到结果数组,并且可以和标量混用。
### 条件编译探测 ## 条件编译探测
<!-- section-id: syntax-06-012 --> <!-- section-id: syntax-06-012 -->
@@ -68,7 +68,7 @@
- `createObject(...)` 示例只在字符串类名、类类型变量或跨 `unit` 路径场景复制。 - `createObject(...)` 示例只在字符串类名、类类型变量或跨 `unit` 路径场景复制。
- `property` 类型注解只有在已有类型名证据时生成;不要为了完整性发明说明性类型名。 - `property` 类型注解只有在已有类型名证据时生成;不要为了完整性发明说明性类型名。
### 最小类与声明位置 ## 最小类与声明位置
<!-- section-id: syntax-08-004 --> <!-- section-id: syntax-08-004 -->
@@ -180,7 +180,7 @@ writeLn(a);
- 函数体内部声明类会报 `invalid statement` - 函数体内部声明类会报 `invalid statement`
- 在松散语句脚本里,`type MyClass = class ... end;` 之后继续写 `writeLn(a);` 也会报 `invalid statement` - 在松散语句脚本里,`type MyClass = class ... end;` 之后继续写 `writeLn(a);` 也会报 `invalid statement`
### 字段、静态成员、常量与可见性 ## 字段、静态成员、常量与成员访问
<!-- section-id: syntax-08-005 --> <!-- section-id: syntax-08-005 -->
@@ -275,6 +275,12 @@ end;
- `c.Inc()` 输出 `11` - `c.Inc()` 输出 `11`
## 成员可见性与继承访问
<!-- section-id: syntax-08-013 -->
<!-- tags: 成员访问可见性, private protected public, 可见性, 访问权限, 子类访问 -->
可见性 `private` / `protected` / `public` 可见性 `private` / `protected` / `public`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -396,7 +402,7 @@ end;
- 子类里直接调用父类 `private` 方法也会在执行时报错。 - 子类里直接调用父类 `private` 方法也会在执行时报错。
- `private` / `protected` 方法访问也遵循同样边界:外部不能调 `private` / `protected` 方法,子类只能调 `protected` 方法,不能调 `private` 方法。 - `private` / `protected` 方法访问也遵循同样边界:外部不能调 `private` / `protected` 方法,子类只能调 `protected` 方法,不能调 `private` 方法。
### 构造函数边界 ## 构造函数边界
<!-- section-id: syntax-08-006 --> <!-- section-id: syntax-08-006 -->
@@ -427,11 +433,11 @@ end;
- 上述例子里的 `a.value` 输出 `<NIL>`,说明 `private create` 没有执行。 - 上述例子里的 `a.value` 输出 `<NIL>`,说明 `private create` 没有执行。
- `protected create``createObject("A", ...)``createObject(class(A), ...)` 也按同一规则处理;构造函数应保持 `public` - `protected create``createObject("A", ...)``createObject(class(A), ...)` 也按同一规则处理;构造函数应保持 `public`
### 属性、类型注解与类外实现 ## 基础 property 与类型注解
<!-- section-id: syntax-08-007 --> <!-- section-id: syntax-08-007 -->
<!-- tags: property, 读写属性, getter setter, 方法写在类外 --> <!-- tags: property, 读写属性, getter setter -->
基础 `property` 基础 `property`
@@ -530,6 +536,12 @@ abc
abc abc
``` ```
## 类内声明与类外实现
<!-- section-id: syntax-08-014 -->
<!-- tags: 类外实现, 方法写在类外, 类内声明, 外部实现, 类方法实现 -->
类内声明、类外实现的带类型重载方法: 类内声明、类外实现的带类型重载方法:
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -590,6 +602,12 @@ left
left left
``` ```
## 索引与固定 index property
<!-- section-id: syntax-08-015 -->
<!-- tags: 索引 property, 固定索引 property, index property, 固定整数索引, 固定字符串索引 -->
索引型 `property` 索引型 `property`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -695,6 +713,12 @@ end;
- `obj.school` 输出 `math` - `obj.school` 输出 `math`
- `obj.idx("High school")` 也输出 `math` - `obj.idx("High school")` 也输出 `math`
## 参数化 property 与 accessor
<!-- section-id: syntax-08-016 -->
<!-- tags: 参数化 property, property 参数, accessor, getter setter 参数 -->
参数化 `property` 参数化 `property`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -762,7 +786,7 @@ end;
- `write setItem` 这种“写方法接参数个数 + 赋值值”的写法可以通过 - `write setItem` 这种“写方法接参数个数 + 赋值值”的写法可以通过
- 上述例子中的 `obj.Item(2)` 输出 `x` - 上述例子中的 `obj.Item(2)` 输出 `x`
### 对象创建与类类型 ## 对象创建与类类型
<!-- section-id: syntax-08-008 --> <!-- section-id: syntax-08-008 -->
@@ -916,11 +940,11 @@ end;
- `findClass("MathBox").Add(...)` 可以调用类方法 - `findClass("MathBox").Add(...)` 可以调用类方法
- 上述例子依次输出 `7``11` - 上述例子依次输出 `7``11`
### 重载继承与析构 ## 重载继承
<!-- section-id: syntax-08-009 --> <!-- section-id: syntax-08-009 -->
<!-- tags: 继承, 父类子类, 方法重载, 同名不同参, 析构, 对象销毁时, 调用父类方法, 父类同名方法, inherited --> <!-- tags: 继承, 父类子类, 方法重载, 同名不同参, 析构, 对象销毁时, 调用父类方法, 父类同名方法 -->
`overload` 方法: `overload` 方法:
@@ -1036,6 +1060,12 @@ end;
- 当多个父类存在同名方法时,本例优先命中第一个父类 `A` - 当多个父类存在同名方法时,本例优先命中第一个父类 `A`
- 上述例子中的 `obj.Speak()` 输出 `1` - 上述例子中的 `obj.Speak()` 输出 `1`
## 虚方法、覆盖与隐藏
<!-- section-id: syntax-08-017 -->
<!-- tags: 方法隐藏 hide, hide, override, virtual, 覆盖与隐藏 -->
基础 `virtual` / `override` 基础 `virtual` / `override`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -1133,6 +1163,12 @@ end;
- `c.Ask()` 输出 `child``virtual` + `override` 后,父类方法内部的 `Who()` 定向到子类实现 - `c.Ask()` 输出 `child``virtual` + `override` 后,父类方法内部的 `Who()` 定向到子类实现
- 这就是 hide 与 override 的关键区别:hide 只影响直接调用,override 改变了所有经由基类的间接调用 - 这就是 hide 与 override 的关键区别:hide 只影响直接调用,override 改变了所有经由基类的间接调用
## 祖先类调用与类型视图
<!-- section-id: syntax-08-018 -->
<!-- tags: 调用父类 inherited, inherited, Inherited, 祖先类调用, 父类方法, 类型视图 -->
`class(BaseClass, ObjectName).MethodName()` `class(BaseClass, ObjectName).MethodName()`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -1257,6 +1293,12 @@ end;
- `Inherited BaseValue(5)` 可以显式调用父类指定方法 - `Inherited BaseValue(5)` 可以显式调用父类指定方法
- 上述例子依次输出 `6``9` - 上述例子依次输出 `6``9`
## 析构与 self 工厂
<!-- section-id: syntax-08-019 -->
<!-- tags: 析构 destroy, destroy, 对象销毁, self 工厂, self(0), self(1) -->
析构函数 `destroy` 析构函数 `destroy`
代码块身份:可直接照写示例 代码块身份:可直接照写示例
@@ -1323,7 +1365,7 @@ end;
- `self(1)` 返回的对象在这个例子里是 `ChildBox`,因此输出 `1` - `self(1)` 返回的对象在这个例子里是 `ChildBox`,因此输出 `1`
- `self(0)` 返回的对象在这个例子里不是 `ChildBox`,因此输出 `0` - `self(0)` 返回的对象在这个例子里不是 `ChildBox`,因此输出 `0`
### 跨 unit 类路径 ## 跨 unit 类路径
<!-- section-id: syntax-08-010 --> <!-- section-id: syntax-08-010 -->
+153 -29
View File
@@ -37,6 +37,9 @@ QUICKSTART_RULE_RE = re.compile(
QUICKSTART_RULE_PREFIX_RE = re.compile( QUICKSTART_RULE_PREFIX_RE = re.compile(
r"^<!--\s*quickstart-rule\s*:", re.IGNORECASE r"^<!--\s*quickstart-rule\s*:", re.IGNORECASE
) )
OWNER_SECTION_RE = re.compile(
r"^\s+Owner Section`([a-z0-9][a-z0-9._-]*)`\s*$", re.IGNORECASE
)
STRUCTURAL_METADATA_RE = re.compile( STRUCTURAL_METADATA_RE = re.compile(
r"<!--\s*(?:section-id|quickstart-rule)\s*:.*?-->", r"<!--\s*(?:section-id|quickstart-rule)\s*:.*?-->",
re.DOTALL | re.IGNORECASE, re.DOTALL | re.IGNORECASE,
@@ -83,23 +86,24 @@ WRITE_PRELUDE_SECTIONS = (
) )
QUICKSTART_PAGE = "01_quickstart.md" QUICKSTART_PAGE = "01_quickstart.md"
QUICKSTART_SUMMARY_HEADING = "语言核心事实速查" QUICKSTART_SUMMARY_HEADING = "语言核心事实速查"
QUICKSTART_RULE_OWNER_PAGES = { MAX_LEAF_SECTION_LINES = 180
"assignment": "06_expressions_and_operators.md", QUICKSTART_RULE_OWNERS = {
"file-choice": "02_core_model.md", "assignment": ("06_expressions_and_operators.md", "syntax-06-004"),
"tsl-layout": "02_core_model.md", "file-choice": ("02_core_model.md", "syntax-02-002"),
"tsf-layout": "02_core_model.md", "tsl-layout": ("02_core_model.md", "syntax-02-002"),
"tsf-filename": "02_core_model.md", "tsf-layout": ("02_core_model.md", "syntax-02-002"),
"function-default": "05_functions_and_calls.md", "tsf-filename": ("02_core_model.md", "syntax-02-002"),
"procedure-explicit": "05_functions_and_calls.md", "function-default": ("05_functions_and_calls.md", "syntax-05-002"),
"class-shape": "08_objects_and_classes.md", "procedure-explicit": ("05_functions_and_calls.md", "syntax-05-002"),
"object-creation": "08_objects_and_classes.md", "class-shape": ("08_objects_and_classes.md", "syntax-08-002"),
"unit-shape": "09_units_and_scope.md", "object-creation": ("08_objects_and_classes.md", "syntax-08-002"),
"unit-default": "09_units_and_scope.md", "unit-shape": ("09_units_and_scope.md", "syntax-09-002"),
"named-arguments": "05_functions_and_calls.md", "unit-default": ("09_units_and_scope.md", "syntax-09-002"),
"named-argument-order": "05_functions_and_calls.md", "named-arguments": ("05_functions_and_calls.md", "syntax-05-002"),
"string-literal-default": "03_values_and_literals.md", "named-argument-order": ("05_functions_and_calls.md", "syntax-05-002"),
"string-prefix-by-type": "03_values_and_literals.md", "string-literal-default": ("03_values_and_literals.md", "syntax-03-004"),
"index-origins": "03_values_and_literals.md", "string-prefix-by-type": ("03_values_and_literals.md", "syntax-03-004"),
"index-origins": ("03_values_and_literals.md", "syntax-03-002"),
} }
HEADING_TOKEN_SCORE = 12 HEADING_TOKEN_SCORE = 12
@@ -232,6 +236,9 @@ class QuickstartRule:
bullet_line: int bullet_line: int
key: str key: str
text: str text: str
section_id: str | None
owner_section: str | None
owner_line: int | None
class ReferenceInstallationError(RuntimeError): class ReferenceInstallationError(RuntimeError):
@@ -724,6 +731,7 @@ def _quickstart_rule_records(
records: list[QuickstartRule] = [] records: list[QuickstartRule] = []
problems: list[ValidationProblem] = [] problems: list[ValidationProblem] = []
in_fence = False in_fence = False
current_section_id: str | None = None
for index, line in enumerate(lines): for index, line in enumerate(lines):
if FENCE_RE.match(line): if FENCE_RE.match(line):
in_fence = not in_fence in_fence = not in_fence
@@ -731,6 +739,14 @@ def _quickstart_rule_records(
if in_fence: if in_fence:
continue continue
stripped = line.strip() stripped = line.strip()
heading = HEADING_RE.match(line)
if heading is not None and len(heading.group(1)) in (2, 3, 4):
metadata_line = _next_nonblank_line(lines, index + 1)
metadata = lines[metadata_line].strip() if metadata_line is not None else ""
section_id_match = SECTION_ID_RE.fullmatch(metadata)
current_section_id = (
section_id_match.group(1) if section_id_match is not None else None
)
if not QUICKSTART_RULE_PREFIX_RE.match(stripped): if not QUICKSTART_RULE_PREFIX_RE.match(stripped):
continue continue
marker = QUICKSTART_RULE_RE.fullmatch(stripped) marker = QUICKSTART_RULE_RE.fullmatch(stripped)
@@ -750,6 +766,8 @@ def _quickstart_rule_records(
) )
continue continue
text_parts = [lines[bullet_line].strip()] text_parts = [lines[bullet_line].strip()]
owner_section: str | None = None
owner_line: int | None = None
continuation = bullet_line + 1 continuation = bullet_line + 1
while continuation < len(lines): while continuation < len(lines):
line = lines[continuation] line = lines[continuation]
@@ -757,7 +775,21 @@ def _quickstart_rule_records(
break break
if not line.startswith((" ", "\t")): if not line.startswith((" ", "\t")):
break break
text_parts.append(line.strip()) owner_match = OWNER_SECTION_RE.fullmatch(line)
if owner_match is not None:
if owner_section is not None:
problems.append(
ValidationProblem(
page,
continuation + 1,
f"quickstart-rule {marker.group(1)!r} 重复 Owner Section",
)
)
else:
owner_section = owner_match.group(1)
owner_line = continuation + 1
else:
text_parts.append(line.strip())
continuation += 1 continuation += 1
records.append( records.append(
QuickstartRule( QuickstartRule(
@@ -766,6 +798,9 @@ def _quickstart_rule_records(
bullet_line=bullet_line + 1, bullet_line=bullet_line + 1,
key=marker.group(1), key=marker.group(1),
text=" ".join(text_parts), text=" ".join(text_parts),
section_id=current_section_id,
owner_section=owner_section,
owner_line=owner_line,
) )
) )
return records, problems return records, problems
@@ -829,7 +864,13 @@ def _quickstart_rule_problems(
by_key: dict[str, list[QuickstartRule]] = {} by_key: dict[str, list[QuickstartRule]] = {}
for record in records: for record in records:
by_key.setdefault(record.key, []).append(record) by_key.setdefault(record.key, []).append(record)
expected_keys = set(QUICKSTART_RULE_OWNER_PAGES) if require_complete else set() expected_keys = set(QUICKSTART_RULE_OWNERS) if require_complete else set()
declared_section_ids = {
match.group(1)
for lines in pages.values()
for line in lines
if (match := SECTION_ID_RE.fullmatch(line.strip())) is not None
}
for key in sorted(set(by_key) | expected_keys): for key in sorted(set(by_key) | expected_keys):
key_records = by_key.get(key, []) key_records = by_key.get(key, [])
summaries = [record for record in key_records if record.page == quickstart] summaries = [record for record in key_records if record.page == quickstart]
@@ -843,20 +884,57 @@ def _quickstart_rule_problems(
) )
) )
continue continue
expected_page = QUICKSTART_RULE_OWNER_PAGES.get(key) summary = summaries[0]
if expected_page is not None and canonicals[0].page.name != expected_page: canonical = canonicals[0]
expected_owner = QUICKSTART_RULE_OWNERS.get(key)
if expected_owner is not None and canonical.page.name != expected_owner[0]:
problems.append( problems.append(
ValidationProblem( ValidationProblem(
canonicals[0].page, canonical.page,
canonicals[0].line, canonical.line,
f"quickstart-rule {key!r} 的专题 owner 应为 {expected_page}", f"quickstart-rule {key!r} 的专题 owner 应为 {expected_owner[0]}",
) )
) )
if summaries[0].text != canonicals[0].text: if expected_owner is not None and canonical.section_id != expected_owner[1]:
problems.append(
ValidationProblem(
canonical.page,
canonical.line,
f"quickstart-rule {key!r} 的专题 owner Section 应为 "
f"{expected_owner[1]}",
)
)
if summary.owner_section is None:
problems.append( problems.append(
ValidationProblem( ValidationProblem(
quickstart, quickstart,
summaries[0].bullet_line, summary.bullet_line,
f"quickstart-rule {key!r} 缺少 Owner Section",
)
)
elif summary.owner_section not in declared_section_ids:
problems.append(
ValidationProblem(
quickstart,
summary.owner_line or summary.bullet_line,
f"quickstart-rule {key!r} 的 Owner Section 不存在:"
f"{summary.owner_section}",
)
)
elif canonical.section_id is not None and summary.owner_section != canonical.section_id:
problems.append(
ValidationProblem(
quickstart,
summary.owner_line or summary.bullet_line,
f"quickstart-rule {key!r} 的 Owner Section 与专题事实所在 Section "
f"不一致:{summary.owner_section} != {canonical.section_id}",
)
)
if summary.text != canonical.text:
problems.append(
ValidationProblem(
quickstart,
summary.bullet_line,
f"派生摘要规则与专题事实不一致:{key}", f"派生摘要规则与专题事实不一致:{key}",
) )
) )
@@ -867,6 +945,35 @@ def _quickstart_rule_problems(
return problems return problems
def _leaf_section_granularity_problems(
sections: list[Section],
) -> list[ValidationProblem]:
"""Reject exact-retrieval units whose body has grown beyond the safe budget."""
problems: list[ValidationProblem] = []
for section in sections:
has_child = any(
other.page == section.page
and len(other.heading_path) > len(section.heading_path)
and other.heading_path[: len(section.heading_path)]
== section.heading_path
for other in sections
)
if has_child:
continue
line_count = len(section.body.splitlines())
if line_count <= MAX_LEAF_SECTION_LINES:
continue
problems.append(
ValidationProblem(
section.page,
1,
f"叶子 Section 正文超过 {MAX_LEAF_SECTION_LINES} 行:"
f"{section.id}{line_count} 行)",
)
)
return problems
def validate_references( def validate_references(
references_dir: Path = DEFAULT_REFERENCES_DIR, references_dir: Path = DEFAULT_REFERENCES_DIR,
) -> list[ValidationProblem]: ) -> list[ValidationProblem]:
@@ -971,6 +1078,7 @@ def validate_references(
return problems return problems
sections = load_sections(references_dir) sections = load_sections(references_dir)
problems.extend(_tag_problems(sections)) problems.extend(_tag_problems(sections))
problems.extend(_leaf_section_granularity_problems(sections))
ids: dict[str, Section] = {} ids: dict[str, Section] = {}
for section in sections: for section in sections:
if section.id in ids: if section.id in ids:
@@ -1534,9 +1642,24 @@ HELP_EPILOG = """\
选定的 Section ID 可以合并成一次取回 选定的 Section ID 可以合并成一次取回
lookup.py --section "syntax-05-004" "syntax-02-002" lookup.py --section "syntax-05-004" "syntax-02-002"
查询词无从下手时先 --map 把需求映射到 TSL 概念;改动参考页或 data/ 词表后用 --check 校验。 一次 --query 只覆盖一个语法要素;从用户原话提取错误原文、标识符或要素名,
保留原写法但不传完整用户句。多个要素分别查询,再批量 --section。
write 模式额外列出 Required: yes 前置章节,不占 --limit,同一任务只需取回一次。
syntax-01-002 是派生速查;每条规则后的 Owner Section 才是该事实的专题来源。
查询词无从下手时先 --map 把需求映射到 TSL 概念;地图不含可照写事实。
弱命中(候选标 Weak: yes)没有意图/标题/标识符/标签命中,只靠正文低分撞词; 弱命中(候选标 Weak: yes)没有意图/标题/标识符/标签命中,只靠正文低分撞词;
全部候选皆弱时视同无匹配并返回 rc=2,应改进查询词重试而不是从弱候选里挑 混合候选只从强命中里选择;全部候选皆弱时视同无匹配并返回 rc=2。
没有 Weak 行也不保证相关,仍按 Summary 选定后取回正文核对。
--section 中任一 ID 不存在时整批不返回正文,并在 stderr 给出最近的 Section ID。
改动参考页或 data/ 词表后用 --check;它不校验事实语义、排序或示例运行结果。
退出码:
0 动作成功
1 参考目录/结构/安装错误,或 --check 发现问题
2 参数错误、无匹配、全弱命中或 Section ID 不存在
""" """
@@ -1581,7 +1704,8 @@ def _parser() -> argparse.ArgumentParser:
"--check", "--check",
action="store_true", action="store_true",
help="只校验参考页结构、显式 Section ID、代码块身份、本地链接、quickstart " help="只校验参考页结构、显式 Section ID、代码块身份、本地链接、quickstart "
"派生规则词表页覆盖;检索排序由测试套件校验。发现问题时退出码为 1", "派生规则词表页覆盖和叶子 Section 180 行粒度上限;检索排序由测试套件校验。"
"发现问题时退出码为 1",
) )
parser.add_argument( parser.add_argument(
"--mode", "--mode",
+151
View File
@@ -0,0 +1,151 @@
import importlib.util
import io
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from types import SimpleNamespace
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = REPO_ROOT / "skills/gitea-fix-ci/scripts/fetch_ci_logs.py"
SPEC = importlib.util.spec_from_file_location("gitea_fetch_ci_logs", SCRIPT_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class FakeClient:
def __init__(self, payload):
self.payload = payload
self.urls = []
def get_json(self, url):
self.urls.append(url)
return self.payload
class FetchCILogsRunsTests(unittest.TestCase):
def setUp(self):
self.target = MODULE.RepoTarget(
base_url="https://gitea.example.test",
owner="owner",
repo="repo",
)
def test_run_row_preserves_workflow_provenance(self):
sha = "a" * 40
row = MODULE._run_row(
{
"id": 42,
"path": "checks.yml@refs/heads/main",
"display_title": "CI title",
"event": "workflow_run",
"status": "completed",
"conclusion": "failure",
"head_branch": "main",
"head_sha": sha,
"started_at": "2026-08-20T10:00:00+08:00",
"completed_at": "2026-08-20T10:01:00+08:00",
"run_number": 9,
"run_attempt": 2,
}
)
self.assertEqual(row["name"], "checks.yml")
self.assertEqual(row["workflow"], "checks.yml")
self.assertEqual(row["path"], "checks.yml@refs/heads/main")
self.assertEqual(row["title"], "CI title")
self.assertEqual(row["sha"], sha)
self.assertEqual(row["run_number"], 9)
self.assertEqual(row["run_attempt"], 2)
def test_workflow_filter_uses_workflow_endpoint_and_all_omits_status(self):
sha = "b" * 40
args = SimpleNamespace(
workflow="checks.yml",
branch=None,
event="workflow_run",
status="all",
sha=None,
limit=30,
json=True,
)
client = FakeClient(
{
"workflow_runs": [
{
"id": 7,
"path": "checks.yml@refs/heads/main",
"event": "workflow_run",
"conclusion": "success",
"head_sha": sha,
}
]
}
)
stdout = io.StringIO()
with redirect_stdout(stdout):
result = MODULE.cmd_runs(args, self.target, client)
self.assertEqual(result, 0)
self.assertIn("/actions/workflows/checks.yml/runs?", client.urls[0])
self.assertIn("event=workflow_run", client.urls[0])
self.assertIn("limit=30", client.urls[0])
self.assertNotIn("status=", client.urls[0])
self.assertIn(sha, stdout.getvalue())
def test_sha_filter_sends_and_preserves_full_sha(self):
sha = "c" * 40
args = SimpleNamespace(
workflow="prepare.yml",
branch=None,
event="pull_request",
status="all",
sha=sha,
limit=20,
json=True,
)
client = FakeClient(
{
"workflow_runs": [
{
"id": 8,
"path": "prepare.yml@refs/pull/42/head",
"event": "pull_request",
"conclusion": "success",
"head_sha": sha,
}
]
}
)
stdout = io.StringIO()
with redirect_stdout(stdout):
result = MODULE.cmd_runs(args, self.target, client)
self.assertEqual(result, 0)
self.assertIn(f"head_sha={sha}", client.urls[0])
self.assertIn(sha, stdout.getvalue())
def test_sha_filter_rejects_abbreviated_sha(self):
args = SimpleNamespace(
workflow="prepare.yml",
branch=None,
event="pull_request",
status="all",
sha="abc123",
limit=20,
json=True,
)
client = FakeClient({"workflow_runs": []})
with self.assertRaisesRegex(MODULE.ConfigError, "full 40-character"):
MODULE.cmd_runs(args, self.target, client)
self.assertEqual(client.urls, [])
if __name__ == "__main__":
unittest.main()
+172 -7
View File
@@ -1,4 +1,5 @@
import importlib.util import importlib.util
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -275,6 +276,104 @@ class TslSyntaxReferenceTests(unittest.TestCase):
self.assertTrue(any("assignment" in message for message in messages)) 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): def test_missing_references_are_installation_errors_for_every_action(self):
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
missing = Path(temp_dir) / "missing" missing = Path(temp_dir) / "missing"
@@ -317,6 +416,25 @@ class TslSyntaxReferenceTests(unittest.TestCase):
self.assertEqual("syntax-05-008", write_result.matches[0].section.id) self.assertEqual("syntax-05-008", write_result.matches[0].section.id)
self.assertEqual("syntax-02-006", diagnose_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): def test_external_call_queries_retrieve_platform_and_abi_boundaries(self):
cases = { cases = {
"动态库常驻": "syntax-17-012", "动态库常驻": "syntax-17-012",
@@ -438,18 +556,65 @@ class TslSyntaxReferenceTests(unittest.TestCase):
self.assertNotIn("<!-- section-id:", output) self.assertNotIn("<!-- section-id:", output)
self.assertNotIn("<!-- quickstart-rule:", output) self.assertNotIn("<!-- quickstart-rule:", output)
def test_skill_contract_uses_extracted_queries_and_deliverable_api_checks(self): def test_skill_contract_uses_extracted_queries_and_scope_checks(self):
skill = SKILL_PATH.read_text(encoding="utf-8") skill = SKILL_PATH.read_text(encoding="utf-8")
help_text = lookup._parser().format_help() help_result = run_lookup("--help")
self.assertEqual(0, help_result.returncode, help_result.stderr)
help_text = help_result.stdout
self.assertIn("保留这些词在用户原话中", skill) self.assertNotIn("## 构造查询词", skill)
self.assertIn("不传完整句", skill)
self.assertNotIn("用户怎么说就怎么传", skill) self.assertNotIn("用户怎么说就怎么传", skill)
self.assertIn("从用户原话提取", help_text)
self.assertIn("保留原写法但不传完整用户句", help_text)
self.assertIn("不传完整用户句", help_text) self.assertIn("不传完整用户句", help_text)
self.assertIn("面向用户交付的 TSL/TSF 代码", skill) self.assertIn("代码交付只要依赖这些未验证事实", skill)
self.assertIn("每个 builtin/API", skill) self.assertIn("不声称签名、行为、可用性或输出", skill)
self.assertIn("纯语法说明", skill) self.assertIn("纯语法说明", skill)
self.assertIn("不得声称该占位调用的 API 行为或输出", 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): def test_ci_runs_syntax_structure_and_format_gates(self):
workflow = CI_PATH.read_text(encoding="utf-8") workflow = CI_PATH.read_text(encoding="utf-8")