feat(checker+typst): 嵌入 render 使 cph 可 cargo install;实现 shell step 执行器(ADR-0013)

三件事,服务于"把恒一小奥 KenKen 课作者化为合法 native 工程文件"这条主线
(工程文件本身在 repo 外的 sibling playground,不入此提交):

1. cph-typst: 把 render/ 编译期嵌入二进制,`cargo install` 后开箱即用
   - build.rs 把 render/ 拷进 OUT_DIR,剔除 dev 自指符号链接 vendor/local-packages
     (否则 include_dir! 无限递归)
   - embedded.rs 用 include_dir! 嵌入,运行时按版本解压到 per-user cache dir
     (CPH_RENDER_DIR 仍优先作 dev override)
   - default_render_dir 不再用编译期 CARGO_MANIFEST_DIR 写死路径

2. cph-check + cph-cli: 实现 Step::Shell 执行器(此前仅占位)
   - run_shell_target: 跑 check 门控 → 以工程根为 cwd 执行 shell step,首非零退出即停
   - target_is_shell + CLI run_shell_build 路由;`check` 不跑 shell(结构校验只看 lesson)
   - compile_targets 只保留含 TypstCompile 的 target,纯 shell target 不走 typst 引擎
   - 语义:失败属 build-过程错误,不入 6 类诊断(taxonomy 保持 6,显式拒绝加 ShellStep 码)

3. spec + ADR: 在 Export/Render.lean 钉 shell step 执行语义 prose;
   ADR-0013(已实现)、ADR-0014(md/HTML 导出后端方向,Proposed/设计先行,未实现)

测试:全 workspace 42 passed(新增 4 个 shell 执行器测试);lake build 25 jobs 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:09:49 +08:00
parent c6b4d439ac
commit 9590e15236
12 changed files with 809 additions and 18 deletions
@@ -0,0 +1,106 @@
# ADR 0013: Shell-Step Execution For Tool-Generated Assets
## Status
Accepted — and implemented. The `Step::Shell` escape hatch (introduced
structurally in ADR-0011 but left unimplemented) now has an executor in
`cph-check`, wired into `cph build --target <shell-target>`. First real use: the
KenKen interactive boards of the 恒一小奥 lesson, generated by the external
`kendoku` CLI.
Builds on **ADR-0005** (category (b): medium-only information — HTML interactive
behavior, build scripts, npm deps — belongs to the target's build, not the
element) and **ADR-0009/0011** (a target is an `Artifact` produced by an ordered
list of typed `Step`s; `Shell` is the escape hatch for steps that resist
declaration). It does not amend them; it fixes the **execution semantics** of
`Shell`, which those ADRs left open ("MVP 不实现,先建结构").
## Context
Authoring the 恒一小奥 KenKen lesson as a native engineering file surfaced a
concrete category-(b) need. The lesson's interactive teaching apparatus — a
clickable 4×4 KenKen board per problem — is produced by a **separate tool**
(`kendoku`, a TypeScript CLI living in the playground: `kendoku export
<puzzle.json> -o <out.html>`). The structured source of truth is the puzzle JSON
(cages / solution / cell-to-cage); the self-contained interactive HTML is a
**generated build artifact**, exactly ADR-0005's category (b) and ADR-0009's
"third-party archive that runs an external build".
The contract already named this (`Step::Shell`, `Artifact::FileTree`), and
`cph-model` already parsed both, but the engine refused them: there was **no step
executor**, and `cph build` only knew how to compile a typst template to a single
PDF. So the lesson's apparatus could be *described* but not *produced* through the
same framework.
Three questions about *how* a shell step runs were genuinely undecided, and
divergent answers would matter (safety, what `check` means, where failures go).
## Decision
### A shell step executes the command, in the engineering root, on explicit build
`cph build --target <name>` for a target whose steps are `Shell` runs each `run`
string through the platform shell (`sh -c` / `cmd /C`) with the **engineering
root as the working directory**, so manifests use root-relative paths. cph
**executes** the declared command and reports the outcome; it does **not**
assemble or post-process the tool's output — the external tool writes the files
(this is the difference from a `TypstCompile` step, where the framework owns the
compile).
### Arbitrary command execution is opt-in by construction
A shell step is arbitrary code execution, so it must never be a surprise:
- It runs **only** on an explicit `cph build --target <shell-target>`.
- `cph check` **never** runs shell steps — `check` validates lesson *structure*
(is the lesson legal?), not the outcome of running an external tool. A
shell/file-tree target is also skipped by the check pipeline's typst-compile
phase (it is not a typst build).
- The CLI prints each command before running it, and streams the tool's output.
### A shell step failure is a build-process error, not a lesson diagnostic
A non-zero exit is a failure of the *build process*, categorically distinct from
the six lesson-legality diagnostics (`PartPathMissing`, `UnknownKind`,
`MissingContentFile`, `SchemaViolation`, `TypstCompile`, `RenderIgnored`). Those
six describe defects *in the lesson*; a tool that fails to run says nothing about
the lesson's legality.
**We deliberately do NOT add a `ShellStep` diagnostic code.** The closed,
spec-pinned taxonomy (`Check/Diagnostic.lean`, ADR-0010/0012) stays at six. Shell
outcomes are reported by the build-execution layer (exit status + captured
stdout/stderr surfaced by the CLI), keeping the diagnostic vocabulary about
lesson legality only. This is the lighter, constitution-respecting choice
(taxonomy is a pinned divergence point; not every error category belongs in it).
### `kendoku` reachability is left open
How the external tool is invoked (a relative `node …/dist/cli.js` path, `npx
kendoku` after `npm link`, or an installed binary on PATH) is **not** fixed by
the contract — it is a property of a given engineering file's `run` string and
its host. The 恒一小奥 manifest uses a relative `node` path as an MVP and its
README documents the prerequisite; a portable convention is left open.
## Consequences
- The same framework now *produces* the apparatus it could previously only
describe: `cph build --target interactives` runs `kendoku` and writes the 10
interactive boards under the target's `FileTree` root. The lesson's PDFs
(`student`/`teacher`) and its interactives all come from one engineering file
through one CLI.
- `Step::Shell` is no longer a placeholder; its execution semantics are pinned in
`Export/Render.lean`.
- The diagnostic taxonomy is unchanged (still six). Build-process failures live
outside it.
- `TypstCompile` targets are unaffected: the PDF path is exactly as before.
## Open Questions / Deferred
- **Portable tool reachability.** A cross-machine convention for locating an
external generator (`npx`/PATH/vendored) rather than a host-specific path.
- **`FileTree` output validation.** Whether the checker should verify a shell
target actually produced the files its `Artifact::FileTree` `outputs` glob
declares (currently the tool's exit status is the only signal).
- **Step animation (GIF) pipeline.** `kendoku steps` → headless-Chrome
screenshots → ffmpeg is a multi-tool chain; this lesson uses pre-generated
static step PNGs instead. Wiring that chain as shell steps is deferred.