diff --git a/.gitea/workflows/checker-check.yml b/.gitea/workflows/checker-check.yml index a6ce325..89d5bad 100644 --- a/.gitea/workflows/checker-check.yml +++ b/.gitea/workflows/checker-check.yml @@ -19,8 +19,10 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install Rust toolchain (stable + clippy + rustfmt) - uses: dtolnay/rust-toolchain@stable + # Keep local rustup, rustfmt output, clippy, and CI on one reproducible + # 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: components: clippy, rustfmt diff --git a/.scratch/saas-production-readiness/issues/09-restore-checker-format-gate.md b/.scratch/saas-production-readiness/issues/09-restore-checker-format-gate.md index 8533bd2..f9d2823 100644 --- a/.scratch/saas-production-readiness/issues/09-restore-checker-format-gate.md +++ b/.scratch/saas-production-readiness/issues/09-restore-checker-format-gate.md @@ -1,7 +1,7 @@ # Restore the checker formatting gate Type: task -Status: open +Status: resolved ## 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 `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. diff --git a/.scratch/saas-production-readiness/map.md b/.scratch/saas-production-readiness/map.md index 9cb8a65..c79c2e7 100644 --- a/.scratch/saas-production-readiness/map.md +++ b/.scratch/saas-production-readiness/map.md @@ -26,9 +26,9 @@ rollback and recovery, and no known critical security or data-integrity gaps. unrelated Organizations is not an acceptable destination state. - Use the `diagnosing-bugs` loop for every reproduced defect. Prefer explicit failures and observable critical paths over fallback behavior. -- Work on `saas-production-readiness`; make small conventional commits after - verified, independently reversible milestones. Do not push without explicit - authorization. +- Work directly on `main` per developer authorization; make small conventional + commits after verified, independently reversible milestones. Do not push + without explicit authorization. - Local `lake build` currently requires a configured elan toolchain; CI remains the authoritative configured Lean build environment until that local toolchain 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 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. +- [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 diff --git a/README.md b/README.md index c72ae67..75d60ee 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,7 @@ examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 ## CI `.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。 diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index d547846..5cf0371 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -360,8 +360,13 @@ pub fn target_is_shell(root: &Path, target: &str) -> bool { .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 { .. })) + t.steps + .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; 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 { - Command::new("sh").arg("-c").arg(run).current_dir(cwd).output() + Command::new("sh") + .arg("-c") + .arg(run) + .current_dir(cwd) + .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) { - return Err(format!("failed to copy '{}' → '{}': {e}", src.display(), dst.display())); + return Err(format!( + "failed to copy '{}' → '{}': {e}", + src.display(), + dst.display() + )); } } Ok(()) @@ -763,7 +780,8 @@ pub fn target_is_markdown_assemble(root: &Path, target: &str) -> bool { t.steps .iter() .any(|s| matches!(s, cph_model::Step::AssembleMarkdown { .. })) - && !t.steps + && !t + .steps .iter() .any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) }) @@ -833,7 +851,11 @@ fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { lesson .targets .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()) .collect() } diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index a834da2..a7e6398 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -5,7 +5,7 @@ //! fixtures built in a temp dir. The typst Engine is pointed at the repo's real //! `render/` package via `Engine::with_render_dir`. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use cph_diag::DiagCode; 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]` /// 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( tmp.join("manifest.toml"), format!( @@ -264,7 +264,7 @@ template = "exports/handout.typ" /// 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) { +fn write_shell_target_lesson(tmp: &Path, run: &str) { std::fs::write( tmp.join("manifest.toml"), format!( @@ -365,7 +365,10 @@ run = "printf SHOULD_NOT_RUN > build/ran.txt" 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.outcomes.is_empty(), + "no command should run on a broken lesson" + ); assert!(report.check.has_errors()); assert!( !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 /// controls which segments get a `slides.md` (so the "skip absent" path can be /// 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(); for (i, (name, _)) in slides.iter().enumerate() { if i > 0 { @@ -424,10 +427,7 @@ field = "slides" #[test] fn target_is_markdown_assemble_detects_shape() { let tmp = tempdir(); - write_markdown_assemble_target_lesson( - &tmp, - &[("a", "# A"), ("b", "# B")], - ); + write_markdown_assemble_target_lesson(&tmp, &[("a", "# A"), ("b", "# B")]); assert!(cph_check::target_is_markdown_assemble(&tmp, "slides")); // The student target doesn't exist here → falls through to typst path → false. 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(); write_markdown_assemble_target_lesson( &tmp, - &[("intro", "# 规则一\n- 范围"), ("rule2", "# 规则二\n$8\\times$")], + &[ + ("intro", "# 规则一\n- 范围"), + ("rule2", "# 规则二\n$8\\times$"), + ], ); let report = cph_check::run_markdown_assemble_target(&tmp, &engine(), "slides"); @@ -667,6 +670,6 @@ fn tempdir() -> PathBuf { p } -fn cleanup(p: &PathBuf) { +fn cleanup(p: &Path) { let _ = std::fs::remove_dir_all(p); } diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 7cf9be5..c285028 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -51,12 +51,12 @@ enum Command { /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. Completions { /// Which shell to generate completions for. - shell: Shell, + shell: CompletionTarget, }, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] -enum Shell { +enum CompletionTarget { Bash, Zsh, Fish, @@ -82,16 +82,16 @@ fn main() -> ExitCode { /// 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 /// they evolve. -fn run_completions(shell: Shell) -> ExitCode { +fn run_completions(shell: CompletionTarget) -> ExitCode { use clap::CommandFactory; use clap_complete::Shell as CompShell; let sh = match shell { - Shell::Bash => CompShell::Bash, - Shell::Zsh => CompShell::Zsh, - Shell::Fish => CompShell::Fish, - Shell::PowerShell => CompShell::PowerShell, - Shell::Elvish => CompShell::Elvish, + CompletionTarget::Bash => CompShell::Bash, + CompletionTarget::Zsh => CompShell::Zsh, + CompletionTarget::Fish => CompShell::Fish, + CompletionTarget::PowerShell => CompShell::PowerShell, + CompletionTarget::Elvish => CompShell::Elvish, }; let mut cmd = Cli::command(); clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout()); @@ -188,7 +188,10 @@ fn run_build( /// explicit `cph build --target `, 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()); + 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); @@ -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 // shell steps): the diagnostics above explain why. 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 { 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); eprintln!( "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 { - 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 } else { 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 `, /// and never in `check`. 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); print_diagnostics(&report.check); if report.outcomes.is_empty() && !report.ok { 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 { 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 ); 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 { eprintln!("assemble failed: {err}"); @@ -258,7 +280,10 @@ fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: } 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 } else { eprintln!("assemble-markdown target '{target}' failed"); diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 84fc8c2..a5aed91 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -737,7 +737,11 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec) - /// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that /// explicitly renders no kind (every used kind then draws a `renderIgnored` /// warning) — distinct from an absent key. -fn parse_covers(name: &str, value: toml::Value, diags: &mut Vec) -> Option> { +fn parse_covers( + name: &str, + value: toml::Value, + diags: &mut Vec, +) -> Option> { let items = match value { toml::Value::Array(items) => items, _ => { diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 378d00a..64a4d3c 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -324,7 +324,11 @@ fn cph_version_mismatch_is_an_error_diagnostic() { .iter() .filter(|d| d.code == DiagCode::CphVersionMismatch) .collect(); - assert_eq!(mm.len(), 1, "expected one CphVersionMismatch, got {diags:?}"); + assert_eq!( + mm.len(), + 1, + "expected one CphVersionMismatch, got {diags:?}" + ); assert!( mm[0].message.contains("99.99.99") && mm[0].message.contains(CPH_VERSION), "diagnostic should name both versions, got: {}", diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs index 1932b49..5dbe819 100644 --- a/crates/cph-schema/src/lib.rs +++ b/crates/cph-schema/src/lib.rs @@ -526,7 +526,11 @@ mod tests { // ADR-0015: `slides`/`transcript` are markdown content fields (`x-cph-content: "md"`), // distinct from the `.typ` content fields. 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!(!slides.required); let transcript = s diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs index 47fc00e..0d32ce2 100644 --- a/crates/cph-typst/src/embedded.rs +++ b/crates/cph-typst/src/embedded.rs @@ -48,7 +48,8 @@ pub fn resolve_render_dir() -> PathBuf { 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 fallback = + std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); let _ = extract_to(&fallback); fallback }) diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..34986a6 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.92.0" +components = ["clippy", "rustfmt"] +profile = "minimal"