forked from EduCraft/curriculum-project-hub
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:
Generated
+21
@@ -437,6 +437,8 @@ dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
"cph-schema",
|
||||
"dirs",
|
||||
"include_dir",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"typst",
|
||||
@@ -1191,6 +1193,25 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
|
||||
+209
-5
@@ -198,6 +198,198 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
|
||||
}
|
||||
}
|
||||
|
||||
/// One shell step's execution outcome (for [`run_shell_target`]).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShellStepOutcome {
|
||||
/// The command line that was run (verbatim from the manifest `run` field).
|
||||
pub run: String,
|
||||
/// Process exit code (`None` if the process was killed by a signal).
|
||||
pub status: Option<i32>,
|
||||
/// Captured stdout.
|
||||
pub stdout: String,
|
||||
/// Captured stderr.
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl ShellStepOutcome {
|
||||
/// Whether the step exited successfully (status 0).
|
||||
pub fn ok(&self) -> bool {
|
||||
self.status == Some(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of [`run_shell_target`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShellRunReport {
|
||||
/// The check phases' report (load → structural → schema). Shell steps run
|
||||
/// only when this has no errors.
|
||||
pub check: CheckReport,
|
||||
/// Each shell step's outcome, in declared order. Empty if the build was
|
||||
/// refused (check errors, unknown target, or the target has no shell steps).
|
||||
pub outcomes: Vec<ShellStepOutcome>,
|
||||
/// `true` if the target was found, check passed, and every shell step exited 0.
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009
|
||||
/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI).
|
||||
///
|
||||
/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a
|
||||
/// typst template to a PDF), a shell target hands off to an external tool that
|
||||
/// writes files itself. cph only *runs the declared command* in the engineering
|
||||
/// root and reports what happened — it assembles nothing.
|
||||
///
|
||||
/// Contract / safety:
|
||||
/// - Runs the check phases first (load → structural → schema). Any error refuses
|
||||
/// the run (`ok == false`, no command executed) — a shell build of a broken
|
||||
/// lesson is not attempted.
|
||||
/// - Each `Step::Shell { run }` is executed via the platform shell
|
||||
/// (`sh -c <run>` / `cmd /C <run>`) with the **engineering root as the working
|
||||
/// directory**, so a manifest can use root-relative paths.
|
||||
/// - **Arbitrary command execution is opt-in by construction**: this function is
|
||||
/// only reached from `cph build --target <name>`, never from `check`. Callers
|
||||
/// (the CLI) surface the command before running it.
|
||||
/// - `TypstCompile` steps inside the same target are skipped here (a shell target
|
||||
/// is not a typst build); a target with no shell steps yields `ok == false`.
|
||||
pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport {
|
||||
let mut diags = Vec::new();
|
||||
|
||||
let (lesson, load_diags) = cph_model::load(root);
|
||||
diags.extend(load_diags);
|
||||
|
||||
let Some(lesson) = lesson else {
|
||||
return ShellRunReport {
|
||||
check: CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: false,
|
||||
},
|
||||
outcomes: Vec::new(),
|
||||
ok: false,
|
||||
};
|
||||
};
|
||||
|
||||
let known = cph_schema::known_kinds();
|
||||
run_structural_and_schema(&lesson, known, &mut diags);
|
||||
|
||||
// Suppress the unused-parameter warning while keeping the signature uniform
|
||||
// with `build`/`check` (the engine is not needed to run shell steps, but
|
||||
// callers pass it so the API is consistent and future steps may use it).
|
||||
let _ = engine;
|
||||
|
||||
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
|
||||
|
||||
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!("target '{target}' not declared in manifest"),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"add a `[targets.{target}]` table, or run a declared target"
|
||||
)),
|
||||
);
|
||||
return ShellRunReport {
|
||||
check: CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
outcomes: Vec::new(),
|
||||
ok: false,
|
||||
};
|
||||
};
|
||||
|
||||
let shell_steps: Vec<&str> = tc
|
||||
.steps
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
cph_model::Step::Shell { run } => Some(run.as_str()),
|
||||
cph_model::Step::TypstCompile { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if has_error || shell_steps.is_empty() {
|
||||
return ShellRunReport {
|
||||
check: CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
outcomes: Vec::new(),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Run each shell step in the engineering root. First non-zero exit stops the
|
||||
// sequence (a failed step's successors likely depend on it).
|
||||
let mut outcomes = Vec::new();
|
||||
let mut all_ok = true;
|
||||
for run in shell_steps {
|
||||
let outcome = run_one_shell(&lesson.root, run);
|
||||
let ok = outcome.ok();
|
||||
outcomes.push(outcome);
|
||||
if !ok {
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ShellRunReport {
|
||||
check: CheckReport {
|
||||
diagnostics: dedup(diags),
|
||||
lesson_loaded: true,
|
||||
},
|
||||
outcomes,
|
||||
ok: all_ok,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the named target is a **shell-step** target (its steps contain at
|
||||
/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated
|
||||
/// asset bundle that [`run_shell_target`] executes rather than [`build`]
|
||||
/// compiling to PDF.
|
||||
///
|
||||
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
|
||||
/// unknown target returns `false`, so the caller falls through to the normal
|
||||
/// (typst) build path, which then reports the real error.
|
||||
pub fn target_is_shell(root: &Path, target: &str) -> bool {
|
||||
let (Some(lesson), _) = cph_model::load(root) else {
|
||||
return false;
|
||||
};
|
||||
lesson
|
||||
.targets
|
||||
.iter()
|
||||
.find(|t| t.name == target)
|
||||
.is_some_and(|t| {
|
||||
t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. }))
|
||||
&& !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Run one shell command line in `cwd` via the platform shell, capturing output.
|
||||
fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
|
||||
use std::process::Command;
|
||||
|
||||
let output = if cfg!(target_os = "windows") {
|
||||
Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output()
|
||||
} else {
|
||||
Command::new("sh").arg("-c").arg(run).current_dir(cwd).output()
|
||||
};
|
||||
|
||||
match output {
|
||||
Ok(out) => ShellStepOutcome {
|
||||
run: run.to_string(),
|
||||
status: out.status.code(),
|
||||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
},
|
||||
Err(e) => ShellStepOutcome {
|
||||
run: run.to_string(),
|
||||
status: None,
|
||||
stdout: String::new(),
|
||||
stderr: format!("failed to spawn command: {e}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
|
||||
/// for parts whose folder is absent, so the orchestrator skips those parts in
|
||||
/// the structural/schema/coverage phases to avoid piling diagnostics on the
|
||||
@@ -245,14 +437,26 @@ fn run_structural_and_schema(
|
||||
}
|
||||
}
|
||||
|
||||
/// The targets to compile-check for [`check`]: every declared target, or
|
||||
/// `["student"]` if the lesson declares none.
|
||||
/// The targets to compile-check for [`check`]: every declared **typst-compile**
|
||||
/// target, or `["student"]` if the lesson declares none.
|
||||
///
|
||||
/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle —
|
||||
/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is
|
||||
/// **not** typst-compiled, so it is excluded here: compile-checking it would
|
||||
/// wrongly route a non-typst build through the typst engine. Such targets are
|
||||
/// executed only on an explicit `cph build --target <name>` (see
|
||||
/// [`run_shell_target`]); `check` validates the lesson structure, not the
|
||||
/// outcome of running an external tool.
|
||||
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
|
||||
if lesson.targets.is_empty() {
|
||||
vec![DEFAULT_TARGET]
|
||||
} else {
|
||||
lesson.target_names()
|
||||
return vec![DEFAULT_TARGET];
|
||||
}
|
||||
lesson
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })))
|
||||
.map(|t| t.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
|
||||
|
||||
@@ -262,6 +262,118 @@ template = "exports/handout.typ"
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the
|
||||
/// given command. Returns nothing; the caller runs `run_shell_target`.
|
||||
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) {
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[project]
|
||||
id = "sh"
|
||||
name = "sh"
|
||||
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[targets.assets]
|
||||
artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }}
|
||||
[[targets.assets.steps]]
|
||||
type = "shell"
|
||||
run = "{run}"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let part_dir = tmp.join("segments").join("intro");
|
||||
std::fs::create_dir_all(&part_dir).unwrap();
|
||||
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
|
||||
std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_is_shell_detects_shape() {
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(&tmp, "true");
|
||||
assert!(cph_check::target_is_shell(&tmp, "assets"));
|
||||
assert!(!cph_check::target_is_shell(&tmp, "nonexistent"));
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_executes_in_engineering_root() {
|
||||
// The command writes a file under the engineering root; cwd must be the root.
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(
|
||||
&tmp,
|
||||
"mkdir -p build/assets && printf ok > build/assets/marker.txt",
|
||||
);
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(report.ok, "shell target should succeed: {:#?}", report);
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
assert!(report.outcomes[0].ok());
|
||||
|
||||
// The side effect landed in the engineering root.
|
||||
let marker = tmp.join("build").join("assets").join("marker.txt");
|
||||
assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok");
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_reports_nonzero_exit() {
|
||||
let tmp = tempdir();
|
||||
write_shell_target_lesson(&tmp, "exit 3");
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(!report.ok, "non-zero exit must fail the run");
|
||||
assert_eq!(report.outcomes.len(), 1);
|
||||
assert_eq!(report.outcomes[0].status, Some(3));
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shell_target_refuses_broken_lesson() {
|
||||
// A part path that doesn't exist → check error → shell steps never run.
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "sh"
|
||||
name = "sh"
|
||||
|
||||
[info]
|
||||
title = "sh"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/missing"
|
||||
|
||||
[targets.assets]
|
||||
artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" }
|
||||
[[targets.assets.steps]]
|
||||
type = "shell"
|
||||
run = "printf SHOULD_NOT_RUN > build/ran.txt"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
|
||||
assert!(!report.ok);
|
||||
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson");
|
||||
assert!(report.check.has_errors());
|
||||
assert!(
|
||||
!tmp.join("build").join("ran.txt").exists(),
|
||||
"the command must not have executed"
|
||||
);
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
|
||||
@@ -102,6 +102,14 @@ fn run_build(
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
// A target whose steps are shell commands (a tool-generated asset bundle,
|
||||
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
|
||||
// executing those commands, not by compiling a typst template. Detect that
|
||||
// shape up front and route accordingly.
|
||||
if cph_check::target_is_shell(path, target) {
|
||||
return run_shell_build(path, engine, target);
|
||||
}
|
||||
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
|
||||
let (pdf, report) = cph_check::build(path, engine, target);
|
||||
@@ -131,3 +139,46 @@ fn run_build(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a shell-step target: execute its declared commands in the engineering
|
||||
/// root. Arbitrary command execution is **opt-in** — it only happens on an
|
||||
/// explicit `cph build --target <name>`, and each command is printed before it
|
||||
/// runs so the user sees exactly what is executed.
|
||||
fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
|
||||
eprintln!("running shell-step target '{target}' (commands execute in {})", path.display());
|
||||
let report = cph_check::run_shell_target(path, engine, target);
|
||||
print_diagnostics(&report.check);
|
||||
|
||||
if report.outcomes.is_empty() && !report.ok {
|
||||
// Refused before running anything (check errors / unknown target / no
|
||||
// shell steps): the diagnostics above explain why.
|
||||
if report.check.has_errors() {
|
||||
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
|
||||
} else {
|
||||
eprintln!("build failed: target '{target}' has no shell steps to run");
|
||||
}
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
for outcome in &report.outcomes {
|
||||
eprintln!("$ {}", outcome.run);
|
||||
if !outcome.stdout.trim().is_empty() {
|
||||
print!("{}", outcome.stdout);
|
||||
}
|
||||
if !outcome.ok() {
|
||||
eprint!("{}", outcome.stderr);
|
||||
eprintln!(
|
||||
"step failed (exit {})",
|
||||
outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if report.ok {
|
||||
println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len());
|
||||
ExitCode::SUCCESS
|
||||
} else {
|
||||
eprintln!("shell-step target '{target}' failed");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ cph-model = { workspace = true }
|
||||
cph-schema = { path = "../cph-schema" }
|
||||
toml = { workspace = true }
|
||||
|
||||
# Embed the `render/` package (cph-render + vendored @preview/numbly) INTO the
|
||||
# binary so an installed `cph` is self-contained — no reliance on a source
|
||||
# checkout living at a compile-time path. `dirs` locates a per-user cache dir to
|
||||
# extract the embedded tree to (typst's World reads render files from disk).
|
||||
include_dir = "0.7"
|
||||
dirs = "6"
|
||||
|
||||
typst = "=0.15.0"
|
||||
typst-layout = "=0.15.0"
|
||||
typst-pdf = "=0.15.0"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Build script: stage a clean copy of the repo `render/` package into `OUT_DIR`
|
||||
//! so `src/embedded.rs` can `include_dir!` it without tripping over the dev-only
|
||||
//! self-referential symlink at `render/vendor/local-packages/`.
|
||||
//!
|
||||
//! Why this exists: `include_dir!` walks a directory at compile time and follows
|
||||
//! symlinks. The repo keeps `render/vendor/local-packages/local/cph-render/0.1.0`
|
||||
//! as a symlink pointing back at `render/` (a gitignored dev convenience for
|
||||
//! typst's local-package resolution). Embedding `render/` directly would make
|
||||
//! `include_dir!` recurse into that link forever. The runtime never needs that
|
||||
//! link (the engine resolves `@local/cph-render` from the render dir directly,
|
||||
//! and `@preview/*` from `vendor/typst-packages/`), so we copy everything EXCEPT
|
||||
//! `vendor/local-packages/` into `OUT_DIR/render` and embed that staged tree.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
|
||||
let render_src = manifest_dir.join("..").join("..").join("render");
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let staged = out_dir.join("render");
|
||||
|
||||
// Rebuild the staged tree from scratch each run (cheap — ~140 KiB).
|
||||
if staged.exists() {
|
||||
let _ = std::fs::remove_dir_all(&staged);
|
||||
}
|
||||
copy_tree(&render_src, &staged).expect("stage render/ for embedding");
|
||||
|
||||
// Re-run when the render package changes.
|
||||
println!("cargo:rerun-if-changed={}", render_src.display());
|
||||
// Expose the staged path to the crate via an env var include_dir! can read.
|
||||
println!("cargo:rustc-env=CPH_STAGED_RENDER_DIR={}", staged.display());
|
||||
}
|
||||
|
||||
/// Recursively copy `src` → `dst`, skipping the dev-only `vendor/local-packages`
|
||||
/// subtree and never following symlinks into it.
|
||||
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let from = entry.path();
|
||||
let to = dst.join(&name);
|
||||
|
||||
// Skip the self-referential dev symlink tree.
|
||||
if from.ends_with("vendor/local-packages") || name == *"local-packages" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve symlinks/metadata without following into a cycle: use
|
||||
// `symlink_metadata` so a symlinked dir is treated by its link, not its
|
||||
// (recursive) target. We only copy real files and real directories.
|
||||
let meta = std::fs::symlink_metadata(&from)?;
|
||||
let ft = meta.file_type();
|
||||
if ft.is_symlink() {
|
||||
// Skip any symlink (the only one in the tree is the dev convenience).
|
||||
continue;
|
||||
} else if ft.is_dir() {
|
||||
copy_tree(&from, &to)?;
|
||||
} else if ft.is_file() {
|
||||
std::fs::copy(&from, &to)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Embedded `cph-render` package — makes an installed `cph` self-contained.
|
||||
//!
|
||||
//! The typst [`crate::world::LessonWorld`] reads the render package
|
||||
//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by
|
||||
//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's
|
||||
//! `render/`. But a `cargo install`-ed binary has no repo around it — the old
|
||||
//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox
|
||||
//! and does not exist at runtime.
|
||||
//!
|
||||
//! So we **embed the whole `render/` tree into the binary** at compile time
|
||||
//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir**
|
||||
//! keyed by the crate version. `default_render_dir` returns that extracted path.
|
||||
//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction
|
||||
//! is a one-time cost per version.
|
||||
//!
|
||||
//! `render/vendor/local-packages/` (a dev-only self-referential symlink,
|
||||
//! gitignored) is excluded by the build script when it stages the tree for
|
||||
//! embedding — see `build.rs`. The embedded copy never contains it, which is
|
||||
//! correct: the World resolves `@local/cph-render` from `render_dir` directly,
|
||||
//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use include_dir::{include_dir, Dir};
|
||||
|
||||
/// The `render/` tree, embedded at compile time from the **staged** copy the
|
||||
/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that
|
||||
/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR`
|
||||
/// is set by `build.rs`.
|
||||
static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
|
||||
|
||||
/// Resolve the directory the typst World should read the render package from,
|
||||
/// extracting the embedded copy to a per-user cache dir if needed.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
|
||||
/// at the live repo `render/`).
|
||||
/// 2. The extracted embedded copy under the user cache dir
|
||||
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
|
||||
/// subsequent runs reuse it.
|
||||
///
|
||||
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
|
||||
/// location so the engine still works (just re-extracting per process).
|
||||
pub fn resolve_render_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
ensure_extracted().unwrap_or_else(|_| {
|
||||
// Last-resort: extract under the OS temp dir. Still correct, just not
|
||||
// cached across processes.
|
||||
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
|
||||
let _ = extract_to(&fallback);
|
||||
fallback
|
||||
})
|
||||
}
|
||||
|
||||
/// The version-keyed cache location and a guarantee the embedded tree is present
|
||||
/// there. Returns the directory the World should use.
|
||||
fn ensure_extracted() -> std::io::Result<PathBuf> {
|
||||
let base = dirs::cache_dir()
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
|
||||
let dest = base
|
||||
.join("cph")
|
||||
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
|
||||
// version, so a new `cph` version re-extracts into a fresh dir.)
|
||||
let sentinel = dest.join(".extracted");
|
||||
if sentinel.is_file() {
|
||||
return Ok(dest);
|
||||
}
|
||||
|
||||
extract_to(&dest)?;
|
||||
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Write the embedded `render/` tree under `dest` (creating dirs as needed).
|
||||
///
|
||||
/// Each embedded entry's `.path()` is already relative to the embed root (e.g.
|
||||
/// `src/elements/segment.typ`), so we join the **full** path under `dest` and
|
||||
/// create parents — no per-level mirroring needed.
|
||||
fn extract_to(dest: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dest)?;
|
||||
write_dir(&RENDER_DIR, dest)
|
||||
}
|
||||
|
||||
/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the
|
||||
/// full relative path of each file.
|
||||
fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> {
|
||||
for entry in dir.entries() {
|
||||
match entry {
|
||||
include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?,
|
||||
include_dir::DirEntry::File(file) => {
|
||||
let file_dest = dest.join(file.path());
|
||||
if let Some(parent) = file_dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&file_dest, file.contents())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+13
-11
@@ -35,6 +35,7 @@
|
||||
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
|
||||
|
||||
mod diag;
|
||||
mod embedded;
|
||||
mod manifest;
|
||||
mod world;
|
||||
|
||||
@@ -68,8 +69,9 @@ pub struct Engine {
|
||||
impl Engine {
|
||||
/// Construct an engine, discovering system + embedded fonts. The render
|
||||
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
|
||||
/// when set, otherwise defaults to `<repo>/render` resolved relative to this
|
||||
/// crate (`CARGO_MANIFEST_DIR/../../render`).
|
||||
/// when set, otherwise the **embedded** render package is extracted to a
|
||||
/// per-user cache dir and used (see [`default_render_dir`]). This keeps an
|
||||
/// installed `cph` self-contained — no source checkout required at runtime.
|
||||
pub fn new() -> Self {
|
||||
Self::with_render_dir(default_render_dir())
|
||||
}
|
||||
@@ -234,14 +236,14 @@ fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `<crate>/../../render` — the repo's render package, used when `CPH_RENDER_DIR`
|
||||
/// is unset.
|
||||
/// The render-package directory the engine resolves `@local/cph-render` (and the
|
||||
/// vendored `@preview/*`) from when no explicit dir is given.
|
||||
///
|
||||
/// Honors `CPH_RENDER_DIR` first (dev override → live repo `render/`); otherwise
|
||||
/// returns a per-user cache dir holding the **embedded** render tree, extracted
|
||||
/// on demand (see [`embedded`]). This is what makes a `cargo install`-ed `cph`
|
||||
/// self-contained: it no longer depends on a source checkout living at a
|
||||
/// compile-time path.
|
||||
fn default_render_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
embedded::resolve_render_dir()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR 0014: Direction For A Markdown / HTML Export Backend
|
||||
|
||||
## Status
|
||||
|
||||
Proposed — **design-only**. This ADR fixes the *direction* and the key open
|
||||
decision for a future markdown/HTML export target; it does **not** implement a
|
||||
backend. No code or schema changes accompany it. (The 恒一小奥 lesson ships with
|
||||
`student`/`teacher` PDF targets and a `kendoku`-generated `interactives` target;
|
||||
a markdown/HTML analog is future work gated on the decision below.)
|
||||
|
||||
Builds on **ADR-0009** (export targets are builds producing typed artifacts;
|
||||
"which markdown dialect … stays open") and **ADR-0011** (presentation lives in
|
||||
the template; the manifest is injected). Relates to **ADR-0013** (a non-typst
|
||||
backend could ride a shell step or earn a typed step — still open).
|
||||
|
||||
## Context
|
||||
|
||||
The third-party 恒一小奥 deliverable was a slide-deck markdown bundle
|
||||
(`lesson_slides.md`). A recurring goal is to export an analog of such
|
||||
markdown/HTML from the same engineering file. The hard part is **math**: a
|
||||
curriculum is formula-heavy, and a markdown/HTML export that turns formulas into
|
||||
images (losing selectability, accessibility, and editability) is a regression.
|
||||
Three facts were established (web research + reading the pinned deps), and a
|
||||
fourth is the user's framing:
|
||||
|
||||
1. **typlite (tinymist's markdown export)** renders math **as images** and loses
|
||||
cross-references and styles — it goes through HTML as an intermediary. This is
|
||||
exactly the failure mode to avoid for a math-rich curriculum.
|
||||
2. **typst 0.15 native HTML export** (the version this repo pins; `typst-html
|
||||
0.15` is already in `Cargo.lock`) emits equations as **MathML**. MathML is
|
||||
*accessibility-oriented*: selectable and screen-readable, but **not** trivially
|
||||
convertible to KaTeX/LaTeX, and rendering fidelity varies by browser.
|
||||
3. **`mitex`** converts **LaTeX → typst** (an authoring-time import direction),
|
||||
**not** typst → LaTeX. So it is not, by itself, the typst-source → markdown
|
||||
lever; the user's instinct that "keep the formula source span and convert it"
|
||||
is right about *what* we want, but `mitex`'s direction is the reverse of what a
|
||||
typst→markdown path needs.
|
||||
4. **The user's framing**: the content a markdown/HTML target needs and the
|
||||
content the PDF 讲义/教案 need **do not fully overlap** — so whether a
|
||||
markdown/HTML export must reuse the *same* `typst-content` source, or instead
|
||||
draw on target-specific content, is **genuinely open** and is the pivotal
|
||||
design decision here.
|
||||
|
||||
## Decision (direction, not implementation)
|
||||
|
||||
### The pivotal open decision: shared source vs. target-specific content
|
||||
|
||||
Before any backend is built, decide: **does the markdown/HTML target render the
|
||||
same per-element `typst-content` the PDF targets render, or does it consume
|
||||
target-specific content?** This ADR does **not** pre-pick it; it names it as the
|
||||
gating decision and frames the two routes so the next round can choose
|
||||
deliberately (constitution rule 2 — surface, do not assume):
|
||||
|
||||
- **(R1) Shared typst-content.** One source of truth; markdown/HTML is another
|
||||
projection of the same `.typ`. Requires a real typst-source → markdown/HTML
|
||||
converter that preserves math as editable markup. Highest reuse, hardest math
|
||||
story.
|
||||
- **(R2) Target-specific content.** The element carries (or the target supplies)
|
||||
content authored for the web medium. Decouples the hard typst→markdown math
|
||||
problem from the PDF path, at the cost of a second content surface to maintain.
|
||||
|
||||
### Preferred math direction (whichever route is chosen)
|
||||
|
||||
Math must survive as **markup, not images**. The favored approach is to obtain
|
||||
each equation's **source span** from the typst AST and convert that span's source
|
||||
to LaTeX/KaTeX, rather than accept typlite's image rendering or 0.15's
|
||||
accessibility-MathML as the final form. Concretely, the next round should
|
||||
evaluate:
|
||||
|
||||
- harvesting equation source spans via `typst-syntax` and emitting
|
||||
`$…$`/`$$…$$` (KaTeX-compatible) from the typst math source, and
|
||||
- studying tinymist's own conversion crate (typlite / `cmarker`) for reusable
|
||||
pieces — adopting what helps, but **not** its math-as-image fallback.
|
||||
|
||||
A small **proof-of-concept** scoped to *this* lesson (whose math is trivial —
|
||||
`8×`, `2-`, `12×`) is the right first experiment: it exercises the pipeline
|
||||
end-to-end while sidestepping the hardest formula cases, and shows whether
|
||||
equations come out as text/KaTeX rather than images.
|
||||
|
||||
### Backend mechanics, once the route is chosen
|
||||
|
||||
- typst 0.15 HTML export is reachable today (`typst-html 0.15` is a transitive
|
||||
dep; promotable to a direct dep) — usable for R1's HTML side.
|
||||
- A markdown (rather than HTML) artifact may post-process that HTML, or emit
|
||||
markdown directly from the AST — open, decided with the route.
|
||||
- Per ADR-0013, the backend may run as a typed step or a shell step; whether the
|
||||
non-typst backend earns its own typed `Step` constructor is still open.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The export-backend work has an explicit gating decision (R1 vs. R2) and a
|
||||
pinned math principle (markup, not images), so the next round starts from a
|
||||
decision rather than a blank page.
|
||||
- No implementation, schema, or Lean change lands now — the contract is
|
||||
unchanged; this is a recorded design direction.
|
||||
- The PDF and interactives paths are unaffected and remain the lesson's shipping
|
||||
targets.
|
||||
|
||||
## Open Questions / Deferred
|
||||
|
||||
- **R1 vs. R2** — the pivotal decision above; unresolved by design.
|
||||
- **Concrete math conversion** — span-harvest → KaTeX vs. reusing a tinymist
|
||||
crate; to be settled by the PoC.
|
||||
- **Markdown dialect** — which dialect/fenced-div conventions the markdown
|
||||
artifact targets (ADR-0009 left this open; still open).
|
||||
- **Typed non-typst step** — whether the backend rides `Shell` or earns a typed
|
||||
`Step` (ADR-0013 open question).
|
||||
@@ -6,7 +6,7 @@ import Spec.Courseware.Open
|
||||
/-!
|
||||
# Courseware —— 产品层契约(课程工程文件)
|
||||
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0011。按四组组织:
|
||||
护城河:课程"工程文件"的语义母本与合法性规则。决策出处 ADR-0005..0014。按四组组织:
|
||||
|
||||
- **`Model`** —— 工程文件的内容模型:基元 `Primitives`、富内容 `RichContent`、原子
|
||||
单位 `Element`、单节课 `Lesson`(element 的有序序列)。
|
||||
|
||||
@@ -14,6 +14,17 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
|
||||
|
||||
**渲染覆盖**:ADR-0011 废止了 per-target `RenderRule` 载荷——渲染的"how"已移进模板。
|
||||
契约只保留覆盖声明 `covers`(该 target 渲染哪些 kind),供种子诊断用。
|
||||
|
||||
**shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把
|
||||
`run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配
|
||||
内容)。三条边界是真分歧点,故钉契约:
|
||||
1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生,
|
||||
绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。
|
||||
2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷;
|
||||
因此它**不**进 `Diagnostic` 的 6 类(那 6 类是 lesson 自身的诊断,见 `Check/Diagnostic.lean`),
|
||||
而由 build 执行层报告。诊断分类保持 6 类不变(ADR-0013 显式拒绝新增 `ShellStep` 诊断码)。
|
||||
3. **非-typst target 不过 typst 编译** —— 一个只含 `shell` step 的 target(教具包即此)由
|
||||
执行器跑命令,而非走 typst 引擎;`check` 的 compile 阶段跳过它。
|
||||
-/
|
||||
|
||||
namespace Spec.Courseware
|
||||
@@ -27,7 +38,9 @@ inductive Step where
|
||||
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
|
||||
注入这件事裸 shell 写不出。 -/
|
||||
| typstCompile (template : String)
|
||||
/-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。MVP 不实现,先建结构。 -/
|
||||
/-- shell 逃生口:执行命令 `run`(ADR-0005 (b) 类落这)。**已实现**(ADR-0013):以工程根
|
||||
为 cwd 执行,opt-in(只在显式 build 该 target 时跑,`check` 不跑),失败属 build-过程错误
|
||||
而非 lesson 诊断。教具包(如 KenKen 交互 HTML 由外部 `kendoku` 生成)即走此 step。 -/
|
||||
| shell (run : String)
|
||||
|
||||
/-- 一个 export target 的 build 规格(`PINNED` artifact + 有序 steps, ADR-0011)。 -/
|
||||
|
||||
Reference in New Issue
Block a user