chore: restore checker CI gate

This commit is contained in:
2026-07-10 13:46:22 +08:00
parent 420c1c40bc
commit 5f791c5ce4
12 changed files with 147 additions and 44 deletions
+4 -2
View File
@@ -19,8 +19,10 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain (stable + clippy + rustfmt) # Keep local rustup, rustfmt output, clippy, and CI on one reproducible
uses: dtolnay/rust-toolchain@stable # compiler release. Update this together with rust-toolchain.toml.
- name: Install Rust toolchain (1.92.0 + clippy + rustfmt)
uses: dtolnay/rust-toolchain@1.92.0
with: with:
components: clippy, rustfmt components: clippy, rustfmt
@@ -1,7 +1,7 @@
# Restore the checker formatting gate # Restore the checker formatting gate
Type: task Type: task
Status: open Status: resolved
## Question ## Question
@@ -9,3 +9,32 @@ Apply the repository's configured rustfmt output to the currently reported
Rust files, verify that no semantic changes are introduced, and restore Rust files, verify that no semantic changes are introduced, and restore
`cargo fmt --all --check` so the checker CI gate is green. `cargo fmt --all --check` so the checker CI gate is green.
## Answer
The exact CI command failed deterministically on seven Rust files and an
isolated single-file rustfmt check reproduced the same output. The source
changes were formatting-only, but the underlying gate was not reproducible:
the repository had no `rust-toolchain.toml` and checker CI installed floating
`stable`, so local rustfmt, clippy and CI could change independently.
The checker toolchain is now pinned to Rust 1.92.0 with `rustfmt` and `clippy`
in both `rust-toolchain.toml` and the Gitea workflow. The seven files were
formatted with that toolchain. Running the complete CI path then surfaced three
test helpers accepting `&PathBuf` instead of `&Path` and a Clippy name collision
between `Shell` and `PowerShell`; those were corrected without changing CLI
values or behavior.
Verification:
```text
cargo fmt --all --check PASS (twice)
cargo clippy --all-targets --all-features -- -D warnings
PASS
cargo test --all-features --workspace PASS (53 tests)
cph completions zsh / power-shell PASS
```
The final Rust diff consists of rustfmt layout plus four borrowed-Path
signature generalizations and the internal `Shell``CompletionTarget` type
rename. No expression, string value, command behavior, or public Rust API was
changed.
+4 -3
View File
@@ -26,9 +26,9 @@ rollback and recovery, and no known critical security or data-integrity gaps.
unrelated Organizations is not an acceptable destination state. unrelated Organizations is not an acceptable destination state.
- Use the `diagnosing-bugs` loop for every reproduced defect. Prefer explicit - Use the `diagnosing-bugs` loop for every reproduced defect. Prefer explicit
failures and observable critical paths over fallback behavior. failures and observable critical paths over fallback behavior.
- Work on `saas-production-readiness`; make small conventional commits after - Work directly on `main` per developer authorization; make small conventional
verified, independently reversible milestones. Do not push without explicit commits after verified, independently reversible milestones. Do not push
authorization. without explicit authorization.
- Local `lake build` currently requires a configured elan toolchain; CI remains - Local `lake build` currently requires a configured elan toolchain; CI remains
the authoritative configured Lean build environment until that local toolchain the authoritative configured Lean build environment until that local toolchain
is available. is available.
@@ -43,6 +43,7 @@ rollback and recovery, and no known critical security or data-integrity gaps.
- [Audit backup, migration, and disaster recovery safety](issues/06-audit-backup-migration-recovery.md) — separate persistent state from releases, define a database/workspace checkpoint and recovery objectives, make migrations compatibility-gated, and prove complete off-host backup sets through isolated restore and cutover drills. - [Audit backup, migration, and disaster recovery safety](issues/06-audit-backup-migration-recovery.md) — separate persistent state from releases, define a database/workspace checkpoint and recovery objectives, make migrations compatibility-gated, and prove complete off-host backup sets through isolated restore and cutover drills.
- [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release. - [Audit the accepted product surface for production completeness](issues/07-audit-product-surface-completeness.md) — retain the guarded Organization APIs and single-app Feishu vertical slice, but require explicit Organization-scoped Feishu/provider connections and external-identity namespaces, private platform and Organization admin control planes, and the full ADR-0022 capacity surface before release.
- [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account. - [Decide the platform-administrator identity and audit boundary](issues/42-decide-platform-admin-identity-audit.md) — use a dedicated platform Feishu issuer, one peer administrator role, guarded bootstrap and identity-bound invitations, revocable server-side sessions, atomic append-only Platform Audit, and dual-factor offline recovery without a standing break-glass account.
- [Restore the checker formatting gate](issues/09-restore-checker-format-gate.md) — pin local and CI Rust/rustfmt/clippy to 1.92.0, apply the canonical workspace format, and keep the full fmt + clippy + 53-test checker gate green.
## Fog ## Fog
+4
View File
@@ -72,3 +72,7 @@ examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
## CI ## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。 `.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+29 -7
View File
@@ -360,8 +360,13 @@ pub fn target_is_shell(root: &Path, target: &str) -> bool {
.iter() .iter()
.find(|t| t.name == target) .find(|t| t.name == target)
.is_some_and(|t| { .is_some_and(|t| {
t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. })) t.steps
&& !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) .iter()
.any(|s| matches!(s, cph_model::Step::Shell { .. }))
&& !t
.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
}) })
} }
@@ -370,9 +375,17 @@ fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
use std::process::Command; use std::process::Command;
let output = if cfg!(target_os = "windows") { let output = if cfg!(target_os = "windows") {
Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output() Command::new("cmd")
.arg("/C")
.arg(run)
.current_dir(cwd)
.output()
} else { } else {
Command::new("sh").arg("-c").arg(run).current_dir(cwd).output() Command::new("sh")
.arg("-c")
.arg(run)
.current_dir(cwd)
.output()
}; };
match output { match output {
@@ -704,7 +717,11 @@ fn collect_image_assets(eng_root: &Path, build_root: &Path, body: &str) -> Resul
} }
} }
if let Err(e) = std::fs::copy(&src, &dst) { if let Err(e) = std::fs::copy(&src, &dst) {
return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display())); return Err(format!(
"failed to copy '{}' → '{}': {e}",
src.display(),
dst.display()
));
} }
} }
Ok(()) Ok(())
@@ -763,7 +780,8 @@ pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool {
t.steps t.steps
.iter() .iter()
.any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. })) .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. }))
&& !t.steps && !t
.steps
.iter() .iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) .any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
}) })
@@ -833,7 +851,11 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
lesson lesson
.targets .targets
.iter() .iter()
.filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))) .filter(|t| {
t.steps
.iter()
.any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
.map(|t| t.name.as_str()) .map(|t| t.name.as_str())
.collect() .collect()
} }
+14 -11
View File
@@ -5,7 +5,7 @@
//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real //! fixtures built in a temp dir. The typst Engine is pointed at the repo's real
//! `render/` package via `Engine::with_render_dir`. //! `render/` package via `Engine::with_render_dir`.
use std::path::PathBuf; use std::path::{Path, PathBuf};
use cph_diag::DiagCode; use cph_diag::DiagCode;
use cph_typst::Engine; use cph_typst::Engine;
@@ -138,7 +138,7 @@ path = "segments/does-not-exist"
/// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]` /// Write a throwaway one-segment lesson into `tmp`, with the given `[targets.x]`
/// body spliced in. Returns nothing; the caller runs `check`. /// body spliced in. Returns nothing; the caller runs `check`.
fn write_segment_lesson_with_target(tmp: &PathBuf, target_body: &str) { fn write_segment_lesson_with_target(tmp: &Path, target_body: &str) {
std::fs::write( std::fs::write(
tmp.join("manifest.toml"), tmp.join("manifest.toml"),
format!( format!(
@@ -264,7 +264,7 @@ template = "exports/handout.typ"
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the /// 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`. /// given command. Returns nothing; the caller runs `run_shell_target`.
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) { fn write_shell_target_lesson(tmp: &Path, run: &str) {
std::fs::write( std::fs::write(
tmp.join("manifest.toml"), tmp.join("manifest.toml"),
format!( format!(
@@ -365,7 +365,10 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt"
let report = cph_check::run_shell_target(&tmp, &engine(), "assets"); let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok); assert!(!report.ok);
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson"); assert!(
report.outcomes.is_empty(),
"no command should run on a broken lesson"
);
assert!(report.check.has_errors()); assert!(report.check.has_errors());
assert!( assert!(
!tmp.join("build").join("ran.txt").exists(), !tmp.join("build").join("ran.txt").exists(),
@@ -380,7 +383,7 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt"
/// content file, plus a `slides` target that assembles them. The `which` slice /// content file, plus a `slides` target that assembles them. The `which` slice
/// controls which segments get a `slides.md` (so the "skip absent" path can be /// controls which segments get a `slides.md` (so the "skip absent" path can be
/// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`. /// exercised). Returns nothing; the caller runs `run_markdown_assemble_target`.
fn write_markdown_assemble_target_lesson(tmp: &PathBuf, slides: &[(&str, &str)]) { fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) {
let mut parts = String::new(); let mut parts = String::new();
for (i, (name, _)) in slides.iter().enumerate() { for (i, (name, _)) in slides.iter().enumerate() {
if i > 0 { if i > 0 {
@@ -424,10 +427,7 @@ field = "slides"
#[test] #[test]
fn target_is_markdown_assemble_detects_shape() { fn target_is_markdown_assemble_detects_shape() {
let tmp = tempdir(); let tmp = tempdir();
write_markdown_assemble_target_lesson( write_markdown_assemble_target_lesson(&tmp, &[("a", "# A"), ("b", "# B")]);
&tmp,
&[("a", "# A"), ("b", "# B")],
);
assert!(cph_check::target_is_markdown_assemble(&tmp, "slides")); assert!(cph_check::target_is_markdown_assemble(&tmp, "slides"));
// The student target doesn't exist here → falls through to typst path → false. // The student target doesn't exist here → falls through to typst path → false.
assert!(!cph_check::target_is_markdown_assemble(&tmp, "student")); assert!(!cph_check::target_is_markdown_assemble(&tmp, "student"));
@@ -439,7 +439,10 @@ fn run_markdown_assemble_target_concatenates_in_parts_order() {
let tmp = tempdir(); let tmp = tempdir();
write_markdown_assemble_target_lesson( write_markdown_assemble_target_lesson(
&tmp, &tmp,
&[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")], &[
("intro", "# 规则一\n- 范围"),
("rule2", "# 规则二\n$8\\times$"),
],
); );
let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides");
@@ -667,6 +670,6 @@ fn tempdir() -> PathBuf {
p p
} }
fn cleanup(p: &PathBuf) { fn cleanup(p: &Path) {
let _ = std::fs::remove_dir_all(p); let _ = std::fs::remove_dir_all(p);
} }
+41 -16
View File
@@ -51,12 +51,12 @@ enum Command {
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
Completions { Completions {
/// Which shell to generate completions for. /// Which shell to generate completions for.
shell: Shell, shell: CompletionTarget,
}, },
} }
#[derive(Debug, Clone, Copy, clap::ValueEnum)] #[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum Shell { enum CompletionTarget {
Bash, Bash,
Zsh, Zsh,
Fish, Fish,
@@ -82,16 +82,16 @@ fn main() -> ExitCode {
/// Emit a shell-completion script for `shell` to stdout. The script is built /// Emit a shell-completion script for `shell` to stdout. The script is built
/// from the same `Cli` clap definition above, so it tracks subcommands/flags as /// from the same `Cli` clap definition above, so it tracks subcommands/flags as
/// they evolve. /// they evolve.
fn run_completions(shell: Shell) -> ExitCode { fn run_completions(shell: CompletionTarget) -> ExitCode {
use clap::CommandFactory; use clap::CommandFactory;
use clap_complete::Shell as CompShell; use clap_complete::Shell as CompShell;
let sh = match shell { let sh = match shell {
Shell::Bash => CompShell::Bash, CompletionTarget::Bash => CompShell::Bash,
Shell::Zsh => CompShell::Zsh, CompletionTarget::Zsh => CompShell::Zsh,
Shell::Fish => CompShell::Fish, CompletionTarget::Fish => CompShell::Fish,
Shell::PowerShell => CompShell::PowerShell, CompletionTarget::PowerShell => CompShell::PowerShell,
Shell::Elvish => CompShell::Elvish, CompletionTarget::Elvish => CompShell::Elvish,
}; };
let mut cmd = Cli::command(); let mut cmd = Cli::command();
clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout()); clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout());
@@ -188,7 +188,10 @@ fn run_build(
/// explicit `cph build --target <name>`, and each command is printed before it /// explicit `cph build --target <name>`, and each command is printed before it
/// runs so the user sees exactly what is executed. /// runs so the user sees exactly what is executed.
fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("running shell-step target '{target}' (commands execute in {})", path.display()); eprintln!(
"running shell-step target '{target}' (commands execute in {})",
path.display()
);
let report = cph_check::run_shell_target(path, engine, target); let report = cph_check::run_shell_target(path, engine, target);
print_diagnostics(&report.check); print_diagnostics(&report.check);
@@ -196,7 +199,10 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
// Refused before running anything (check errors / unknown target / no // Refused before running anything (check errors / unknown target / no
// shell steps): the diagnostics above explain why. // shell steps): the diagnostics above explain why.
if report.check.has_errors() { if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count()); eprintln!(
"build refused: {} errors (fix the lesson first)",
report.check.error_count()
);
} else { } else {
eprintln!("build failed: target '{target}' has no shell steps to run"); eprintln!("build failed: target '{target}' has no shell steps to run");
} }
@@ -212,13 +218,19 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
eprint!("{}", outcome.stderr); eprint!("{}", outcome.stderr);
eprintln!( eprintln!(
"step failed (exit {})", "step failed (exit {})",
outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into()) outcome
.status
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into())
); );
} }
} }
if report.ok { if report.ok {
println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len()); println!(
"shell-step target '{target}' completed: {} step(s) ok",
report.outcomes.len()
);
ExitCode::SUCCESS ExitCode::SUCCESS
} else { } else {
eprintln!("shell-step target '{target}' failed"); eprintln!("shell-step target '{target}' failed");
@@ -231,13 +243,19 @@ fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> Exi
/// shell path, this is opt-in — only on an explicit `cph build --target <name>`, /// shell path, this is opt-in — only on an explicit `cph build --target <name>`,
/// and never in `check`. /// and never in `check`.
fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("assembling markdown target '{target}' (reading parts under {})", path.display()); eprintln!(
"assembling markdown target '{target}' (reading parts under {})",
path.display()
);
let report = cph_check::run_markdown_assemble_target(path, engine, target); let report = cph_check::run_markdown_assemble_target(path, engine, target);
print_diagnostics(&report.check); print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok { if report.outcomes.is_empty() && !report.ok {
if report.check.has_errors() { if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count()); eprintln!(
"build refused: {} errors (fix the lesson first)",
report.check.error_count()
);
} else { } else {
eprintln!("build failed: target '{target}' has no assemble-markdown steps to run"); eprintln!("build failed: target '{target}' has no assemble-markdown steps to run");
} }
@@ -250,7 +268,11 @@ fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target:
outcome.field, outcome.parts_read outcome.field, outcome.parts_read
); );
if let Some(out_rel) = &outcome.written { if let Some(out_rel) = &outcome.written {
println!("wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len()); println!(
"wrote {} ({} bytes)",
path.join(out_rel).display(),
outcome.body.len()
);
} }
if let Some(err) = &outcome.error { if let Some(err) = &outcome.error {
eprintln!("assemble failed: {err}"); eprintln!("assemble failed: {err}");
@@ -258,7 +280,10 @@ fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target:
} }
if report.ok { if report.ok {
println!("assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len()); println!(
"assemble-markdown target '{target}' completed: {} step(s) ok",
report.outcomes.len()
);
ExitCode::SUCCESS ExitCode::SUCCESS
} else { } else {
eprintln!("assemble-markdown target '{target}' failed"); eprintln!("assemble-markdown target '{target}' failed");
+5 -1
View File
@@ -737,7 +737,11 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
/// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that /// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that
/// explicitly renders no kind (every used kind then draws a `renderIgnored` /// explicitly renders no kind (every used kind then draws a `renderIgnored`
/// warning) — distinct from an absent key. /// warning) — distinct from an absent key.
fn parse_covers(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Vec<String>> { fn parse_covers(
name: &str,
value: toml::Value,
diags: &mut Vec<Diagnostic>,
) -> Option<Vec<String>> {
let items = match value { let items = match value {
toml::Value::Array(items) => items, toml::Value::Array(items) => items,
_ => { _ => {
+5 -1
View File
@@ -324,7 +324,11 @@ fn cph_version_mismatch_is_an_error_diagnostic() {
.iter() .iter()
.filter(|d| d.code == DiagCode::CphVersionMismatch) .filter(|d| d.code == DiagCode::CphVersionMismatch)
.collect(); .collect();
assert_eq!(mm.len(), 1, "expected one CphVersionMismatch, got {diags:?}"); assert_eq!(
mm.len(),
1,
"expected one CphVersionMismatch, got {diags:?}"
);
assert!( assert!(
mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION), mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION),
"diagnostic should name both versions, got: {}", "diagnostic should name both versions, got: {}",
+5 -1
View File
@@ -526,7 +526,11 @@ mod tests {
// ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`), // ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`),
// distinct from the `.typ` content fields. // distinct from the `.typ` content fields.
let s = schema_for("segment").unwrap(); let s = schema_for("segment").unwrap();
let slides = s.content_fields.iter().find(|f| f.name == "slides").unwrap(); let slides = s
.content_fields
.iter()
.find(|f| f.name == "slides")
.unwrap();
assert_eq!(slides.ext, "md"); assert_eq!(slides.ext, "md");
assert!(!slides.required); assert!(!slides.required);
let transcript = s let transcript = s
+2 -1
View File
@@ -48,7 +48,8 @@ pub fn resolve_render_dir() -> PathBuf {
ensure_extracted().unwrap_or_else(|_| { ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not // Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes. // cached across processes.
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); let fallback =
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback); let _ = extract_to(&fallback);
fallback fallback
}) })
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.92.0"
components = ["clippy", "rustfmt"]
profile = "minimal"