diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a689ea..13f4623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- **`hooks/release-check` now reads the `$schema` a manifest declares, which nothing had ever inspected.** Two manifests point at a JSON Schema (`.claude-plugin/marketplace.json`, `.claude-plugin/plugin.json`) and measured 2026-10-29 **both of those URLs resolve to 404**: each `301`s to `www.anthropic.com/...` and stops there. Six candidate replacements were probed and none returned a real schema - `docs.anthropic.com/claude-code/plugin.schema.json` answers 200 with a 296 KB HTML error page. The assertion therefore judges only what the repository can decide by itself: a declared `$schema` must carry a non-empty value, must be an absolute `https` URL, may appear once per file (a duplicate key's winner is parser-dependent), and the set of files declaring one is pinned so adding or removing a declaration is a visible event rather than a silent edit. **Reachability is deliberately not checked**: every other check in this file is offline, and a `curl` here would redden on a DNS blip while still being wrong the moment the remote moved - which is exactly how these two went unnoticed. The pin is enforced only once something declares, so a repository that has not adopted `$schema` is not told it dropped one it never had. +- **`hooks/smoke-test` covers the new assertion, in the section that already drives `release-check` from a fixture.** It has to live there rather than only in the check itself: `hooks/*` is in the smoke path set, while the `release` job runs only when a manifest, `CHANGELOG.md` or a README changes - so a change to `release-check` alone would never execute it in CI. Verified by mutation: removing the empty-value branch, the `https` test, the duplicate count, and the pin each reddens its own named case, and restoring the file byte-identically returns the suite to `ALL SMOKE PASSED`. - **A Step counts as closed only when it says `- Status: done`, so a word the gate did not know can no longer archive a Plan that is not finished.** `hooks/completion-gate` read the field as a blacklist — `pending`, `in_progress`, `blocked` were named and everything else was finished — so the word `completed`, a misspelling, an empty value, and a Step with no `- Status:` line at all all passed. Measured 2026-10-27 by driving the gate directly: `completed`, `complted`, `skipped`, `wip`, `DONE`, an empty value and a missing line each returned exit 0 and "finished". `hooks/task progress` has only ever accepted `pending|in_progress|done|blocked`, so those spellings could not have been written by the tool — they are the result of hand edits made precisely because the two sides disagreed. Closed is now a whitelist of one word, and it is the same word the writer accepts. The block is located by its `### Step ` heading anywhere in the Plan rather than only under `## Steps`, because that is how `task progress` finds the block it edits; a reader that skipped the other sections would see a different set of Steps than the writer changes. **Knowingly still open, and pinned by an assertion so it stays a decision**: a Plan with no `### Step` block at all is not refused, because "must a Plan have Steps" is a separate rule this change does not make. - **The Windows smoke suite no longer stops dead when its stdin is open and silent.** `hooks/smoke-test-windows.ps1` called `run-hook.cmd session-start` with no stdin of its own, so the hook inherited the runner's — and `session-start` reads its event with `cat`. From a terminal that read never reaches EOF, so the suite printed `WINDOWS LAUNCHER ARGS PASSED` and then hung indefinitely; a red assertion reached a person as an unexplained freeze rather than a message. The cause is the inherited stdin, not the throw the report named: probing with `throw` at eight positions, every one before that call printed and exited 1 in under 14s, while every one after it hung — the boundary is exactly `Invoke-SessionStart 'pr'`. The bash suite already carried this lesson for its own `session-start` call (`hooks/smoke-test`, `< /dev/null`); the PowerShell one is now given an empty event the same way, through `cmd /c "... < nul"` rather than `'' |`. **An empty PowerShell string is not an empty stdin** — it is encoded and written as a line, and which line depends on `$OutputEncoding`: measured 0x0d 0x0a on the author's machine and a BOM on the CI runner, where `session-start` rejected it as invalid JSON and the suite failed ([run 40712165629](https://github.com/hkwuks/TaskFlow/actions/runs/40712165629)). A redirection is zero bytes on every host. Measured 2026-10-28: before, still silent at 180s and killed; after, four `PASSED` banners in 27s. **CI could never have caught it** — a runner's stdin is empty, so `cat` sees EOF at once and every windows-latest job has been green regardless. - **The Windows suite now checks this itself, by re-running with an inherited open stdin and failing if it does not finish.** The check has to live there rather than in `hooks/smoke-test`, because that suite does not run on the Windows runner at all — the workflow gates it on `runner.os != 'Windows'` — so an assertion written there is skipped by every CI job, and the one platform that can reproduce the defect is the one platform that never executes the check. `hooks/smoke-test-windows.ps1` is the only TaskFlow check a Windows runner runs, which makes it the only place this can be caught automatically. It spawns itself with `RedirectStandardInput` set and never written: a pipe that is open and silent, the shape a terminal gives a command a person typed and the shape CI does not have. `TASKFLOW_STDIN_SELFCHECK` bounds the recursion to one child. Verified by mutation: with the `session-start` call reverted to inheriting, the child is killed at the 90s deadline and the suite fails naming the read; with `< nul` in place it exits 0 in about 28s, and the whole suite takes 51s. diff --git a/TaskFlowDocs/2026-10-29-schema-check/old/v1/prd.md b/TaskFlowDocs/2026-10-29-schema-check/old/v1/prd.md new file mode 100644 index 0000000..3a3e7b5 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-schema-check/old/v1/prd.md @@ -0,0 +1,110 @@ +# 断言插件 manifest 声明的 `$schema` 可用 +> Task version: v1 +> Status: in_progress + +## Goal + +仓库里有两个 manifest 用 `$schema` 指向一份 JSON Schema,用来让编辑器、校验器和阅读者知道这份 manifest 该按什么形状理解。这两个指针现在**都指向 404**,而仓库里没有任何检查发现这件事。本任务给 `hooks/release-check` 加一段断言,让「声明了 `$schema` 但指向不成立」和「同类 manifest 里有的声明、有的不声明」都变成一次可见的失败,而不是等到某个工具静默跳过校验才被发现。 + +## Background / Confirmed Facts + +以下均为 2026-10-29 在本仓库实测。 + +### 声明现状(5 个 manifest,2 个声明) + +| 文件 | `$schema` | +|---|---| +| `.claude-plugin/marketplace.json` | `https://anthropic.com/claude-code/marketplace.schema.json` | +| `.claude-plugin/plugin.json` | `https://anthropic.com/claude-code/plugin.schema.json` | +| `.codebuddy-plugin/marketplace.json` | 无 | +| `.codebuddy-plugin/plugin.json` | 无 | +| `.codex-plugin/plugin.json` | 无 | +| `package.json`(dsh) | 无 | + +### 两个声明都指向 404(**这是当前正在发生的事实,不是风险**) + +``` +https://anthropic.com/claude-code/marketplace.schema.json + 301 -> https://www.anthropic.com/claude-code/marketplace.schema.json -> 404 +https://anthropic.com/claude-code/plugin.schema.json + 301 -> https://www.anthropic.com/claude-code/plugin.schema.json -> 404 +``` + +### 找不到能替代的正确地址 + +2026-10-29 逐个试探,没有一个返回真正的 JSON Schema: + +| 候选 | 结果 | +|---|---| +| `json.schemastore.org/claude-code-plugin.json` | 404 | +| `code.claude.com/docs/schema/plugin.json` | 404 | +| `docs.anthropic.com/claude-code/plugin.schema.json` | 200,但内容是 **296 KB 的 HTML 错误页**,不是 schema | + +因此**本任务不改 URL**:改地址只能靠猜,而猜出来的地址会让断言通过却仍然指向错误的东西 —— 那比现在更糟,因为失败的信号被伪装成了成功。 + +### 现有检查一个都没读过 `$schema` + +`hooks/release-check`、`hooks/smoke-test`、`RELEASE.md` 三处 grep `schema` 零命中。`release-check` 已经会读这 5 个 manifest,但只读 `version` 字段。 + +### 仓库的检查不带网络 + +`hooks/release-check` 与 `hooks/repository-check` 里没有任何 `curl`/`wget`;`repository-check` 唯一出现 `http` 的地方是把 URL 里的凭据抹掉再打印。断言若联网取 schema,会把一个确定性检查变成依赖外网可用性的检查。 + +## Requirements + +- **R1 只断言仓库内可验证的事实,不联网。** 断言不得发出任何网络请求。理由是上一条:联网会让 `release-check` 在断网、DNS 抖动或对方限流时假红,而它目前是一个完全离线的检查。 +- **R2 声明了就不得为空。** `"$schema": ""`、`"$schema": null`、只有键没有值,都算失败。 +- **R3 声明了就必须是绝对 HTTPS URL。** 相对路径、`http://`、以 `//` 开头的协议相对形式,都算失败。 +- **R4 同一文件里只能有一个 `$schema` 键。** 重复键在 JSON 里是未定义行为,解析器取第一个还是最后一个取决于实现;这里不猜。 +- **R5 五个 manifest 要么都声明、要么都不声明,或者差异被显式记录。** 现状是 2/5 声明,而这个差异没有任何地方说明它是有意的。断言必须让「有人加了第三个」和「有人删掉了一个」都留下痕迹,而不是静默通过。 +- **R6 断言放进 `hooks/release-check`**,与既有的版本一致性检查并列,复用它的 `needs`/`STATUS:` 退出约定(`needs=1` → `STATUS: needs-user-input`,退出 2)。 +- **R7 不修改任何 manifest。** 本任务只加检查。两个 404 作为断言第一次运行时的**已知失败**记录下来,修复留给后续任务。 +- **R8 文档同步**:`CHANGELOG.md` 增一条;若 `hooks/README.md` 或 `skills/taskflow/references/runtime.md` 描述了 `release-check` 的检查范围,一并更新。 +- **R9 让 CI 真的执行到这段断言。** `hooks/smoke-test` **已经**在夹具仓库上调用 `hooks/release-check`(`:4488` 起的 `== release-check catches version drift and a bad marketplace pin ==` 一节,模式是「构造合法的 → 必须 pass,再轻量破坏一个字段 → 必须点名失败」)。新的 `$schema` 用例**并入这一节**,不新建机制、不改 `tools/workflow-affected`。这同时解决两件事:`hooks/*` 属于 smoke 集合,所以改 `release-check` 时 CI 一定会执行到它;夹具仓库又使真实仓库的两个 404 不会把 smoke 拖红。 + +## Acceptance Criteria + +- **A1** 对当前仓库运行 `bash hooks/release-check .` → 退出码 **2**,`STATUS: needs-user-input`,且输出里逐条点名两个 404 声明所在的文件与 URL。 +- **A2** 把 `.claude-plugin/plugin.json` 的 `$schema` 改成 `""` → 断言报出「声明为空」,且是那条具体的失败信息,不是笼统的失败。 +- **A3** 把某一处 `$schema` 改成 `http://example.com/x.json` → 断言报出「不是 HTTPS」。 +- **A4** 把 `.claude-plugin/plugin.json` 的 `$schema` 整行删掉 → 断言报出「声明数量与记录的不一致」,并说明现在是几个声明、记录的是几个。 +- **A5** 不联网也能得到 A1–A4 的全部结果:断网状态下跑同一条命令,结论不变。 +- **A6** 断言**只在** `$schema` 相关事实变化时改变结论:把 workspace 恢复到当前 `main` 的 manifest 内容后,结论仍为 A1 描述的失败;仓库里其他任何字段变化都不影响它。 +- **A7** `bash hooks/smoke-test` → `ALL SMOKE PASSED`(回归)。 +- **A8** `git diff --check` 干净。 +- **A9** 并进 `:4488` 那一节后,夹具里**不含**任何 `$schema` 时 `release-check` 仍 `STATUS: pass`(证明新检查不会把没有声明的仓库判红)。 +- **A10** 夹具里加一个合法的 `$schema`(绝对 HTTPS URL)时仍 `STATUS: pass`;再把它改成 `""` 或 `http://…` 时退出 2 且输出点名该文件与原因。 +- **A11** `hooks/smoke-test` 不读真实 manifest —— 真实仓库当前两个 404 声明不会让它变红。这一点由后果证明:把真实 manifest 原样留在仓库里跑 `bash hooks/smoke-test`,结果是 `ALL SMOKE PASSED`。 + +## In Scope + +- `hooks/release-check` 新增一段 `$schema` 检查。 +- `hooks/smoke-test` 里为该检查新增的断言(若该套件已有 `release-check` 的测试段落,则并入该段),以及一段在夹具仓库上调用 `hooks/release-check` 的回归(R9)。 +- `CHANGELOG.md`。 +- 本任务的 TaskFlow 文档。 + +## Out of Scope + +- **修那两个 404 URL。** 用户已于 2026-10-29 裁定:只加断言,先不动 URL。找不到可验证的正确地址时改 URL 只能靠猜。 +- **给另外三个 manifest 补 `$schema`。** 这是不是有意的差异,本任务不下结论(见 Open Questions)。 +- **联网校验 schema 可达性。** 见 R1。 +- **校验 manifest 内容是否符合 schema。** 断言只检查声明本身,不检查 manifest 是否符合它声明的 schema —— 那需要真的取到 schema。 +- **`hooks/repository-check`。** 它是仓库结构检查,不是发布检查。 +- 前半「三宿主手工 catalog 校验改条件式」:前提已被 `2026-09-19-release-flow-exception` 搬走,`RELEASE.md` 里已无该步骤,无对象可改。 + +## Risks / Deferred Items + +- **A1 会让 `release-check` 从「通过」变成「需要用户输入」,这是有意的。** 一个当前就坏的指针应当让检查红起来。若合并后立刻阻塞了发布流程,那正是它该做的事;但要提前说明,因为它会让 main 上的 `release-check` 立刻变成非 pass。 +- **A5 的断网验证**在本机可用 `unshare -n` 或临时 `HTTP_PROXY=http://127.0.0.1:1` 模拟;若宿主不支持,退化为代码审查(断言里无网络调用)+ 记录该限制。 +- **R5 的「差异被显式记录」有两种实现**:断言里硬编码「允许的声明清单」,或读一个仓库内的记录文件。本任务倾向硬编码 + 注释说明理由(见 Plan),因为多一个文件就是多一个会漂移的东西。 +- **两个 404 会一直红到有人决定怎么修。** 如果长期不修,检查会变成噪音,进而被忽略 —— 这是这类断言的典型死法。缓解办法是把修复 URL 作为本任务的 Follow-up 明确列出,而不是留着。 + +## Open Questions + +- **另外三个 manifest 为什么没有 `$schema`?** `.codebuddy-plugin/*` 和 `.codex-plugin/plugin.json` 可能本来就没有公开 schema,`package.json` 是 npm 的、有自己的约定。这一点本任务不猜;如果查明是「本来就没有」,那 R5 的正确形态是「记录哪些可以有、哪些不该有」,而不是「要求五个都有」。 +- **`release-check` 由谁运行、什么时候运行?—— 已查明(2026-10-29)。** CI 会跑它(`.github/workflows/hooks.yml:101-111`,`bash hooks/release-check .`),但只在 `release` 分类为真时:`.claude-plugin/*`、`.codex-plugin/*`、`.codebuddy-plugin/*`、`package.json`、`CHANGELOG.md`、`README.md`、`README.zh-CN.md` 有改动(`tools/workflow-affected` 的 `release=1` 分支)。 + + **这带来一个必须解决的后果**:本任务改的是 `hooks/release-check`,而 `hooks/*` 落在 **`smoke` 集合**、不在 `release` 集合,所以这一轮 CI 会跑 smoke,**不会跑 `release-check` 自己**。断言写进去之后,CI 里没有任何一次执行会验证它。 + + **用户裁定(2026-10-29):让 `hooks/smoke-test` 直接调用 `hooks/release-check`。** 理由:`hooks/*` 属于 smoke 集合,每次改 hook 都会跑 smoke,所以改 `release-check` 时 CI 一定会执行到新断言;把 `release-check` 加进 release 触发集只在它自己被改时才生效,覆盖不到「新断言是否真的工作」。 + diff --git a/TaskFlowDocs/2026-10-29-schema-check/old/v1/version.md b/TaskFlowDocs/2026-10-29-schema-check/old/v1/version.md new file mode 100644 index 0000000..b3d8e4f --- /dev/null +++ b/TaskFlowDocs/2026-10-29-schema-check/old/v1/version.md @@ -0,0 +1,22 @@ +# Archived Version v1 — Plan — 断言 manifest 的 `$schema` 声明 +> Task: 2026-10-29-schema-check +> Version: v1 +> Status: superseded +> Archived: 2026-10-29 20:47 +0800 +> Superseded by: v2 +> Archive mode: file + +## Change Summary + +v1 的 R1 与 A1 自相矛盾:R1 要求断言不联网,A1 要求它「点名两个 404」。离线的断言无法知道某个 URL 是否 404 —— 这不是实现没做到,是验收标准本身写不成立。 + +实现到 Step 1 时测出来:按 R1 实现的检查在当前仓库返回 `STATUS: pass` 退出 0,而 A1 要求的是退出 2。两者不可能同时满足。 + +v2 删掉 A1 里「点名 404」那半,A1 改为只断言声明本身的合法性(非空、绝对 HTTPS、无重复键、pin 不变)。两个 404 移入 Follow-ups,由人处理,不在 CI 里检测。R1 不变。 + + +## Archived Materials +- prd.md + +## Restore +Read the archived v1 core docs from a new temporary restore root. Do not overwrite the current task root. diff --git a/TaskFlowDocs/2026-10-29-schema-check/plan.md b/TaskFlowDocs/2026-10-29-schema-check/plan.md new file mode 100644 index 0000000..8568a17 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-schema-check/plan.md @@ -0,0 +1,138 @@ +# Plan — 断言 manifest 的 `$schema` 声明 +> Task version: v2 +> Status: in_progress + +No spec required — small, self-contained task. + +## Reference Pointers + +- `hooks/release-check:20-31` — 它读的九个文件清单;新检查的判断范围。 +- `hooks/release-check:221-226` — `needs=1` → `STATUS: needs-user-input` → 退出 2 的既有约定。 +- `hooks/smoke-test:4488` — `== release-check catches version drift and a bad marketplace pin ==`,新用例并入这里。 +- `tools/workflow-affected:87-89` — `release=1` 的路径集合;`hooks/*` 不在其中,这就是 R9 的由来。 +- `.claude-plugin/marketplace.json:2`、`.claude-plugin/plugin.json:2` — 当前两个声明。 + +## Related Tasks + +- 来源:`TaskFlowDocs/todo.md` 的 `TF-20260928-3910fb`(re-scoped 为 `TF-20261029-218fdf`)。 +- 前置已交付:`achieved/2026-09-19-release-flow-exception`(把本条目前半的「三宿主手工校验」前提搬走)。 + +## Skills / Tools Used + +- [PRD] `agent-skills:spec-driven-development` — purpose: 把一条前提已过期的 todo 收敛成可验收的需求;outcome: succeeded; incorporated: 去掉已无对象的前半,只做 `$schema` 断言;v2 又删掉了与 R1 互斥的「点名 404」验收项,并把「断言必须能被 CI 执行」写进 R9。 +- [Plan] `agent-skills:planning-and-task-breakdown` — purpose: 决定两处改动的先后与验证顺序;outcome: succeeded; incorporated: 先写检查再写用例(用例的期望消息以检查实际打印的为准),变异验证放在最后。 +- [Test] `Unaided — no capability applied to this phase; considered: testing strategy` — 断言形态沿用 `hooks/smoke-test:4488` 既有夹具模式,没有引入外部能力;三次变异验证由本任务手工执行。 + +## Preconditions + +- [x] 已读 `CONTRIBUTING.md`(分支/worktree 规则)、`CODE_STYLE.md`(脚本风格)。 +- [x] 隔离:工作树 `.worktrees/schema-check`,分支 `chore/schema-check`,基线 `main` @ `ba3d9e7`。 +- [x] 已实测:两个声明都 404;6 个候选地址无一可用;现有检查零处读 `schema`;CI 的 `release` job 只在 manifest/CHANGELOG/README 变化时运行。 + +## Approval + +- Status: approved +- Approved by: user +- Approved at: 2026-10-29 20:50 +0800 +- Approved version: v2 +- Approved scope: PRD / Plan + +## Steps + +### Step 1 — 在 `release-check` 里加 `$schema` 断言 + +- Goal: 让「声明了但为空 / 不是绝对 HTTPS / 同一文件重复声明 / 声明集合与 pin 不符」四种情况各自报出具体原因。**不检测 URL 是否可达**(R1 不联网,v2 起 A1 也不再要求)。 +- Dependencies: 无。 +- Files: `hooks/release-check`。 +- Implementation checklist: + - [x] 新增一段,遍历五个 manifest,逐条实现 R2–R5。 + - [x] 每种失败打印自己的消息并置 `needs=1`。 + - [x] 「数量与记录不符」用脚本内硬编码的清单(两个文件 + 各自 URL)+ 注释说明为什么不读外部文件。 + - [x] 不引入任何网络调用。 +- Acceptance: PRD v2 的 A1–A6。A1 期望 `STATUS: pass` 退出 0 —— 当前两个声明按本任务的规则是合法的。 +- Verification: 手工构造四种坏法各跑一次,确认消息与退出码。 +- Rollback: 单段代码,`git revert` 即恢复。 +- Status: done + +### Step 2 — 把用例并进 `smoke-test:4488` 那一节 + +- Goal: 让新断言在 CI 里真的被执行,且不被真实仓库的两个 404 拖红。 +- Dependencies: Step 1。 +- Files: `hooks/smoke-test`。 +- Implementation checklist: + - [x] 在既有 `rel` 夹具上加三个 case:无声明 → pass;合法声明 → pass;空值/非 HTTPS → 退出 2 且点名。 + - [x] 复用该节已有写法(`bash "$HERE/release-check" "$rel"` + `grep -q` 消息片段)。 + - [x] 不在真实仓库上调用 `release-check`。 +- Acceptance: PRD 的 A9–A11。 +- Verification: `bash hooks/smoke-test` → `ALL SMOKE PASSED`。 +- Rollback: 删除新增 case。 +- Status: done + +### Step 3 — 变异验证与文档 + +- Goal: 证明 Step 2 的用例真的在检测 Step 1 的检查;补 `CHANGELOG.md`。 +- Dependencies: Step 1、Step 2。 +- Files: `hooks/release-check`(临时破坏,验证后逐字节恢复)、`CHANGELOG.md`。 +- Implementation checklist: + - [x] 依次破坏 R2 空值、R3 非 HTTPS、R4 重复键、R5 数量,每次确认对应用例变红并指名。 + - [x] 每次逐字节恢复,`diff -q` 确认。 + - [x] `CHANGELOG.md` 增一条,说明这是「发现既有问题」而非「预防未来问题」:两个声明今天就 404。 +- Acceptance: 四次变异各自红在**对应**的用例上。 +- Verification: 四次结果写进 `## Verification / Review`。 +- Rollback: 无需。 +- Status: done + +## Checkpoints + +- Step 1 后:`bash hooks/release-check .` 在当前仓库返回退出 2 并点名两个 404 声明。 +- Step 2 后:`bash hooks/smoke-test` → `ALL SMOKE PASSED`。 +- Step 3 后:四次变异各自红在对应用例,恢复后再次全绿。 + +## Verification / Review + +- 2026-10-29 Step 3: Four mutations each reddened its own case; restored byte-identically + +- 2026-10-29 Step 2: Six schema cases added to the existing release-check fixture section + +- 2026-10-29 Step 1: release-check reports pass on the real repo; four format rules implemented + +### 实测(2026-10-29) + +| 检查 | 结果 | +|---|---| +| `bash hooks/release-check .`(真实仓库) | `STATUS: pass`,退出 0。符合 PRD v2 的 A1:两个声明都非空、都是绝对 HTTPS、无重复键、都在 pin 清单里。 | +| `bash hooks/smoke-test` | `ALL SMOKE PASSED` | +| `git diff --check` | 干净(见收尾) | + +### 变异验证(四次,各自红在对应用例) + +| 变异 | smoke 输出 | +|---|---| +| 删掉空值分支 | `FAIL empty $schema accepted` | +| 去掉 https 判定 | `FAIL non-https $schema accepted: http://example.invalid/x.schema.json` | +| 不数重复键 | `FAIL duplicate $schema accepted` | +| 不执行 pin | `FAIL a pinned declaration was dropped silently` | +| 逐字节恢复后 | `ALL SMOKE PASSED`,`diff -q` 一致 | + +### 实现过程中被自己抓到的一个缺陷 + +第一版用提取出的值判断「有没有声明」(`[ "$count" -eq 0 ] && continue`),于是 `"$schema": ""` 在空值检查之前就被当成「没有声明」跳过了——**空值这个用例本身永远不会红**。改成先 `grep -q` 键是否存在、再分别报「空字符串」与「非字符串值」(`null`、数字),两次消息不同,因为它们是两个不同的错误。 + +### 与 v1 的差别 + +v1 的 A1 要求点名两个 404,与 R1(不联网)互斥,实测时表现为 `release-check` 退出 0 而 A1 要求 2。v2 已改(见 `old/v1/version.md`)。**后果**:本任务合并后两个 404 依然不会被任何自动化发现,只记在 Follow-ups 里。 + +## Change Log + +- 2026-10-29 — Step 1–3 完成:`hooks/release-check` 新增 `$schema` 段(值/pin/重复/协议四项),`hooks/smoke-test` 在既有 release-check 夹具段新增一整节六个用例,`CHANGELOG.md` 记两条。四次变异各自红在对应断言。 +- 2026-10-29 — v1 → v2:R1(不联网)与 A1(点名两个 404)矛盾,离线断言无法判定可达性。A1 改为只断言声明合法性,期望 `pass`;404 移入 Follow-ups。原因记录在 `old/v1/version.md`。 + +## Follow-ups + +- **修那两个 404 URL。** 本任务只加断言不改 URL(用户 2026-10-29 裁定)。 +- **另外三个 manifest 是否需要 `$schema`。** `.codebuddy-plugin/*`、`.codex-plugin/plugin.json`、`package.json` 都不声明;查明是「本来就没有」还是「漏了」后再定 R5 的正确形态。 +- **`release-check` 在 CI 里只在 manifest 变化时运行。** 本任务通过 smoke 让它每次改 hook 都跑;若将来两者解耦,这条路会断,届时要回到 `tools/workflow-affected`。 + +## Version History + +- v1 — planning. diff --git a/TaskFlowDocs/2026-10-29-schema-check/prd.md b/TaskFlowDocs/2026-10-29-schema-check/prd.md new file mode 100644 index 0000000..a0040c8 --- /dev/null +++ b/TaskFlowDocs/2026-10-29-schema-check/prd.md @@ -0,0 +1,112 @@ +# 断言插件 manifest 声明的 `$schema` 可用 +> Task version: v2 +> Status: in_progress + +## Goal + +仓库里有两个 manifest 用 `$schema` 指向一份 JSON Schema,用来让编辑器、校验器和阅读者知道这份 manifest 该按什么形状理解。这两个指针现在**都指向 404**,而仓库里没有任何检查发现这件事。本任务给 `hooks/release-check` 加一段断言,让「声明了 `$schema` 但声明本身不成立」和「同类 manifest 里有的声明、有的不声明」变成一次可见的失败。 + +**本任务不检测 URL 是否可达**(见 R1 与 A1):离线的断言无法判定远端返回什么,也不该为此变成联网检查。两个 404 由 Follow-ups 里的人工事项处理。 + +## Background / Confirmed Facts + +以下均为 2026-10-29 在本仓库实测。 + +### 声明现状(5 个 manifest,2 个声明) + +| 文件 | `$schema` | +|---|---| +| `.claude-plugin/marketplace.json` | `https://anthropic.com/claude-code/marketplace.schema.json` | +| `.claude-plugin/plugin.json` | `https://anthropic.com/claude-code/plugin.schema.json` | +| `.codebuddy-plugin/marketplace.json` | 无 | +| `.codebuddy-plugin/plugin.json` | 无 | +| `.codex-plugin/plugin.json` | 无 | +| `package.json`(dsh) | 无 | + +### 两个声明都指向 404(**这是当前正在发生的事实,不是风险**) + +``` +https://anthropic.com/claude-code/marketplace.schema.json + 301 -> https://www.anthropic.com/claude-code/marketplace.schema.json -> 404 +https://anthropic.com/claude-code/plugin.schema.json + 301 -> https://www.anthropic.com/claude-code/plugin.schema.json -> 404 +``` + +### 找不到能替代的正确地址 + +2026-10-29 逐个试探,没有一个返回真正的 JSON Schema: + +| 候选 | 结果 | +|---|---| +| `json.schemastore.org/claude-code-plugin.json` | 404 | +| `code.claude.com/docs/schema/plugin.json` | 404 | +| `docs.anthropic.com/claude-code/plugin.schema.json` | 200,但内容是 **296 KB 的 HTML 错误页**,不是 schema | + +因此**本任务不改 URL**:改地址只能靠猜,而猜出来的地址会让断言通过却仍然指向错误的东西 —— 那比现在更糟,因为失败的信号被伪装成了成功。 + +### 现有检查一个都没读过 `$schema` + +`hooks/release-check`、`hooks/smoke-test`、`RELEASE.md` 三处 grep `schema` 零命中。`release-check` 已经会读这 5 个 manifest,但只读 `version` 字段。 + +### 仓库的检查不带网络 + +`hooks/release-check` 与 `hooks/repository-check` 里没有任何 `curl`/`wget`;`repository-check` 唯一出现 `http` 的地方是把 URL 里的凭据抹掉再打印。断言若联网取 schema,会把一个确定性检查变成依赖外网可用性的检查。 + +## Requirements + +- **R1 只断言仓库内可验证的事实,不联网。** 断言不得发出任何网络请求。理由是上一条:联网会让 `release-check` 在断网、DNS 抖动或对方限流时假红,而它目前是一个完全离线的检查。 +- **R2 声明了就不得为空。** `"$schema": ""`、`"$schema": null`、只有键没有值,都算失败。 +- **R3 声明了就必须是绝对 HTTPS URL。** 相对路径、`http://`、以 `//` 开头的协议相对形式,都算失败。 +- **R4 同一文件里只能有一个 `$schema` 键。** 重复键在 JSON 里是未定义行为,解析器取第一个还是最后一个取决于实现;这里不猜。 +- **R5 五个 manifest 要么都声明、要么都不声明,或者差异被显式记录。** 现状是 2/5 声明,而这个差异没有任何地方说明它是有意的。断言必须让「有人加了第三个」和「有人删掉了一个」都留下痕迹,而不是静默通过。 +- **R6 断言放进 `hooks/release-check`**,与既有的版本一致性检查并列,复用它的 `needs`/`STATUS:` 退出约定(`needs=1` → `STATUS: needs-user-input`,退出 2)。 +- **R7 不修改任何 manifest。** 本任务只加检查。两个 404 作为断言第一次运行时的**已知失败**记录下来,修复留给后续任务。 +- **R8 文档同步**:`CHANGELOG.md` 增一条;若 `hooks/README.md` 或 `skills/taskflow/references/runtime.md` 描述了 `release-check` 的检查范围,一并更新。 +- **R9 让 CI 真的执行到这段断言。** `hooks/smoke-test` **已经**在夹具仓库上调用 `hooks/release-check`(`:4488` 起的 `== release-check catches version drift and a bad marketplace pin ==` 一节,模式是「构造合法的 → 必须 pass,再轻量破坏一个字段 → 必须点名失败」)。新的 `$schema` 用例**并入这一节**,不新建机制、不改 `tools/workflow-affected`。这同时解决两件事:`hooks/*` 属于 smoke 集合,所以改 `release-check` 时 CI 一定会执行到它;夹具仓库又使真实仓库的两个 404 不会把 smoke 拖红。 + +## Acceptance Criteria + +- **A1** 对当前仓库运行 `bash hooks/release-check .` → 退出码 **0**、`STATUS: pass`。当前两个声明都非空、都是绝对 HTTPS、无重复键、都在 pin 清单里,所以按本任务的规则它们是合法的。**两个 URL 是否 404 不在断言范围内**(R1 不联网),由 Follow-ups 里的人工事项处理。 +- **A2** 把 `.claude-plugin/plugin.json` 的 `$schema` 改成 `""` → 断言报出「声明为空」,且是那条具体的失败信息,不是笼统的失败。 +- **A3** 把某一处 `$schema` 改成 `http://example.com/x.json` → 断言报出「不是 HTTPS」。 +- **A4** 把 `.claude-plugin/plugin.json` 的 `$schema` 整行删掉 → 断言报出「声明数量与记录的不一致」,并说明现在是几个声明、记录的是几个。 +- **A5** 不联网也能得到 A1–A4 的全部结果:断网状态下跑同一条命令,结论不变。这一条同时也是 A1 不再要求 404 检测的理由。 +- **A6** 断言**只在** `$schema` 相关事实变化时改变结论:仓库里其他任何字段(版本号、pin、README 样本)变化都不改变它的判定。 +- **A7** `bash hooks/smoke-test` → `ALL SMOKE PASSED`(回归)。 +- **A8** `git diff --check` 干净。 +- **A9** 并进 `:4488` 那一节后,夹具里**不含**任何 `$schema` 时 `release-check` 仍 `STATUS: pass`(证明新检查不会把没有声明的仓库判红)。 +- **A10** 夹具里加一个合法的 `$schema`(绝对 HTTPS URL)时仍 `STATUS: pass`;再把它改成 `""` 或 `http://…` 时退出 2 且输出点名该文件与原因。 +- **A11** `hooks/smoke-test` 不读真实 manifest —— 真实仓库当前两个 404 声明不会让它变红。这一点由后果证明:把真实 manifest 原样留在仓库里跑 `bash hooks/smoke-test`,结果是 `ALL SMOKE PASSED`。 + +## In Scope + +- `hooks/release-check` 新增一段 `$schema` 检查。 +- `hooks/smoke-test` 里为该检查新增的断言(若该套件已有 `release-check` 的测试段落,则并入该段),以及一段在夹具仓库上调用 `hooks/release-check` 的回归(R9)。 +- `CHANGELOG.md`。 +- 本任务的 TaskFlow 文档。 + +## Out of Scope + +- **修那两个 404 URL —— 这是本任务唯一能真正解决 404 的路径。** 用户已于 2026-10-29 裁定:只加断言,先不动 URL。而断言本身不联网,所以它发现不了 404。找到可验证的正确地址后再单独开任务。 +- **给另外三个 manifest 补 `$schema`。** 这是不是有意的差异,本任务不下结论(见 Open Questions)。 +- **联网校验 schema 可达性。** 见 R1。 +- **校验 manifest 内容是否符合 schema。** 断言只检查声明本身,不检查 manifest 是否符合它声明的 schema —— 那需要真的取到 schema。 +- **`hooks/repository-check`。** 它是仓库结构检查,不是发布检查。 +- 前半「三宿主手工 catalog 校验改条件式」:前提已被 `2026-09-19-release-flow-exception` 搬走,`RELEASE.md` 里已无该步骤,无对象可改。 + +## Risks / Deferred Items + +- **合并后 `release-check` 在 main 上仍然是 `STATUS: pass`。** 这与 v1 的预期相反:v1 以为检查会让两个 404 变红,但那条要求与 R1 矛盾,已由 v2 去掉。**代价必须说清楚**:本任务合并后,两个 404 依然不会被任何自动化发现。它们只出现在 Follow-ups 里,以及 PRD 的 Background 中。 +- **A5 的断网验证**在本机可用 `unshare -n` 或临时 `HTTP_PROXY=http://127.0.0.1:1` 模拟;若宿主不支持,退化为代码审查(断言里无网络调用)+ 记录该限制。 +- **R5 的「差异被显式记录」有两种实现**:断言里硬编码「允许的声明清单」,或读一个仓库内的记录文件。本任务倾向硬编码 + 注释说明理由(见 Plan),因为多一个文件就是多一个会漂移的东西。 +- **两个 404 会一直红到有人决定怎么修。** 如果长期不修,检查会变成噪音,进而被忽略 —— 这是这类断言的典型死法。缓解办法是把修复 URL 作为本任务的 Follow-up 明确列出,而不是留着。 + +## Open Questions + +- **另外三个 manifest 为什么没有 `$schema`?** `.codebuddy-plugin/*` 和 `.codex-plugin/plugin.json` 可能本来就没有公开 schema,`package.json` 是 npm 的、有自己的约定。这一点本任务不猜;如果查明是「本来就没有」,那 R5 的正确形态是「记录哪些可以有、哪些不该有」,而不是「要求五个都有」。 +- **`release-check` 由谁运行、什么时候运行?—— 已查明(2026-10-29)。** CI 会跑它(`.github/workflows/hooks.yml:101-111`,`bash hooks/release-check .`),但只在 `release` 分类为真时:`.claude-plugin/*`、`.codex-plugin/*`、`.codebuddy-plugin/*`、`package.json`、`CHANGELOG.md`、`README.md`、`README.zh-CN.md` 有改动(`tools/workflow-affected` 的 `release=1` 分支)。 + + **这带来一个必须解决的后果**:本任务改的是 `hooks/release-check`,而 `hooks/*` 落在 **`smoke` 集合**、不在 `release` 集合,所以这一轮 CI 会跑 smoke,**不会跑 `release-check` 自己**。断言写进去之后,CI 里没有任何一次执行会验证它。 + + **用户裁定(2026-10-29):让 `hooks/smoke-test` 直接调用 `hooks/release-check`。** 理由:`hooks/*` 属于 smoke 集合,每次改 hook 都会跑 smoke,所以改 `release-check` 时 CI 一定会执行到新断言;把 `release-check` 加进 release 触发集只在它自己被改时才生效,覆盖不到「新断言是否真的工作」。 + diff --git a/TaskFlowDocs/todo.md b/TaskFlowDocs/todo.md index 16fdcb3..0ab751e 100644 --- a/TaskFlowDocs/todo.md +++ b/TaskFlowDocs/todo.md @@ -26,6 +26,19 @@ has an issue number or URL. +## Assert the $schema a plugin manifest declares, so a schema URL that stops resolv + +- ID: TF-20261029-218fdf +- Status: in_progress +- Priority: normal +- Owner: Claude +- Source: user request 2026-10-29 (TF-20260928-3910fb re-scoped) +- Added: 2026-10-29 +- Updated: 2026-10-29 +- Goal: Assert the $schema a plugin manifest declares, so a schema URL that stops resolving or a manifest that drops one fails a check instead of shipping +- Task: `TaskFlowDocs/2026-10-29-schema-check/` +- Next action: Re-approve v2: A1 now expects STATUS: pass, since the offline assertion cannot detect the two 404 URLs. + ## task approve requires the first [Plan] line to name exactly the capability relea - ID: TF-20261026-726ec4 diff --git a/hooks/release-check b/hooks/release-check index 45949cb..8a0ff66 100755 --- a/hooks/release-check +++ b/hooks/release-check @@ -86,6 +86,95 @@ printf -- '- %s: %s\n' "$codex_manifest" "$codex_v" printf -- '- %s: %s\n' "$codebuddy_manifest" "$codebuddy_v" printf -- '- %s: %s\n' "$dsh_manifest" "$dsh_v" printf -- '- CHANGELOG.md (first section): %s\n' "$changelog_v" + +# --- $schema declarations -------------------------------------------------- +# A manifest may point at a JSON Schema for editors and validators. Nothing read +# those pointers, and measured 2026-10-29 both of the ones in this repository +# resolve to 404: `anthropic.com/claude-code/{marketplace,plugin}.schema.json` +# each 301 to `www.anthropic.com/...` and then 404. So the assertion here checks +# what the repository can decide by itself and says nothing about reachability. +# +# Deliberately no network. Every other check in this file is offline, and a +# `curl` here would turn a deterministic check into one that reddens on a DNS +# blip or a rate limit -- and the reachability answer would be wrong the moment +# the remote moved, which is exactly how the current two got this way unnoticed. +# +# The declaration set is written out rather than read from a file: a second +# record of the same fact is a second thing to drift, and this one is two lines. +# It is a pin, not a policy, so the failure it produces is "the set changed" -- +# which is the interesting event either way. Adding a `$schema` to a manifest +# that has none must edit this list, and deleting one must too. +schema_expected=".claude-plugin/marketplace.json .claude-plugin/plugin.json" +schema_seen="" +for file in "$marketplace" "$claude_manifest" "$codex_manifest" \ + "$codebuddy_manifest" "$codebuddy_marketplace" "$dsh_manifest"; do + [ -f "$file" ] || continue + # Presence is the key, not the extracted value: an empty `""` is a declaration + # with an empty value and must reach the loop below, where it is its own + # failure. Keying on the value instead skipped exactly that case -- measured + # 2026-10-29, `"$schema": ""` passed the whole check. + grep -q '"\$schema"' "$file" || continue + # Every value on every `$schema` line. A duplicate key inside one file is + # undefined behaviour for JSON parsers -- which one wins depends on the + # implementation -- so two values are reported rather than resolved. + values="$(sed -n 's/.*"\$schema"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$file")" + count="$(printf '%s\n' "$values" | grep -c . || true)" + if [ "$count" -gt 1 ]; then + echo "- $file declares \$schema $count times; a JSON parser's choice between them is unspecified" + needs=1 + fi + schema_seen="$schema_seen $file" + if [ "$count" -eq 0 ]; then + # The key is there but carries no string value. `""` and `null` are reported + # apart because they are different mistakes: an empty string is a value the + # author meant to fill in, a non-string is a shape that was never a URL. + if grep -q '"\$schema"[[:space:]]*:[[:space:]]*""' "$file"; then + echo "- $file declares an empty \$schema" + else + echo "- $file has a \$schema key with no string value" + fi + needs=1 + continue + fi + while IFS= read -r value; do + [ -n "$value" ] || { echo "- $file declares an empty \$schema"; needs=1; continue; } + case "$value" in + https://?*) ;; + *) echo "- $file \$schema is not an absolute https URL: $value" + needs=1 ;; + esac + done < "$sch/.claude-plugin/plugin.json" +printf '{\n "name": "taskflow",\n "version": "9.9.9+codex.20260915"\n}\n' > "$sch/.codex-plugin/plugin.json" +printf '{\n "name": "taskflow",\n "version": "9.9.9+codebuddy.20260917"\n}\n' > "$sch/.codebuddy-plugin/plugin.json" +printf '{\n "name": "dsh-taskflow",\n "version": "9.9.9"\n}\n' > "$sch/package.json" +printf '# Changelog\n\n## [9.9.9] — 2026-09-15\n' > "$sch/CHANGELOG.md" +printf '# Readme\n\n```bash\n# taskflow@taskflow Version: 9.9.9 Status: enabled\n```\n' > "$sch/README.md" +cp "$sch/README.md" "$sch/README.zh-CN.md" +git -C "$sch" init -q +git -C "$sch" config user.email smoke@example.invalid +git -C "$sch" config user.name smoke +git -C "$sch" add -A +git -C "$sch" commit -qm init +git -C "$sch" tag v9.9.9 +pin_sha="$(git -C "$sch" rev-parse HEAD)" +for catalog in .claude-plugin/marketplace.json .codebuddy-plugin/marketplace.json; do + printf '{\n "plugins": [\n {\n "source": {\n "ref": "v9.9.9",\n "sha": "%s"\n }\n }\n ]\n}\n' "$pin_sha" > "$sch/$catalog" +done +# The pinned set names two files, so the fixture starts with no declarations and +# the FIRST case below asserts that silence passes; the pinned pair is then added +# by the second case. Written out here rather than via sed so each case reads on +# its own. + +echo "== release-check judges a declared \$schema by what the file says ==" +# No declaration anywhere: pass. The check must not demand one, because three of +# the five shipped manifests legitimately have none. +bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1 \ + || { echo "FAIL a repository with no \$schema was refused"; cat "$sch/out"; exit 1; } +grep -q 'STATUS: pass' "$sch/out" || { echo "FAIL no-\$schema repository did not pass"; cat "$sch/out"; exit 1; } + +# The pinned set with a well-formed declaration on each: still pass. +for pair in .claude-plugin/marketplace.json .claude-plugin/plugin.json; do + sed "s/^{/{\n \"\$schema\": \"https:\/\/example.invalid\/x.schema.json\",/" \ + "$sch/$pair" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/$pair" +done +bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1 \ + || { echo "FAIL a well-formed \$schema declaration was refused"; cat "$sch/out"; exit 1; } +grep -q 'STATUS: pass' "$sch/out" || { echo "FAIL well-formed declarations did not pass"; cat "$sch/out"; exit 1; } + +# Empty value. +sed 's|"\$schema": "[^"]*"|"\$schema": ""|' "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" +mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" +if bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1; then echo "FAIL empty \$schema accepted"; exit 1; fi +grep -q 'declares an empty \$schema' "$sch/out" || { echo "FAIL empty \$schema not named"; cat "$sch/out"; exit 1; } +sed 's|"\$schema": ""|"\$schema": "https://example.invalid/x.schema.json"|' \ + "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" + +# Plain http, and a relative path: neither can be an absolute https URL. +for bad in 'http://example.invalid/x.schema.json' './x.schema.json'; do + sed "s|\"\$schema\": \"[^\"]*\"|\"\$schema\": \"$bad\"|" "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" + mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" + if bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1; then echo "FAIL non-https \$schema accepted: $bad"; exit 1; fi + grep -q 'is not an absolute https URL' "$sch/out" || { echo "FAIL non-https \$schema not named: $bad"; cat "$sch/out"; exit 1; } +done +sed 's|"\$schema": "[^"]*"|"\$schema": "https://example.invalid/x.schema.json"|' \ + "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" + +# Declared twice in one file: which value a parser keeps is unspecified, so the +# count is the finding rather than either value. +sed 's|"\$schema": "[^"]*"|"\$schema": "https://example.invalid/a.json",\n "\$schema": "https://example.invalid/b.json"|' \ + "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" +if bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1; then echo "FAIL duplicate \$schema accepted"; exit 1; fi +grep -q 'declares \$schema 2 times' "$sch/out" || { echo "FAIL duplicate \$schema not counted"; cat "$sch/out"; exit 1; } +sed 's|"\$schema": "https://example.invalid/a.json",||; s|^ "\$schema": "https://example.invalid/b.json"| "\$schema": "https://example.invalid/x.schema.json"|' \ + "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" + +# The pin, both directions. A declaration removed from a pinned file, and a +# declaration added to a file that is not pinned -- each is the same event: the +# set changed and nobody said so. +sed '/"\$schema"/d' "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" +if bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1; then echo "FAIL a pinned declaration was dropped silently"; exit 1; fi +grep -q 'no longer declares a \$schema' "$sch/out" || { echo "FAIL dropped declaration not named"; cat "$sch/out"; exit 1; } +sed 's/^{/{\n "\$schema": "https:\/\/example.invalid\/x.schema.json",/' \ + "$sch/.claude-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.claude-plugin/plugin.json" +sed 's/^{/{\n "\$schema": "https:\/\/example.invalid\/y.schema.json",/' \ + "$sch/.codex-plugin/plugin.json" > "$sch/tmp.json" && mv "$sch/tmp.json" "$sch/.codex-plugin/plugin.json" +if bash "$HERE/release-check" "$sch" >"$sch/out" 2>&1; then echo "FAIL an unpinned declaration was accepted silently"; exit 1; fi +grep -q 'is not in the pinned set' "$sch/out" || { echo "FAIL unpinned declaration not named"; cat "$sch/out"; exit 1; } +echo ok + echo "== release-version writes every literal, and refuses before it writes any ==" # The eight literals are one approved version string transcribed into six files, # so writing them by hand spends tokens on a mechanical edit and is the step that