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:
+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 {
|
||||
|
||||
Reference in New Issue
Block a user