From 91b1449236f87b005320dceebfe965fc4ba5fa2e Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:09:23 +0300 Subject: [PATCH 01/12] feat(core): integrate A's source-safe XeLaTeX runner and failure log retention --- crates/tikzarium-cli/Cargo.toml | 2 + crates/tikzarium-cli/src/main.rs | 105 +++++++- crates/tikzarium-core/src/build.rs | 269 ++++++++++++++++--- crates/tikzarium-core/tests/runner_unix.rs | 189 +++++++++++++ insights/2026-10-31_A_W02_RUNNER_EVIDENCE.md | 33 +++ insights/2026-10-31_A_W03_CI_REBASE.md | 18 ++ plans/2026-10-31_A_W02_XELATEX_RUNNER.md | 36 +++ plans/2026-10-31_A_W03_ERROR_DIAGNOSTICS.md | 19 ++ 8 files changed, 629 insertions(+), 42 deletions(-) create mode 100644 crates/tikzarium-core/tests/runner_unix.rs create mode 100644 insights/2026-10-31_A_W02_RUNNER_EVIDENCE.md create mode 100644 insights/2026-10-31_A_W03_CI_REBASE.md create mode 100644 plans/2026-10-31_A_W02_XELATEX_RUNNER.md create mode 100644 plans/2026-10-31_A_W03_ERROR_DIAGNOSTICS.md diff --git a/crates/tikzarium-cli/Cargo.toml b/crates/tikzarium-cli/Cargo.toml index ab5ff95f..2f99d878 100644 --- a/crates/tikzarium-cli/Cargo.toml +++ b/crates/tikzarium-cli/Cargo.toml @@ -4,5 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true + [dependencies] tikzarium-core = { path = "../tikzarium-core" } +tempfile.workspace = true diff --git a/crates/tikzarium-cli/src/main.rs b/crates/tikzarium-cli/src/main.rs index 4d875b7e..753d791d 100644 --- a/crates/tikzarium-cli/src/main.rs +++ b/crates/tikzarium-cli/src/main.rs @@ -1,14 +1,51 @@ -use std::{env, fs, path::PathBuf, process::ExitCode}; +use std::{ + env, fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::ExitCode, +}; use tikzarium_core::{BuildOptions, BuildRequest, BuildService, TexEnvironment}; fn usage() { - eprintln!("Usage: tikzarium-cli doctor | build "); + eprintln!( + "Usage: tikzarium-cli doctor | build [--log ]" + ); +} + +fn safe_pdf_target(source: &Path, destination: &Path) -> bool { + if !destination + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("pdf")) + { + return false; + } + match (source.canonicalize(), destination.canonicalize()) { + (Ok(source), Ok(destination)) => source != destination, + _ => true, + } +} + +fn write_pdf(destination: &Path, pdf: &[u8]) -> io::Result<()> { + let directory = destination + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(directory)?; + let mut temporary = tempfile::NamedTempFile::new_in(directory)?; + temporary.write_all(pdf)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + temporary + .persist(destination) + .map_err(|error| error.error)?; + Ok(()) } fn main() -> ExitCode { let args: Vec = env::args().collect(); match args.get(1).map(String::as_str) { - Some("doctor") => { + Some("doctor") if args.len() == 2 => { let env = TexEnvironment::discover(); println!("XeLaTeX: {:?}", env.xelatex); println!("Version: {:?}", env.xelatex_version); @@ -21,23 +58,48 @@ fn main() -> ExitCode { ExitCode::from(2) } } - Some("build") if args.len() == 4 => { + Some("build") if args.len() == 4 || args.len() == 6 => { + let source = PathBuf::from(&args[2]); + let destination = PathBuf::from(&args[3]); + if !safe_pdf_target(&source, &destination) { + eprintln!("Refusing output path: expected a .pdf that is not the TeX input."); + return ExitCode::from(4); + } + let log_path = if args.len() == 6 && args[4] == "--log" { + let path = PathBuf::from(&args[5]); + if !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("log")) + { + eprintln!("Persistent build log must use the .log extension."); + return ExitCode::from(1); + } + Some(path) + } else if args.len() == 4 { + None + } else { + usage(); + return ExitCode::from(1); + }; + let env = TexEnvironment::discover(); let Some(xelatex) = env.xelatex else { eprintln!("XeLaTeX missing. Install TeX Live/MiKTeX, or set TIKZARIUM_XELATEX."); return ExitCode::from(2); }; let req = BuildRequest { - source: PathBuf::from(&args[2]), + source, xelatex, pdf_renderer: env.pdf_renderer, options: BuildOptions { rasterize_first_page: false, + log_path: log_path.clone(), ..BuildOptions::default() }, }; match BuildService::new().run(&req) { - Ok(artifact) => match fs::write(&args[3], &artifact.pdf) { + Ok(artifact) => match write_pdf(&destination, &artifact.pdf) { Ok(()) => { println!( "Built {} bytes in {:?}; {} dependencies", @@ -45,10 +107,13 @@ fn main() -> ExitCode { artifact.elapsed, artifact.dependencies.len() ); + if let Some(log) = log_path { + println!("Persistent TeX log: {}", log.display()); + } ExitCode::SUCCESS } Err(error) => { - eprintln!("Cannot write output: {error}"); + eprintln!("Cannot atomically publish PDF: {error}"); ExitCode::from(4) } }, @@ -64,3 +129,29 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn never_overwrite_input_or_non_pdf_with_compiled_bytes() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("example.tex"); + fs::write(&source, "source").unwrap(); + assert!(!safe_pdf_target(&source, &source)); + assert!(!safe_pdf_target(&source, &dir.path().join("included.tex"))); + assert!(safe_pdf_target(&source, &dir.path().join("result.pdf"))); + } + + #[test] + fn atomic_pdf_write_does_not_mutate_source() { + let dir = tempfile::tempdir().unwrap(); + let original = dir.path().join("original.tex"); + fs::write(&original, b"% original\r\n").unwrap(); + let output = dir.path().join("result.pdf"); + write_pdf(&output, b"%PDF-1.4\n").unwrap(); + assert_eq!(fs::read(&original).unwrap(), b"% original\r\n"); + assert_eq!(fs::read(&output).unwrap(), b"%PDF-1.4\n"); + } +} diff --git a/crates/tikzarium-core/src/build.rs b/crates/tikzarium-core/src/build.rs index ef1292d6..1a3b0659 100644 --- a/crates/tikzarium-core/src/build.rs +++ b/crates/tikzarium-core/src/build.rs @@ -1,13 +1,14 @@ -//! XeLaTeX build isolation and cancellation. NOT a full OS security sandbox. +//! Direct XeLaTeX runner. -no-shell-escape is not an OS sandbox. use crate::{ - diagnostics::{BuildDiagnostic, extract_diagnostics}, + diagnostics::{BuildDiagnostic, Severity, extract_diagnostics}, preview::{Preview, render_first_page}, }; use std::{ + collections::HashSet, fs::{self, File}, - io, + io::{self, Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, - process::{Command, Stdio}, + process::{Child, Command, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -21,12 +22,16 @@ use thiserror::Error; pub struct BuildOptions { pub timeout: Duration, pub rasterize_first_page: bool, + /// Optional persistent build log. If absent, temporary TeX output is removed. + pub log_path: Option, } + impl Default for BuildOptions { fn default() -> Self { Self { timeout: Duration::from_secs(120), rasterize_first_page: true, + log_path: None, } } } @@ -60,25 +65,64 @@ pub enum BuildError { #[error("XeLaTeX exceeded timeout of {0:?}")] Timeout(Duration), #[error("XeLaTeX exited with {code:?}: {details}")] - TeXFailure { code: Option, details: String }, + TeXFailure { + code: Option, + details: String, + /// Bounded in-memory TeX log for GUI error navigation (not the full persistent log). + log_excerpt: String, + diagnostics: Vec, + }, #[error("Compiled PDF missing: {0}")] MissingPdf(PathBuf), + #[error("Build log path would overwrite the TeX source: {0}")] + UnsafeLogPath(PathBuf), + #[error("Cannot save XeLaTeX log to {path}: {cause}")] + LogWrite { + path: PathBuf, + #[source] + cause: io::Error, + }, #[error("I/O: {0}")] Io(#[from] io::Error), } +impl BuildError { + /// Structured diagnostics for failed XeLaTeX runs, ready for source navigation. + /// Other errors have no TeX diagnostics. + #[must_use] + pub fn diagnostics(&self) -> &[BuildDiagnostic] { + match self { + Self::TeXFailure { diagnostics, .. } => diagnostics, + _ => &[], + } + } + + /// A bounded text excerpt from the engine's log, if the engine exited in error. + /// Use BuildOptions::log_path to retain the entire .log file. + #[must_use] + pub fn log_excerpt(&self) -> Option<&str> { + match self { + Self::TeXFailure { log_excerpt, .. } => Some(log_excerpt), + _ => None, + } + } +} + #[derive(Clone, Default)] pub struct BuildService { cancelled: Arc, } + impl BuildService { #[must_use] pub fn new() -> Self { Self::default() } + pub fn cancel(&self) { self.cancelled.store(true, Ordering::SeqCst); } + pub fn run(&self, request: &BuildRequest) -> Result { if !request.source.is_file() { return Err(BuildError::MissingSource(request.source.clone())); @@ -89,8 +133,16 @@ impl BuildService { if self.cancelled.load(Ordering::SeqCst) { return Err(BuildError::Cancelled); } + let started = Instant::now(); let source = request.source.canonicalize()?; + if let Some(destination) = request.options.log_path.as_deref() + && destination.exists() + && destination.canonicalize()? == source + { + return Err(BuildError::UnsafeLogPath(destination.to_path_buf())); + } + let cwd = source.parent().unwrap_or_else(|| Path::new(".")); let filename = source .file_name() @@ -100,6 +152,9 @@ impl BuildService { let stderr_file = temp.path().join("xelatex.stderr.log"); let out_dir = temp.path().join("out"); fs::create_dir_all(&out_dir)?; + + // A relative ./ prefix prevents a source named -shell-escape.tex from + // being interpreted as another XeLaTeX command-line option. let mut child = Command::new(&request.xelatex) .current_dir(cwd) .arg("-interaction=nonstopmode") @@ -108,59 +163,75 @@ impl BuildService { .arg("-recorder") .arg("-no-shell-escape") .arg(format!("-output-directory={}", out_dir.display())) - .arg(filename) + .arg(Path::new(".").join(filename)) .stdin(Stdio::null()) .stdout(Stdio::from(File::create(&stdout_file)?)) .stderr(Stdio::from(File::create(&stderr_file)?)) .spawn()?; + + // Retain the entire source name before replacing its final extension: + // "figure.v2.tex" must generate "figure.v2.pdf", not "figure.pdf". + let job_path = out_dir.join(filename); + let log_path = job_path.with_extension("log"); + let pdf_path = job_path.with_extension("pdf"); + let fls_path = job_path.with_extension("fls"); + let status = loop { if self.cancelled.load(Ordering::SeqCst) { - let _ = child.kill(); - let _ = child.wait(); + terminate_child(&mut child); + let _ = save_log(&log_path, &stdout_file, request.options.log_path.as_deref()); return Err(BuildError::Cancelled); } if started.elapsed() > request.options.timeout { - let _ = child.kill(); - let _ = child.wait(); + terminate_child(&mut child); + let _ = save_log(&log_path, &stdout_file, request.options.log_path.as_deref()); return Err(BuildError::Timeout(request.options.timeout)); } - if let Some(status) = child.try_wait()? { - break status; + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => thread::sleep(Duration::from_millis(60)), + Err(error) => { + terminate_child(&mut child); + return Err(error.into()); + } } - thread::sleep(Duration::from_millis(60)); }; - let stem = source - .file_stem() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "No source stem"))?; - let log_path = out_dir.join(stem).with_extension("log"); + let mut log = read_capped(&log_path, 1_000_000).unwrap_or_default(); if log.is_empty() { log = read_capped(&stdout_file, 1_000_000).unwrap_or_default(); } + let stderr = read_capped(&stderr_file, 8_000).unwrap_or_default(); + let log_saved = save_log(&log_path, &stdout_file, request.options.log_path.as_deref()); if !status.success() { - let stderr = read_capped(&stderr_file, 4000).unwrap_or_default(); - let details = if stderr.trim().is_empty() { - tail(&log, 3000) - } else { - stderr - }; + let diagnostics = extract_diagnostics(&log); + let mut details = failure_details(&log, &stderr); + if let Err(error) = log_saved { + details.push_str(&format!("\nLog persistence also failed: {error}")); + } return Err(BuildError::TeXFailure { code: status.code(), details, + log_excerpt: log, + diagnostics, }); } - let pdf_path = out_dir.join(stem).with_extension("pdf"); + log_saved?; + if !pdf_path.is_file() { return Err(BuildError::MissingPdf(pdf_path)); } let pdf = fs::read(&pdf_path)?; - let fls_path = out_dir.join(stem).with_extension("fls"); - let deps = parse_fls(&fs::read_to_string(fls_path).unwrap_or_default()); + let dependencies = + parse_fls_with_cwd(&fs::read_to_string(&fls_path).unwrap_or_default(), cwd) + .into_iter() + .filter(|path| !path.starts_with(&out_dir)) + .collect(); let preview = if request.options.rasterize_first_page { match &request.pdf_renderer { Some(renderer) => render_first_page(renderer, &pdf_path) .map(Some) - .map_err(|e| e.to_string()), + .map_err(|error| error.to_string()), None => Ok(None), } } else { @@ -170,7 +241,7 @@ impl BuildService { Ok(BuildArtifact { pdf, log, - dependencies: deps, + dependencies, diagnostics, preview, elapsed: started.elapsed(), @@ -178,24 +249,136 @@ impl BuildService { } } +fn terminate_child(child: &mut Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn save_log(primary: &Path, stdout: &Path, destination: Option<&Path>) -> Result<(), BuildError> { + let Some(destination) = destination else { + return Ok(()); + }; + let directory = destination + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let result = (|| -> io::Result<()> { + fs::create_dir_all(directory)?; + let mut temp = tempfile::NamedTempFile::new_in(directory)?; + let source = if primary.is_file() { primary } else { stdout }; + io::copy(&mut File::open(source)?, &mut temp)?; + temp.flush()?; + temp.as_file().sync_all()?; + temp.persist(destination).map_err(|error| error.error)?; + Ok(()) + })(); + result.map_err(|cause| BuildError::LogWrite { + path: destination.to_path_buf(), + cause, + }) +} + +fn failure_details(log: &str, stderr: &str) -> String { + let mut text = String::new(); + if let Some(diagnostic) = extract_diagnostics(log) + .into_iter() + .find(|item| item.severity == Severity::Error) + { + if let Some(path) = diagnostic.source { + text.push_str(&path.display().to_string()); + if let Some(line) = diagnostic.line { + text.push_str(&format!(":{line}")); + } + text.push_str(": "); + } else if let Some(line) = diagnostic.line { + text.push_str(&format!("line {line}: ")); + } + text.push_str(&diagnostic.message); + text.push('\n'); + } + if !stderr.trim().is_empty() { + text.push_str("stderr:\n"); + text.push_str(stderr.trim()); + text.push('\n'); + } + if !log.trim().is_empty() { + text.push_str("TeX log tail:\n"); + text.push_str(&tail(log, 6_000)); + } + if text.is_empty() { + text.push_str("Compiler returned a failure without a readable TeX log"); + } + text +} + +/// Return the raw INPUT paths as recorded, without interpreting PWD. #[must_use] pub fn parse_fls(log: &str) -> Vec { let mut paths = Vec::new(); + let mut seen = HashSet::new(); for line in log.lines() { - if let Some(path) = line.strip_prefix("INPUT ") { - let path = PathBuf::from(path); - if !paths.contains(&path) { - paths.push(path); + if let Some(value) = line.strip_prefix("INPUT ") { + let value = value.trim_end_matches('\r'); + if !value.is_empty() { + let path = PathBuf::from(value); + if seen.insert(path.clone()) { + paths.push(path); + } + } + } + } + paths +} + +/// Resolve INPUT records against recorder PWD (or the document directory). +/// This does not claim that external TeX packages are project-local files. +#[must_use] +pub fn parse_fls_with_cwd(log: &str, cwd: &Path) -> Vec { + let base = log + .lines() + .find_map(|line| line.strip_prefix("PWD ")) + .map(|value| PathBuf::from(value.trim_end_matches('\r'))) + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| { + if path.is_absolute() { + path + } else { + cwd.join(path) } + }) + .unwrap_or_else(|| cwd.to_path_buf()); + + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for path in parse_fls(log) { + let absolute = if path.is_absolute() { + path + } else { + base.join(path) + }; + let resolved = absolute.canonicalize().unwrap_or(absolute); + if seen.insert(resolved.clone()) { + paths.push(resolved); } } paths } + fn read_capped(path: &Path, cap: usize) -> io::Result { - let contents = fs::read(path)?; - let start = contents.len().saturating_sub(cap); - Ok(String::from_utf8_lossy(&contents[start..]).into_owned()) + let mut file = File::open(path)?; + let size = file.metadata()?.len(); + let start = size.saturating_sub(cap as u64); + file.seek(SeekFrom::Start(start))?; + let mut contents = Vec::new(); + file.take(cap as u64).read_to_end(&mut contents)?; + if start > 0 + && let Some(newline) = contents.iter().position(|byte| *byte == b'\n') + { + contents.drain(..=newline); + } + Ok(String::from_utf8_lossy(&contents).into_owned()) } + fn tail(text: &str, max: usize) -> String { let start = text.len().saturating_sub(max); String::from_utf8_lossy(&text.as_bytes()[start..]).into_owned() @@ -204,6 +387,7 @@ fn tail(text: &str, max: usize) -> String { #[cfg(test)] mod tests { use super::*; + #[test] fn dependencies_are_deduplicated() { let parsed = parse_fls( @@ -211,6 +395,20 @@ mod tests { ); assert_eq!(parsed.len(), 2); } + + #[test] + fn recorder_pwd_resolves_relative_paths() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("a.tex"); + fs::write(&source, "% test").unwrap(); + let contents = format!( + "PWD {}\nINPUT ./a.tex\nINPUT ./a.tex\n", + directory.path().display() + ); + let paths = parse_fls_with_cwd(&contents, directory.path()); + assert_eq!(paths, vec![source.canonicalize().unwrap()]); + } + #[test] fn missing_source_is_reported() { let dir = tempfile::tempdir().unwrap(); @@ -225,6 +423,7 @@ mod tests { Err(BuildError::MissingSource(_)) )); } + #[test] fn cancelled_job_never_launches_engine() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tikzarium-core/tests/runner_unix.rs b/crates/tikzarium-core/tests/runner_unix.rs new file mode 100644 index 00000000..4dafb399 --- /dev/null +++ b/crates/tikzarium-core/tests/runner_unix.rs @@ -0,0 +1,189 @@ +#![cfg(unix)] +//! Fake executable integration tests. The production runner never invokes a shell. +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + thread, + time::{Duration, Instant}, +}; +use tikzarium_core::{BuildError, BuildOptions, BuildRequest, BuildService, Severity}; + +fn fake_xelatex(directory: &Path) -> PathBuf { + let executable = directory.join("fake xelatex"); + let script = r#"#!/bin/sh +set -eu +out="" +last="" +for arg in "$@"; do + case "$arg" in + -output-directory=*) out=$(printf '%s' "$arg" | sed 's/^-output-directory=//') ;; + *) last="$arg" ;; + esac +done +name=$(basename "$last") +stem=$(printf '%s' "$name" | sed 's/\.tex$//') +case "$name" in + broken.tex) + printf '! Undefined control sequence.\nl.9 \\badmacro\n' > "$out/$stem.log" + printf 'simulated engine failure\n' >&2 + exit 1 + ;; + slow.tex) + printf 'Starting slow compilation\n' > "$out/$stem.log" + printf 'ready\n' > "$PWD/fake-engine-ready" + while :; do :; done + ;; +esac +printf 'LaTeX Warning: Integration fixture.\nCommand: %s\n' "$*" > "$out/$stem.log" +printf 'PWD %s\nINPUT ./%s\nINPUT ./assets/shape.tex\nINPUT %s/%s.aux\n' "$PWD" "$name" "$out" "$stem" > "$out/$stem.fls" +printf '%%PDF-1.4\n' > "$out/$stem.pdf" +"#; + fs::write(&executable, script).unwrap(); + let mut permissions = fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&executable, permissions).unwrap(); + executable +} + +fn request(source: &Path, engine: &Path) -> BuildRequest { + BuildRequest { + source: source.to_path_buf(), + xelatex: engine.to_path_buf(), + pdf_renderer: None, + options: BuildOptions { + rasterize_first_page: false, + ..BuildOptions::default() + }, + } +} + +fn wait_for_engine_start(directory: &Path) { + let marker = directory.join("fake-engine-ready"); + let started = Instant::now(); + while !marker.exists() && started.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(25)); + } + assert!(marker.exists(), "fake engine did not start"); +} + +#[test] +fn dotted_job_name_dependencies_and_direct_argv_are_preserved() { + let directory = tempfile::tempdir().unwrap(); + let assets = directory.path().join("assets"); + fs::create_dir_all(&assets).unwrap(); + fs::write(assets.join("shape.tex"), "% shape").unwrap(); + let source = directory.path().join("figure.v2.tex"); + fs::write(&source, "% source remains unchanged\n").unwrap(); + let original = fs::read(&source).unwrap(); + let engine = fake_xelatex(directory.path()); + + let artifact = BuildService::new().run(&request(&source, &engine)).unwrap(); + assert_eq!(artifact.pdf, b"%PDF-1.4\n"); + assert_eq!(fs::read(&source).unwrap(), original); + assert!(artifact.log.contains("-no-shell-escape")); + assert!( + artifact + .diagnostics + .iter() + .any(|item| item.severity == Severity::Warning) + ); + assert!( + artifact + .dependencies + .contains(&source.canonicalize().unwrap()) + ); + assert!( + artifact + .dependencies + .contains(&assets.join("shape.tex").canonicalize().unwrap()) + ); + assert_eq!(artifact.dependencies.len(), 2); +} + +#[test] +fn leading_dash_source_is_a_file_argument_not_a_tex_option() { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir_all(directory.path().join("assets")).unwrap(); + fs::write(directory.path().join("assets/shape.tex"), "").unwrap(); + let source = directory.path().join("-shell-escape.tex"); + fs::write(&source, "% sensitive filename").unwrap(); + let engine = fake_xelatex(directory.path()); + + let artifact = BuildService::new().run(&request(&source, &engine)).unwrap(); + assert!(artifact.log.contains("./-shell-escape.tex")); + assert_eq!(fs::read_to_string(&source).unwrap(), "% sensitive filename"); +} + +#[test] +fn failure_reports_source_line_and_retains_full_log_on_request() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("broken.tex"); + fs::write(&source, "\\badmacro\n").unwrap(); + let engine = fake_xelatex(directory.path()); + let log_path = directory.path().join("diagnostics.log"); + let mut build = request(&source, &engine); + build.options.log_path = Some(log_path.clone()); + + let failure = BuildService::new().run(&build).unwrap_err(); + assert!(matches!(failure, BuildError::TeXFailure { .. })); + assert!(failure.to_string().contains("line 9")); + assert!(failure.to_string().contains("simulated engine failure")); + assert!( + failure + .log_excerpt() + .unwrap() + .contains("Undefined control sequence") + ); + assert!( + failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error && diagnostic.line == Some(9)) + ); + assert!( + fs::read_to_string(&log_path) + .unwrap() + .contains("Undefined control sequence") + ); + assert_eq!(fs::read_to_string(&source).unwrap(), "\\badmacro\n"); +} + +#[test] +fn timeout_retains_partial_log() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("slow.tex"); + fs::write(&source, "% wait").unwrap(); + let engine = fake_xelatex(directory.path()); + let log_path = directory.path().join("slow-run.log"); + let mut build = request(&source, &engine); + build.options.timeout = Duration::from_secs(2); + build.options.log_path = Some(log_path.clone()); + + assert!(matches!( + BuildService::new().run(&build), + Err(BuildError::Timeout(_)) + )); + assert!(directory.path().join("fake-engine-ready").exists()); + assert!( + fs::read_to_string(log_path) + .unwrap() + .contains("Starting slow compilation") + ); +} + +#[test] +fn cancelling_active_compilation_stops_the_child() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("slow.tex"); + fs::write(&source, "% wait").unwrap(); + let engine = fake_xelatex(directory.path()); + let mut build = request(&source, &engine); + build.options.timeout = Duration::from_secs(10); + let service = BuildService::new(); + let worker = service.clone(); + let thread = thread::spawn(move || worker.run(&build)); + wait_for_engine_start(directory.path()); + service.cancel(); + assert!(matches!(thread.join().unwrap(), Err(BuildError::Cancelled))); +} diff --git a/insights/2026-10-31_A_W02_RUNNER_EVIDENCE.md b/insights/2026-10-31_A_W02_RUNNER_EVIDENCE.md new file mode 100644 index 00000000..040d6d1a --- /dev/null +++ b/insights/2026-10-31_A_W02_RUNNER_EVIDENCE.md @@ -0,0 +1,33 @@ +# Lane A — W02 engineering evidence and handoff + +Date: 2026-10-31. Scope: core and CLI only. Issue #2. + +## Findings and fixes implemented + +- W01 mistakenly replaced the final extension of the stem of a multi-dot TeX filename. In a real local Linux TeX Live 2025/dev experiment, figure.v2.tex produced figure.v2.aux, figure.v2.fls, figure.v2.log and figure.v2.pdf. W02 reads these exact names. +- An input named -shell-escape.tex must not arrive as a bare option-like argument: use an explicit ./ (or platform equivalent) relative filename. Command remains direct, no shell. +- Earlier error paths returned a short stderr/tail and deleted build logs. W02 adds a requested persistent .log, retains the TeX error summary plus stderr and log tail, and keeps partial logs when cancellation/timeout occurs if write succeeds. +- The recorder's PWD and relative INPUT records must be resolved; generated .aux files from the disposable output directory are not external dependencies. +- CLI output target should not overwrite a source file or be partially published. PDF publication now uses a temporary file in the target directory; extension restriction is deliberately conservative. +- TeX file-line-error diagnostics require support for Windows drive letters, filenames with dots and spaces, plus l.N fallback markers. + +## Evidence available at authoring time + +- GitHub W01 branch format automation completed successfully: https://github.com/i-shutov/tikzarium/actions/runs/41169197653 +- W01 older commit Rust native jobs failed at formatting; check and test were skipped, not PASS: https://github.com/i-shutov/tikzarium/actions/runs/41169201065 +- W01 initial Linux XeLaTeX fixture CI succeeded before this W02 code: https://github.com/i-shutov/tikzarium/actions/runs/41169131617 +- Local environment is Linux x86_64, XeTeX 3.141592653-2.6-0.999996 (TeX Live 2025/dev/Debian). A local direct XeLaTeX multi-dot-name output test completed. This does not test the new Rust runner. +- Rust compiler and cargo are absent in this execution sandbox. Neither W02 cargo compilation nor its new tests can be declared PASS until GitHub CI completes. +- Physical Windows 10 x64 and Apple Silicon M1/M4 acceptance remain OPEN. + +## Open technical risks + +- Cannot currently cancel or time-limit external PDF rasterization. Requires a separate future bounded runner. +- A user-supplied TeX document is not sandboxed merely because shell escape is disabled. +- On cancellation and timeout, failure to write an optional retained log is best effort and does not replace the original cancellation/timeout error. +- Recorder captures dependencies it observes; dynamic generation, shell commands disabled by policy and runtime external tooling remain outside this small workpack. +- Desktop GUI still uses transient logs unless B independently opts in to BuildOptions.log_path. This PR intentionally does not modify B files. + +## Handoff + +Integrator should review the stacked diff, inspect actual W02 GitHub Actions by exact SHA and merge W01 first. Rebase/cherry-pick only after checking B/C/D branch overlaps. Do not report Windows 10 or physical Mac smoke from hosted CI alone. diff --git a/insights/2026-10-31_A_W03_CI_REBASE.md b/insights/2026-10-31_A_W03_CI_REBASE.md new file mode 100644 index 00000000..b3657c94 --- /dev/null +++ b/insights/2026-10-31_A_W03_CI_REBASE.md @@ -0,0 +1,18 @@ +# A / W03 — error diagnostics and stale-base integration evidence + +Date: 2026-10-31. Owner A. PR #10 remains draft. + +## Root cause +The W02 overall CI failure at A SHA `7937ffa9542584ce35d973058010ea36eb38146d` was not a core/CLI compilation failure. Both macOS-15 ARM hosted and windows-2022 hosted jobs completed rustfmt/check/tests successfully, then failed Clippy on B GUI `crates/tikzarium-app/src/main.rs:283` (`collapsible_if`). Linux real XeLaTeX smoke succeeded. Direct evidence: https://github.com/i-shutov/tikzarium/actions/runs/41169804989 + +W01 was subsequently updated at merge commit `3e3de6a1eeb5a268f296cf9aa6a4e93b2d215f7e` by the integrator. Its CI completed successfully: https://github.com/i-shutov/tikzarium/actions/runs/41170856043 and https://github.com/i-shutov/tikzarium/actions/runs/41170859719. A merge into the A branch retains upstream GUI/extension/QA files; no A edit to those files. + +## Contract and evidence +- `BuildError::TeXFailure` retains stable human `details` / `code` and now provides `diagnostics` with file and line, plus up to the in-memory log cap through `log_excerpt`. +- The accessor returns an empty slice / None for non-TeX errors; consumers cannot mistake a timeout for a TeX compiler error. +- The original `.log` can still be saved verbatim at `BuildOptions.log_path`; the excerpt is explicitly NOT guaranteed to contain the entire long TeX log. +- Failure integration fixture validates line 9, log excerpt, persisted full TeX log and byte-identical input. +- Rust compiler absent in current execution sandbox; tests at the new SHA must be judged only from fresh GitHub CI, not assumed from a clean parent branch. + +## Known limitations +This is not a process-tree sandbox. `-no-shell-escape` does not prevent arbitrary file access by TeX. B GUI has not yet wired click-to-error from the new error API. macOS hosted build and Windows Server CI are NOT physical M1/M4 or Windows 10 GUI smoke tests. Keep the PR draft until all CI evidence is checked. diff --git a/plans/2026-10-31_A_W02_XELATEX_RUNNER.md b/plans/2026-10-31_A_W02_XELATEX_RUNNER.md new file mode 100644 index 00000000..78752b72 --- /dev/null +++ b/plans/2026-10-31_A_W02_XELATEX_RUNNER.md @@ -0,0 +1,36 @@ +# A / W02 — reliable XeLaTeX runner, source-safe CLI and failure evidence + +Date: 2026-10-31. Owner: lane A, issue #2. +Base main SHA: 4ceca871fc2c3b661f92d64f9c6b39b473cebd0a. +Dependency: W01 draft PR #6, branch feat/w01-xelatex-egui-core at ed8ff4f316afea97c8092aab3f65c6509ddcbdfe. +Integration: this is a stacked A-only PR, not a change to main or any B/C/D files. + +## Explicit scope and acceptance + +1. Execute XeLaTeX via Command::new only, retain -no-shell-escape and -recorder, protect filenames beginning with a dash by passing a relative path. +2. Preserve multi-dot TeX job names in log, recorder and PDF paths; keep canonical original source entirely unchanged. +3. Preserve full XeLaTeX .log to a caller-selected destination, including on TeX failure and best effort on cancel/timeout. Without a requested destination, transient logs remain in the temporary build directory and are discarded after the run. +4. Present actionable failure information from TeX log and stderr, with file/line diagnostics for both file-line-error and l.N forms. +5. Resolve .fls INPUT paths relative to recorder PWD (or the source directory); remove temporary output-directory artifacts from the returned dependency set. +6. CLI accepts optional --log destination.log; reject non-PDF output paths and publish PDFs atomically, never replace a TeX input with PDF bytes. +7. Add Unix fake-executable integration tests for safe argv, filename regression, recorder, failure and log, timeout and cancel. Add cross-platform pure Rust unit tests for path/diagnostic logic. +8. Preserve B GUI API expectations: BuildService::run and BuildError::TeXFailure existing fields remain; optional BuildOptions.log_path defaults to None. + +## Gates and follow-up + +- Rust format, compilation, unit/integration tests, Clippy and real CLI XeLaTeX smoke: require GitHub Actions evidence; not proven by editing. +- Native macOS ARM64 CI is a build gate, not a physical M1/M4 UI test. +- Windows-2022 CI is not a Windows 10 smoke test. +- Temporary sandbox is not an OS security boundary. Cancellation kills the direct child; recursive subprocess-tree termination is not guaranteed. +- This workpack does not introduce visual editing, package compatibility claims, live log streaming, a persistent cache, or changes to the GUI. + +## Reproduction + +cargo fmt --all -- --check +cargo check --workspace --all-targets +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo run -p tikzarium-cli -- build examples/quickstart.tex /tmp/quickstart.pdf --log /tmp/quickstart.log +cargo run -p tikzarium-cli -- build examples/cyrillic.tex /tmp/cyrillic.pdf + +On Windows, replace /tmp paths with a writable local directory. TeX Live or MiKTeX with XeLaTeX must be installed for real smoke tests. diff --git a/plans/2026-10-31_A_W03_ERROR_DIAGNOSTICS.md b/plans/2026-10-31_A_W03_ERROR_DIAGNOSTICS.md new file mode 100644 index 00000000..bd2db8b4 --- /dev/null +++ b/plans/2026-10-31_A_W03_ERROR_DIAGNOSTICS.md @@ -0,0 +1,19 @@ +# A / W03 — structured failed-build diagnostics and upstream integration + +Date: 2026-10-31. Lane A, issue #2; stacked PR #10 against W01 #6. + +## Vertical problem +A/W02 saves the engine log on failure, but its `BuildError::TeXFailure` only exposes a formatted `details` string. The GUI cannot reliably navigate to a failed source location without reparsing that concatenated human-oriented error. Concurrent W01 integration also fixed an inherited B GUI Clippy lint, but W02 was tested against an older W01 SHA and must be reconciled without editing B/C/D code. + +## Implementation +1. Retain previous `code` and `details`, add a bounded `log_excerpt` and structured `diagnostics` to `TeXFailure`. Accessors `BuildError::diagnostics()` and `log_excerpt()` return empty/None on non-TeX errors. The complete log still requires optional `BuildOptions.log_path`. +2. Update the Unix simulated compiler failure fixture to check the error carries TeX source line 9 and original log content; verify source and persisted log stay untouched/present. +3. Merge the latest W01 branch into A (two-parent merge) with W01's B/C/D files retained byte-for-byte and the A-only core/CLI, tests and plans/insights overlaid. Do not edit GUI or independent QA assets. +4. Require fresh hosted GitHub CI at the new A head: Linux real XeLaTeX fixture; Windows Server rustfmt/check/tests/Clippy/release; macOS ARM64 native rustfmt/check/tests/Clippy/release. The Windows Server result is not Windows 10 acceptance. + +## QA and remaining gates +- Before this change, A HEAD `7937ffa9542584ce35d973058010ea36eb38146d`, Actions 41169804989: Linux real fixtures PASS, hosted Windows/macOS cargo fmt/check/tests PASS, Clippy FAIL in inherited B GUI file line 283; release binaries skipped. +- W01 upstream HEAD `3e3de6a1eeb5a268f296cf9aa6a4e93b2d215f7e`, Actions 41170856043 and 41170859719 succeeded after independent integration. This does not prove W03. +- New A HEAD must be checked separately; no local Rust compiler is installed in this environment. +- User Windows 10 x64 and physical Mac M1/M4 GUI/platform tests remain NOT_RUN. +- Core API extension exposes error context but B GUI does not consume it in this PR; follow-up B integration is required for click-to-error behavior. From 3525e50d4be7590214934a667b7712e37726a9fc Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:10:11 +0300 Subject: [PATCH 02/12] feat(integration): merge source-aware TeX errors with font warnings and GUI diagnostics --- crates/tikzarium-app/src/main.rs | 47 +++++--- crates/tikzarium-app/src/ui_state.rs | 33 ++++++ crates/tikzarium-core/src/diagnostics.rs | 144 ++++++++++++++++++----- crates/tikzarium-core/src/lib.rs | 2 +- 4 files changed, 179 insertions(+), 47 deletions(-) diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index 89d46642..307aef3c 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -18,7 +18,7 @@ mod ui_state; mod ui_theme; use ui_state::{ BottomView, EditorCommand, WorkspaceMode, editor_command, line_start_char_index, - preview_is_stale, scaled_zoom, + preview_is_stale, scaled_zoom, diagnostic_targets_current_file, }; use font_proof::{FontProofResult, spawn_font_proof}; @@ -345,20 +345,25 @@ impl TikzariumApp { } Err(error) => { self.last_build_failed = true; - self.diagnostics = vec![BuildDiagnostic { - severity: Severity::Error, - message: error.to_string(), - line: None, - }]; + self.diagnostics = error.diagnostics().to_vec(); + if self.diagnostics.is_empty() { + self.diagnostics = vec![BuildDiagnostic { + severity: Severity::Error, + message: error.to_string(), + source: None, + line: None, + }]; + } self.bottom_view = BottomView::Issues; - self.show_log = true; - self.log = error.to_string(); + self.show_log = error.log_excerpt().map(str::to_owned).unwrap_or_else(|| error.to_string()); self.preview_status = if self.pdf.is_some() { "Build failed — last good PDF retained (stale)".into() } else { "Build failed — no PDF available".into() }; - self.status = format!("Build failed: {error}"); + let summary = error.to_string(); + let first_line = summary.lines().next().unwrap_or("XeLaTeX error"); + self.status = format!("Build failed: {}", first_line.chars().take(160).collect::()); } } } @@ -957,21 +962,29 @@ impl eframe::App for TikzariumApp { Severity::Error => "ERROR", Severity::Warning => "WARNING", }; - let label = match item.line { - Some(line) => { - format!("{severity} · line {line} · {}", item.message) - } - None => format!("{severity} · {}", item.message), + let location = match (item.source.as_ref(), item.line) { + (Some(source), Some(line)) => format!("{}:{line}", source.display()), + (Some(source), None) => source.display().to_string(), + (None, Some(line)) => format!("line {line}, file unknown"), + (None, None) => "location unknown".to_owned(), }; - if let Some(line) = item.line { - if ui.button(label).clicked() { + let label = format!("{severity} · {location} · {}", item.message); + let active_source = diagnostic_targets_current_file( + item.source.as_deref(), + self.document.path.as_deref(), + ); + if active_source { + if let Some(line) = item.line + && ui.button(&label).clicked() + { self.jump_to_line = Some(line); if !self.workspace.show_source() { self.workspace = WorkspaceMode::Split; } } } else { - ui.label(label); + ui.label(label) + .on_hover_text("Open the referenced file to navigate; unknown source is not guessed."); } } } diff --git a/crates/tikzarium-app/src/ui_state.rs b/crates/tikzarium-app/src/ui_state.rs index 70943b04..736b0080 100644 --- a/crates/tikzarium-app/src/ui_state.rs +++ b/crates/tikzarium-app/src/ui_state.rs @@ -90,6 +90,25 @@ pub fn editor_command(key: Key, modifiers: Modifiers) -> Option { } } +/// Only navigate an error when the logged source can be identified as the open file. +/// Relative TeX paths are resolved against the current document directory. +/// For an unknown file we deliberately refuse to jump to a possibly wrong line. +#[must_use] +pub fn diagnostic_targets_current_file(source: Option<&Path>, current: Option<&Path>) -> bool { + let (Some(source), Some(current)) = (source, current) else { + return false; + }; + let absolute = if source.is_absolute() { + source.to_path_buf() + } else { + current.parent().unwrap_or_else(|| Path::new(".")).join(source) + }; + match (absolute.canonicalize(), current.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => absolute == current, + } +} + /// Keep zoom finite and inside a user-visible range, including corrupt state. #[must_use] pub fn scaled_zoom(current: f32, factor: f32) -> f32 { @@ -103,6 +122,20 @@ pub fn scaled_zoom(current: f32, factor: f32) -> f32 { mod tests { use super::*; + #[test] + fn failed_build_diagnostics_never_jump_to_an_included_file() { + let dir = tempfile::tempdir().unwrap(); + let main = dir.path().join("main.tex"); + let include = dir.path().join("included.tex"); + std::fs::write(&main, "% main").unwrap(); + std::fs::write(&include, "% included").unwrap(); + assert!(diagnostic_targets_current_file(Some(Path::new("./main.tex")), Some(&main))); + assert!(!diagnostic_targets_current_file(Some(Path::new("included.tex")), Some(&main))); + assert!(!diagnostic_targets_current_file(Some(&include), Some(&main))); + assert!(!diagnostic_targets_current_file(None, Some(&main))); + assert!(!diagnostic_targets_current_file(Some(Path::new("main.tex")), None)); + } + #[test] fn source_canvas_and_split_have_explicit_boundaries() { assert!(WorkspaceMode::Source.show_source()); diff --git a/crates/tikzarium-core/src/diagnostics.rs b/crates/tikzarium-core/src/diagnostics.rs index fcdc8fdb..db3a2f22 100644 --- a/crates/tikzarium-core/src/diagnostics.rs +++ b/crates/tikzarium-core/src/diagnostics.rs @@ -1,4 +1,6 @@ -//! Conservative TeX log diagnostics, without mutating source. +//! Conservative TeX log diagnostics; never edits the TeX source. +use std::path::{Path, PathBuf}; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum Severity { Error, @@ -9,66 +11,150 @@ pub enum Severity { pub struct BuildDiagnostic { pub severity: Severity, pub message: String, + pub source: Option, pub line: Option, } #[must_use] pub fn extract_diagnostics(log: &str) -> Vec { - let mut items = Vec::new(); + let mut items: Vec = Vec::new(); for line in log.lines() { let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix("! ") { + if let Some((source, number, message)) = file_line_error(trimmed) { + // The TeX log may repeat the preceding "! ..." message in + // -file-line-error format. Keep one diagnostic with a location. + if let Some(previous) = items.last_mut() + && previous.severity == Severity::Error + && previous.line.is_none() + && (message.contains(&previous.message) || previous.message.contains(&message)) + { + previous.source = Some(source); + previous.line = Some(number); + continue; + } + items.push(BuildDiagnostic { + severity: Severity::Error, + message, + source: Some(source), + line: Some(number), + }); + } else if let Some(message) = trimmed.strip_prefix("! ") { items.push(BuildDiagnostic { severity: Severity::Error, - message: rest.to_owned(), + message: message.to_owned(), + source: None, line: None, }); - } else if trimmed.contains("LaTeX Warning:") - || trimmed.contains("Package pgf Warning:") + } else if let Some(number) = tex_line_marker(trimmed) { + if let Some(previous) = items + .iter_mut() + .rev() + .find(|item| item.severity == Severity::Error && item.line.is_none()) + { + previous.line = Some(number); + } + } else if trimmed.starts_with("LaTeX Warning:") + || (trimmed.starts_with("Package ") && trimmed.contains(" Warning:")) + || trimmed.starts_with("Overfull \\") + || trimmed.starts_with("Underfull \\") || trimmed.starts_with("Missing character:") || trimmed.starts_with("LaTeX Font Warning:") - || trimmed.starts_with("Package fontspec Warning:") { items.push(BuildDiagnostic { severity: Severity::Warning, message: trimmed.to_owned(), + source: None, line: None, }); - } else if let Some((_, rest)) = trimmed.rsplit_once(".tex:") { - let digits = rest - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect::(); - if let Ok(n) = digits.parse::() { - items.push(BuildDiagnostic { - severity: Severity::Error, - message: trimmed.to_owned(), - line: Some(n), - }); - } } } items } +fn tex_line_marker(text: &str) -> Option { + let digits = text.strip_prefix("l.")?; + let digits = digits + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + digits.parse::().ok() +} + +fn file_line_error(text: &str) -> Option<(PathBuf, usize, String)> { + // Scan for :: rather than splitting on the first colon: Windows + // drive paths contain colons, and TeX diagnostics can contain more colons. + for (index, character) in text.char_indices() { + if character != ':' { + continue; + } + let rest = &text[index + 1..]; + let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digit_count == 0 || !rest[digit_count..].starts_with(':') { + continue; + } + let filename = text[..index].trim(); + let extension = Path::new(filename).extension()?.to_str()?; + if !["tex", "tikz", "ltx", "sty", "cls", "def"] + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + { + continue; + } + let number = rest[..digit_count].parse::().ok()?; + let message = rest[digit_count + 1..].trim(); + if message.is_empty() { + continue; + } + return Some((PathBuf::from(filename), number, message.to_owned())); + } + None +} + #[cfg(test)] mod tests { use super::*; + + #[test] + fn deduplicates_error_with_file_line_location() { + let log = "! Undefined control sequence.\n./main.tex:18: Undefined control sequence.\nLaTeX Warning: Label changed.\n"; + let diagnostics = extract_diagnostics(log); + assert_eq!(diagnostics.len(), 2); + assert_eq!(diagnostics[0].line, Some(18)); + assert_eq!(diagnostics[0].source, Some(PathBuf::from("./main.tex"))); + assert_eq!(diagnostics[1].severity, Severity::Warning); + } + + #[test] + fn recognizes_windows_paths_and_bare_tex_line_numbers() { + let log = "C:\\research project\\fig.v2.tex:73: Undefined control sequence.\n! Missing $ inserted.\nl.41 \\foo\n"; + let diagnostics = extract_diagnostics(log); + assert_eq!(diagnostics.len(), 2); + assert_eq!( + diagnostics[0].source, + Some(PathBuf::from("C:\\research project\\fig.v2.tex")) + ); + assert_eq!(diagnostics[0].line, Some(73)); + assert_eq!(diagnostics[1].line, Some(41)); + } + #[test] - fn missing_glyph_is_nonfatal_but_always_reported() { - let log = "Missing character: There is no Ж in font LM Roman 10!\nLaTeX Font Warning: substituted\n"; + fn font_warnings_remain_visible_after_source_path_merge() { + let log = "Missing character: There is no Ж in font LM Roman 10!\nLaTeX Font Warning: substituted\nPackage fontspec Warning: family shape\n"; let out = extract_diagnostics(log); - assert_eq!(out.len(), 2); + assert_eq!(out.len(), 3); assert!(out.iter().all(|item| item.severity == Severity::Warning)); + assert!(out.iter().all(|item| item.source.is_none())); } #[test] - fn parse_log_without_panics() { - let diagnostics = extract_diagnostics( - "! Undefined control sequence.\nmain.tex:18: Undefined control sequence\nLaTeX Warning: Label changed.\n", - ); - assert_eq!(diagnostics.len(), 3); - assert_eq!(diagnostics[1].line, Some(18)); - assert_eq!(diagnostics[2].severity, Severity::Warning); + fn package_and_box_warnings_are_not_silent() { + let log = "Package pgfplots Warning: empty axis\nOverfull \\hbox (10.0pt too wide)\nMissing character: There is no x\n"; + assert_eq!(extract_diagnostics(log).len(), 3); + } + + #[test] + fn malformed_log_never_produces_a_phantom_line() { + let log = "foo.tex:: error\nfoo.tex:abc: error\n\\SomeMacro{100}\n"; + assert!(extract_diagnostics(log).is_empty()); } } diff --git a/crates/tikzarium-core/src/lib.rs b/crates/tikzarium-core/src/lib.rs index 8e17fa1b..e8ed4a5e 100644 --- a/crates/tikzarium-core/src/lib.rs +++ b/crates/tikzarium-core/src/lib.rs @@ -8,7 +8,7 @@ pub mod font_audit; pub mod fonts; pub mod preview; -pub use build::{BuildArtifact, BuildError, BuildOptions, BuildRequest, BuildService}; +pub use build::{BuildArtifact, BuildError, BuildOptions, BuildRequest, BuildService, parse_fls, parse_fls_with_cwd}; pub use diagnostics::{BuildDiagnostic, Severity, extract_diagnostics}; pub use discovery::{TexEnvironment, find_executable}; pub use document::Document; From 97a536b7f30cecc29c7a32e611b036b9a672528c Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:10:50 +0300 Subject: [PATCH 03/12] ci(extensions): execute C03 artifact auditor on Linux, Windows and ARM macOS --- .github/workflows/ci.yml | 8 ++++++++ .github/workflows/qa-matrix.yml | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dacd9e9f..add83854 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,14 @@ jobs: --output-dir /tmp/tikzarium-ci/multipage \ --expect-first 'Привет' \ --expect-second 'Геометрия' + - name: Audit XeLaTeX PGF/TikZ extension fixtures with actual artifacts + run: | + python3 -m unittest discover -s extensions/qa -p 'test_run_smoke.py' -v + python3 -m unittest discover -s extensions/qa -p 'test_verify_smoke.py' -v + python3 extensions/qa/run_smoke.py --output /tmp/tikzarium-ci/extensions-c03 + python3 extensions/qa/verify_smoke.py \ + --output /tmp/tikzarium-ci/extensions-c03 \ + --report /tmp/tikzarium-ci/extensions-c03/audit.json - name: Upload smoke PDFs and Unicode evidence if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/qa-matrix.yml b/.github/workflows/qa-matrix.yml index 983aa66d..d46fe70c 100644 --- a/.github/workflows/qa-matrix.yml +++ b/.github/workflows/qa-matrix.yml @@ -102,6 +102,9 @@ jobs: collection-xetex collection-latexrecommended collection-pictures + pgfplots + circuitikz + tikz-cd standalone unicode-math lm-math @@ -135,6 +138,12 @@ jobs: run: cargo build -p tikzarium-cli - name: Execute fixture suite with machine-readable evidence run: python scripts/tools/qa_smoke.py --cli "target/debug/${{ matrix.cli }}" --output ".qa-artifacts/${{ matrix.label }}" + - name: Extension smoke: record and audit five exact XeLaTeX fixtures + run: | + python -m unittest discover -s extensions/qa -p "test_run_smoke.py" -v + python -m unittest discover -s extensions/qa -p "test_verify_smoke.py" -v + python extensions/qa/run_smoke.py --output ".qa-artifacts/${{ matrix.label }}/extensions" + python extensions/qa/verify_smoke.py --output ".qa-artifacts/${{ matrix.label }}/extensions" --report ".qa-artifacts/${{ matrix.label }}/extensions/audit.json" - name: Capture TeX installer failure log, if present if: failure() && steps.texlive_install.outcome == 'failure' run: python -c "from pathlib import Path; import shutil; d=Path('.qa-artifacts/${{ matrix.label }}'); d.mkdir(parents=True,exist_ok=True); s=Path('install-tl.log'); shutil.copyfile(s,d/'install-tl.log') if s.is_file() else (d/'installer-log-unavailable.txt').write_text('TeX installation failed before producing install-tl.log', encoding='utf-8')" From 087d35bb99d1faf90e93c46b2d8ab769089139b5 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:11:43 +0300 Subject: [PATCH 04/12] docs(I05): synchronize XeLaTeX runner and extension QA workplans with code --- CHANGELOG.md | 8 +++++ docs/ARCHITECTURE.md | 4 +++ docs/EXTENSIONS.md | 4 +++ insights/2026-10-31_I05_INTEGRATION_QA.md | 24 +++++++++++++++ ...-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md | 29 +++++++++++++++++++ 5 files changed, 69 insertions(+) create mode 100644 insights/2026-10-31_I05_INTEGRATION_QA.md create mode 100644 plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8214d87f..49751697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased — 2026-10-31: I05 hardened XeLaTeX runner and extension evidence + +- Reconciled former A #10 runner improvements with F02 font-warning detection: XeLaTeX retains structured file+line diagnostics, optional durable logs, source-safe atomic output and recorder paths. +- GUI error panel uses structured failures and refuses current-buffer line jumps for diagnostics belonging to a different included TeX file. +- C03 verifier now runs after real five-fixture extension XeLaTeX observation in Linux CI and on each hosted Windows Server/macOS ARM/Linux D QA job. Stored artifact hashes and Lua-only negative are audited, not merely reported. +- Exact-head CI and uploaded evidence are PENDING at initial integration. Native Windows 10 and M1/M4 GUI remain NOT_RUN. No v0.1 release claim. + + ## Unreleased — 2026-10-31: I04 two-page PDF integration gate - Added `scripts/qa_multipage_pdf.py` for actual Rust CLI → XeLaTeX → Poppler two-page evidence, including page-specific Cyrillic text and distinct 120-dpi PNG hashes. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c9fd38e6..29ea50c7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,3 +89,7 @@ ADR-001 primary XeLaTeX, explicit and visible incompatibilities. ADR-002 source-preserving edits and opaque regions, not forced full-AST rewriting. ADR-003 TeX packages ≠ visual adapters ≠ executable tool plugins. ADR-004 release proof from both CI and physical primary desktop smoke tests. + +## I05 source-linked errors and protected preview + +On compilation failure the Rust build service preserves the optional durable XeLaTeX log and exposes typed diagnostics with optional filename/line. The egui diagnostic panel distinguishes current-file diagnostics from included-file diagnostics: it navigates only the former to avoid an incorrect source jump. The previous successful PDF remains stale/nonexportable after an error. `font_audit` and text/math glyph warnings remain independent checked signals; no success is inferred solely from XeLaTeX exit code zero. diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 6f1944ae..3b633801 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -100,3 +100,7 @@ The C02 registry now prioritizes a definite missing file over another indetermin The C03 tool [extensions/qa/verify_smoke.py](../extensions/qa/verify_smoke.py) independently checks the *bytes* behind a C02 observation: the fixed fixture SHA-256 in the current source tree, captured console and TeX logs, positive PDF hash/signature/EOF plus \`pdfinfo\` page count, and the specific expected LuaTeX-only diagnostic for the negative graphdrawing probe. It reconciles the five observations with summary.json and rejects incomplete, contradictory, mixed-host and symlinked evidence by default. See the [QA runbook](../extensions/qa/README.md), [C03 plan](../plans/2026-10-31_C03_ARTIFACT_AUDIT.md), and [C03 insights](../insights/2026-10-31_C03_ARTIFACT_AUDIT_QA.md). \`--allow-partial\` is explicitly *selected-only* auditing, never an accepted full five-fixture suite. A recorded \`runner_kind=hosted_ci\` is self-reported metadata, not an attested GitHub runner. Matching hashes also cannot prove the compiler really produced those bytes or identify the exact operating system. The auditor does not promote registry capabilities, authenticate machines, certify package versions, or prove visual edits. The PACKAGE_MATRIX.csv remains conservative until per-platform evidence has been reviewed, not merely parsed. + +## I05 real package evidence integration + +The fixed five-fixture C02 runner is followed by the independent C03 auditor in CI. An observation is bound to a source SHA, exact TeX and log/PDF artifacts, the host/TeX engine, a pdfinfo page count and an explicit incompatible-Lua negative. This is **not** a whitelist of all CTAN libraries, nor an editable scene model. See `plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md` and `insights/2026-10-31_I05_INTEGRATION_QA.md`. The packages on real Windows 10 and physical M1/M4 are not yet accepted. diff --git a/insights/2026-10-31_I05_INTEGRATION_QA.md b/insights/2026-10-31_I05_INTEGRATION_QA.md new file mode 100644 index 00000000..9e0facf1 --- /dev/null +++ b/insights/2026-10-31_I05_INTEGRATION_QA.md @@ -0,0 +1,24 @@ +# I05 — Independent integration QA and provenance +Date: 2026-10-31. Starting main: `e3a78ae5646ef24994161c8ebe7bfaaf7a7d2ab6`. + +## Confirmed at start +- I04 #23 merged into main as `62fc517f5bd0b243cb44d092937d0685663e4b0b`: exact head `5adcc770cc03`, GitHub Actions 41178741632 (3/3), 41178741675 (6/6), 41178741644 (1/1) successful. This is real Rust CLI/2-page TeX/Poppler evidence, not GUI mouse clicks. +- B04 #20 merged as `1edbe7101204e728daeb775d6dcbecc79f094729`, exact head `154741e67d71`, hosted 41176482625 three jobs green. Source lexical font hints are **not** effective font evaluation. +- C03 #21 merged as `e3a78ae5646ef24994161c8ebe7bfaaf7a7d2ab6`, exact head `a9e5aa67baee`: hosted CI 41177061724, 41177061828, 41177061720 green. But audit script was **not itself invoked by existing workflows**; this I05 workpack adds the real call. +- A #10 remains draft/unmerged due conflict with F02 font messages. A head `3af867cfcd32` had independently green 41171585266 three jobs, but copying A's diagnostics wholesale would lose current main's font-warning behavior. I05 combines both, not forcing old PR. + +## Implementation changes made in isolated I05 branch +- Copy A runner/CLI/tests and preserve current `font_audit` module. +- Add `BuildDiagnostic.source` while retaining explicit `LaTeX Font Warning` and `Missing character` diagnostics. +- Failures now surface structured source line/log, only eligible current-file diagnostics are clickable; included-file source is displayed but not deceptively navigated. +- Record and re-audit actual `extensions/fixtures` compilation in Linux and D matrix on all hosted OSes. Storing summary JSON is not independent verification without checking source/log/PDF hashes. + +## QA evidence boundary +This document records submitted changes, **NOT** a passed integration until exact-head Actions jobs and audit JSON are read. No Rust binary was run in the assistant's local sandbox, which does not contain rustc/cargo. Hosted macOS ARM64 and Windows Server evidence are CI_ONLY; Win10 and physical M1/M4 are NOT_RUN. + +## Known risks +- `BuildOptions.log_path` may point to a user-selected location; atomic persist, canonical source equality guard and tests are essential. +- TeX failure log parsing is bounded. `font_audit.rs` remains a separate typed semantic audit, not automatically equivalent to source-linked BuildDiagnostic. +- Main Egui preview's stale detection still ignores changes in included assets unless the top-level source changes. +- C03 audited results bind hashes to a checkout and host, not signatures or licensing. CTAN package coverage is not the entire extension universe. +- Lua-only graphdrawing must remain engine-incompatible and cannot be advertised as a XeLaTeX feature. diff --git a/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md new file mode 100644 index 00000000..3fb85099 --- /dev/null +++ b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md @@ -0,0 +1,29 @@ +# I05 — Integrate A runner and C03 package evidence +Date: 2026-10-31 · project: i-shutov/tikzarium · starting main SHA `e3a78ae5646ef24994161c8ebe7bfaaf7a7d2ab6` + +## Scope and ownership +Integrate A's standalone #10 changes without force-merging its conflicting `diagnostics.rs`, preserving F02 Unicode glyph audit (#18). Connect the merged C03 #21 artifact auditor to actual cross-platform XeLaTeX matrix. Preserve independent B04 #20 and multipage I04 #23, both now in main. + +## Vertical workpack +- [x] Import source-safe runner and CLI: direct XeLaTeX argv with `-no-shell-escape`; leading-dash/dotted filenames; optional durable `.log`; atomic PDF output; cancel/timeout log retention; normalized `.fls` dependencies. +- [x] Merge source-aware `BuildDiagnostic` with all previous font-warning semantics, including `Missing character:`, `LaTeX Font Warning`, `Package fontspec Warning`. +- [x] Wire failed-build structured diagnostics and log excerpt to GUI, with current-file-only jump guard; retain stale last-good PDF. +- [x] Bring A's native/Unix fake-compiler tests and distinct CLI tests, preserving font proof and GUI test contracts. +- [x] Call production C02 `run_smoke.py` and independent C03 `verify_smoke.py` for five fixed cases; retain Linux evidence and cross-platform WinServer/macOS ARM evidence in existing CI artifact structures. +- [x] Keep TeX package detection, compilation evidence and visual-editability separate. +- [ ] Exact integration PR Rust fmt/check/clippy/test on Windows Server and macOS ARM; Linux real XeLaTeX; D 6-job matrix, font proof — **PENDING GitHub CI**. +- [ ] Confirm five-fixture observations, all hashes, expected Lua-only negative; inspect uploaded JSON, not just green workflow. +- [ ] Full Windows 10 x64 and physical Apple Silicon M1/M4 GUI acceptance, font/theme persistence, Unicode input and native PDF interaction — **NOT_RUN**. + +## Acceptance gates +1. Build source/byte integrity and recorded `.fls` dependencies preserved. Failed XeLaTeX returns source/line diagnostics and persistent log. +2. Source-linked warnings and missing-glyphs survive core refactor; bad include source does not make GUI jump to wrong line. +3. Actual installed extension fixtures: four positive 1-page PDFs plus one engine-incompatible graphdrawing negative with correct LuaTeX diagnostic, hashes matched. Only specific package/host scoped claims. +4. All 3 hosted build CI and 6 independent D QA jobs succeed against the **same Git SHA**. A successful branch run does not prove physical Win10 or Mac M1/M4. +5. Merge to main only after checked evidence and conflict resolution; then mark superseded #10 appropriately and record integrated SHA. + +## Follow-on work +- Replace optional external Poppler dependence with reviewed packaged renderer, or documented native deployment. +- File watcher: dependency fingerprints for included resources, not only open document source. +- Process-tree termination for TeX helpers; `-no-shell-escape` is not an OS sandbox. +- Real native 1366×768/Retina visual acceptance before internal v0.1. No geometry-aware direct drawing yet. From b2b7dce99c8e802cc88f88de598344a3818c7235 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:12:18 +0300 Subject: [PATCH 05/12] fix(ci): quote extension audit job name in GitHub Actions YAML --- .github/workflows/qa-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qa-matrix.yml b/.github/workflows/qa-matrix.yml index d46fe70c..0d361ddb 100644 --- a/.github/workflows/qa-matrix.yml +++ b/.github/workflows/qa-matrix.yml @@ -138,7 +138,7 @@ jobs: run: cargo build -p tikzarium-cli - name: Execute fixture suite with machine-readable evidence run: python scripts/tools/qa_smoke.py --cli "target/debug/${{ matrix.cli }}" --output ".qa-artifacts/${{ matrix.label }}" - - name: Extension smoke: record and audit five exact XeLaTeX fixtures + - name: "Extension smoke — record and audit five exact XeLaTeX fixtures" run: | python -m unittest discover -s extensions/qa -p "test_run_smoke.py" -v python -m unittest discover -s extensions/qa -p "test_verify_smoke.py" -v From 75fb43b5bd63685db5f86bf065a2406dc22be4c6 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:12:45 +0300 Subject: [PATCH 06/12] ci: one-time rustfmt for I05 integration conflict resolution --- .github/workflows/i05-rustfmt.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/i05-rustfmt.yml diff --git a/.github/workflows/i05-rustfmt.yml b/.github/workflows/i05-rustfmt.yml new file mode 100644 index 00000000..8eadfc8f --- /dev/null +++ b/.github/workflows/i05-rustfmt.yml @@ -0,0 +1,31 @@ +name: I05 one-time rustfmt +on: + push: + branches: + - feat/i05-runner-diagnostics-extension-audit +permissions: + contents: write +jobs: + format: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + ref: feat/i05-runner-diagnostics-extension-audit + persist-credentials: true + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.91.0 + components: rustfmt + - name: Apply reproducible format pass and update feature branch + shell: bash + run: | + set -euo pipefail + cargo fmt --all + if ! git diff --quiet -- crates/; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add crates/ + git commit -m "style(i05): rustfmt integrated core and GUI diagnostics" + git push origin HEAD:feat/i05-runner-diagnostics-extension-audit + fi From 5c2c2b2d07149b6702cf222976ef69d769c96425 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 07:13:06 +0000 Subject: [PATCH 07/12] style(i05): rustfmt integrated core and GUI diagnostics --- crates/tikzarium-app/src/main.rs | 14 ++++++++++---- crates/tikzarium-app/src/ui_state.rs | 25 ++++++++++++++++++++----- crates/tikzarium-core/src/lib.rs | 5 ++++- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index 307aef3c..c5b529a0 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -17,8 +17,8 @@ mod source_fonts; mod ui_state; mod ui_theme; use ui_state::{ - BottomView, EditorCommand, WorkspaceMode, editor_command, line_start_char_index, - preview_is_stale, scaled_zoom, diagnostic_targets_current_file, + BottomView, EditorCommand, WorkspaceMode, diagnostic_targets_current_file, editor_command, + line_start_char_index, preview_is_stale, scaled_zoom, }; use font_proof::{FontProofResult, spawn_font_proof}; @@ -355,7 +355,10 @@ impl TikzariumApp { }]; } self.bottom_view = BottomView::Issues; - self.show_log = error.log_excerpt().map(str::to_owned).unwrap_or_else(|| error.to_string()); + self.show_log = error + .log_excerpt() + .map(str::to_owned) + .unwrap_or_else(|| error.to_string()); self.preview_status = if self.pdf.is_some() { "Build failed — last good PDF retained (stale)".into() } else { @@ -363,7 +366,10 @@ impl TikzariumApp { }; let summary = error.to_string(); let first_line = summary.lines().next().unwrap_or("XeLaTeX error"); - self.status = format!("Build failed: {}", first_line.chars().take(160).collect::()); + self.status = format!( + "Build failed: {}", + first_line.chars().take(160).collect::() + ); } } } diff --git a/crates/tikzarium-app/src/ui_state.rs b/crates/tikzarium-app/src/ui_state.rs index 736b0080..e19302f7 100644 --- a/crates/tikzarium-app/src/ui_state.rs +++ b/crates/tikzarium-app/src/ui_state.rs @@ -101,7 +101,10 @@ pub fn diagnostic_targets_current_file(source: Option<&Path>, current: Option<&P let absolute = if source.is_absolute() { source.to_path_buf() } else { - current.parent().unwrap_or_else(|| Path::new(".")).join(source) + current + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(source) }; match (absolute.canonicalize(), current.canonicalize()) { (Ok(a), Ok(b)) => a == b, @@ -129,11 +132,23 @@ mod tests { let include = dir.path().join("included.tex"); std::fs::write(&main, "% main").unwrap(); std::fs::write(&include, "% included").unwrap(); - assert!(diagnostic_targets_current_file(Some(Path::new("./main.tex")), Some(&main))); - assert!(!diagnostic_targets_current_file(Some(Path::new("included.tex")), Some(&main))); - assert!(!diagnostic_targets_current_file(Some(&include), Some(&main))); + assert!(diagnostic_targets_current_file( + Some(Path::new("./main.tex")), + Some(&main) + )); + assert!(!diagnostic_targets_current_file( + Some(Path::new("included.tex")), + Some(&main) + )); + assert!(!diagnostic_targets_current_file( + Some(&include), + Some(&main) + )); assert!(!diagnostic_targets_current_file(None, Some(&main))); - assert!(!diagnostic_targets_current_file(Some(Path::new("main.tex")), None)); + assert!(!diagnostic_targets_current_file( + Some(Path::new("main.tex")), + None + )); } #[test] diff --git a/crates/tikzarium-core/src/lib.rs b/crates/tikzarium-core/src/lib.rs index e8ed4a5e..fe3247ef 100644 --- a/crates/tikzarium-core/src/lib.rs +++ b/crates/tikzarium-core/src/lib.rs @@ -8,7 +8,10 @@ pub mod font_audit; pub mod fonts; pub mod preview; -pub use build::{BuildArtifact, BuildError, BuildOptions, BuildRequest, BuildService, parse_fls, parse_fls_with_cwd}; +pub use build::{ + BuildArtifact, BuildError, BuildOptions, BuildRequest, BuildService, parse_fls, + parse_fls_with_cwd, +}; pub use diagnostics::{BuildDiagnostic, Severity, extract_diagnostics}; pub use discovery::{TexEnvironment, find_executable}; pub use document::Document; From a85b5322fc69eded02c7cbe92fdb20856cf77bd0 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:13:24 +0300 Subject: [PATCH 08/12] ci: remove one-time I05 formatting job after successful rustfmt --- .github/workflows/i05-rustfmt.yml | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 .github/workflows/i05-rustfmt.yml diff --git a/.github/workflows/i05-rustfmt.yml b/.github/workflows/i05-rustfmt.yml deleted file mode 100644 index 8eadfc8f..00000000 --- a/.github/workflows/i05-rustfmt.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: I05 one-time rustfmt -on: - push: - branches: - - feat/i05-runner-diagnostics-extension-audit -permissions: - contents: write -jobs: - format: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - ref: feat/i05-runner-diagnostics-extension-audit - persist-credentials: true - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.91.0 - components: rustfmt - - name: Apply reproducible format pass and update feature branch - shell: bash - run: | - set -euo pipefail - cargo fmt --all - if ! git diff --quiet -- crates/; then - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/ - git commit -m "style(i05): rustfmt integrated core and GUI diagnostics" - git push origin HEAD:feat/i05-runner-diagnostics-extension-audit - fi From b7e98aaf14cf36ca5c256c414d25e6b4697e6dba Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:15:42 +0300 Subject: [PATCH 09/12] fix(gui): keep diagnostics panel boolean and store failed TeX log separately --- crates/tikzarium-app/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tikzarium-app/src/main.rs b/crates/tikzarium-app/src/main.rs index c5b529a0..9d1b5f6b 100644 --- a/crates/tikzarium-app/src/main.rs +++ b/crates/tikzarium-app/src/main.rs @@ -355,7 +355,8 @@ impl TikzariumApp { }]; } self.bottom_view = BottomView::Issues; - self.show_log = error + self.show_log = true; + self.log = error .log_excerpt() .map(str::to_owned) .unwrap_or_else(|| error.to_string()); From 5f6780c524199af5e96a4659dd8537ff230112ea Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:17:44 +0300 Subject: [PATCH 10/12] fix(C03): strict LuaTeX negative diagnosis across TeX Live 2026 line wraps --- extensions/qa/run_smoke.py | 14 +++++++++++++- extensions/qa/test_run_smoke.py | 6 ++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/extensions/qa/run_smoke.py b/extensions/qa/run_smoke.py index 1ca2ed49..f6d358ff 100644 --- a/extensions/qa/run_smoke.py +++ b/extensions/qa/run_smoke.py @@ -31,8 +31,20 @@ "-recorder", "-no-shell-escape", ) # TeX wraps the engine-error message across lines in narrow log output. +# XeTeX 2026 can wrap within a word (observed grap followed by newline+h). +# Require the entire canonical message; tolerate only TeX display line breaks. +def _tex_wrapped_word(word: str) -> str: + return r"\s*".join(map(re.escape, word)) + + LUA_REQUIRED = re.compile( - r"need to run LuaTeX to use the graph\s+drawing library", re.I, + r"\b" + + r"\s+".join( + _tex_wrapped_word(token) + for token in ("need", "to", "run", "LuaTeX", "to", "use", "the", "graph", "drawing", "library") + ) + + r"\b", + re.I, ) MISSING_GLYPH = re.compile(r"Missing character:", re.I) diff --git a/extensions/qa/test_run_smoke.py b/extensions/qa/test_run_smoke.py index 15261a84..cf13366b 100644 --- a/extensions/qa/test_run_smoke.py +++ b/extensions/qa/test_run_smoke.py @@ -27,6 +27,12 @@ def test_lua_negative_requires_specific_diagnostic(self): source_preserved=True, ) self.assertEqual(verdict(**args), "expected_engine_error") + for wrapped in ( + "Need to run LuaTeX to use the grap\nh drawing library", + "Need to run LuaTeX to use the graph draw\ning library", + "Need to run LuaTeX to use the graph drawing libr\nary", + ): + self.assertEqual(verdict(**(args | {"log_text": wrapped})), "expected_engine_error") for change in ( {"exit_code": 0}, {"pdf_valid": True}, {"log_text": "Some unrelated TeX error"}, From 1db951c02d7aa7a896a661d7f2c17bd7de91c973 Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:18:20 +0300 Subject: [PATCH 11/12] docs(i05): record actual TeX Live 2026 wrapped negative QA finding --- CHANGELOG.md | 2 ++ insights/2026-10-31_I05_INTEGRATION_QA.md | 7 +++++++ plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49751697..a2c061e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,3 +44,5 @@ Unreleased development state; no production release. - Four-crate Rust workspace, egui shell, XeLaTeX core/CLI, package catalogue and hosted CI in draft PR #6. - Independent QA PR #9 documented the missing-Cyrillic-glyph false positive. + +- I05 QA found TeX Live 2026/PGF 3.1.12 wraps the required LuaTeX graphdrawing diagnostic **inside the word graph**; narrowed negative-control matching to the full phrase with allowed TeX line wraps, with targeted regression tests. Previous TeX Live 2023 five-fixture artifact audit remained green. Fresh head CI pending. diff --git a/insights/2026-10-31_I05_INTEGRATION_QA.md b/insights/2026-10-31_I05_INTEGRATION_QA.md index 9e0facf1..aa7a1374 100644 --- a/insights/2026-10-31_I05_INTEGRATION_QA.md +++ b/insights/2026-10-31_I05_INTEGRATION_QA.md @@ -22,3 +22,10 @@ This document records submitted changes, **NOT** a passed integration until exac - Main Egui preview's stale detection still ignores changes in included assets unless the top-level source changes. - C03 audited results bind hashes to a checkout and host, not signatures or licensing. CTAN package coverage is not the entire extension universe. - Lua-only graphdrawing must remain engine-incompatible and cannot be advertised as a XeLaTeX feature. + +## Discovered with downloaded GitHub Windows/Linux QA artifacts +I05 D QA run 41179678610, Linux XeTeX 2026 / PGF 3.1.12: 4 positive fixtures output valid PDF with exit 0, but the Lua-only fixture exited 1 with the **correct** message split midword: `You need to run LuaTeX to use the grap\nh drawing library.` The old C02 regex expected an unbroken `graph` and classified it `failed`; C03 auditor thus did not run for that attempt. Raw `graphdrawing-lua.log` and `console.log` were inspected from artifact 13034367975, with exact error. The failure was **test recognition**, not a XeLaTeX success or justification to weaken the engine compatibility rule. + +I05 now matches the *full canonical LuaTeX requirement phrase* while allowing XeTeX line wraps within words; tests cover graph/drawing/library internal breaks and unrelated errors. Status **PENDING a fresh exact-head CI**. + +Earlier Linux CLI workflow run 41179660717 succeeded: artifact 13033669833 was downloaded and independently inspected. Its `extensions-c03/audit.json` recorded `accepted=true`, `complete_suite=true`, `verified_fixtures=5`, `errors=[]`, source/tool TeX Live **2023/Debian**. Individual console, TeX and positive PDF SHA-256 were independently recomputed from all five ZIP members and matched observation TOML. That evidence is **Linux TeX Live 2023 only**, not a 2026/Windows/macOS verdict. diff --git a/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md index 3fb85099..1546c2b3 100644 --- a/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md +++ b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md @@ -27,3 +27,8 @@ Integrate A's standalone #10 changes without force-merging its conflicting `diag - File watcher: dependency fingerprints for included resources, not only open document source. - Process-tree termination for TeX helpers; `-no-shell-escape` is not an OS sandbox. - Real native 1366×768/Retina visual acceptance before internal v0.1. No geometry-aware direct drawing yet. + +## New cross-version negative control +- [x] Observed real TeX Live **2026 / PGF 3.1.12** failure text wraps inside “graph” (`grap\n h`), unlike TeX Live 2023. Four other extension fixtures compiled successfully. +- [x] Keep Lua-required message semantics exact; tolerate intra-word TeX log line wraps; added unit regression examples for graph/drawing/library word splits. +- [ ] Re-run I05 exact-head 3-host CI and inspect **actual** negative observation + independent auditor JSON. A previous Linux D job failed despite valid expected engine failure because the old recognizer missed the wrapped word — not a successful 5/5 until rerun. From 230428d6e7de0c4c75ca73b9fc917cbda282346e Mon Sep 17 00:00:00 2001 From: i-shutov Date: Sat, 31 Oct 2026 10:52:17 +0300 Subject: [PATCH 12/12] fix(I05): preserve portable XeLaTeX source operand on Windows; add CI regression provenance --- CHANGELOG.md | 2 ++ crates/tikzarium-core/src/build.rs | 32 +++++++++++++++++-- insights/2026-10-31_I05_INTEGRATION_QA.md | 5 +++ ...-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md | 6 ++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c061e4..593d1930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased — 2026-10-31: I05 hardened XeLaTeX runner and extension evidence +- Correct the Windows-hosted XeLaTeX `./filename.tex` source operand: never construct it using Windows-native `Path::join` (`.\\filename.tex` is a TeX control-sequence hazard). Use an `OsString` literal `./` prefix and regression-test ordinary, leading-dash and Cyrillic filenames. Re-run cross-host CI against the exact new SHA; no Win10 acceptance claim. + - Reconciled former A #10 runner improvements with F02 font-warning detection: XeLaTeX retains structured file+line diagnostics, optional durable logs, source-safe atomic output and recorder paths. - GUI error panel uses structured failures and refuses current-buffer line jumps for diagnostics belonging to a different included TeX file. - C03 verifier now runs after real five-fixture extension XeLaTeX observation in Linux CI and on each hosted Windows Server/macOS ARM/Linux D QA job. Stored artifact hashes and Lua-only negative are audited, not merely reported. diff --git a/crates/tikzarium-core/src/build.rs b/crates/tikzarium-core/src/build.rs index 1a3b0659..80cb01d9 100644 --- a/crates/tikzarium-core/src/build.rs +++ b/crates/tikzarium-core/src/build.rs @@ -5,6 +5,7 @@ use crate::{ }; use std::{ collections::HashSet, + ffi::{OsStr, OsString}, fs::{self, File}, io::{self, Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, @@ -153,8 +154,9 @@ impl BuildService { let out_dir = temp.path().join("out"); fs::create_dir_all(&out_dir)?; - // A relative ./ prefix prevents a source named -shell-escape.tex from - // being interpreted as another XeLaTeX command-line option. + // A literal forward-slash ./ prefix protects a leading-dash filename. + // Path::new(".").join(filename) creates a backslash on Windows that + // XeTeX interprets as a TeX control sequence instead of a path. let mut child = Command::new(&request.xelatex) .current_dir(cwd) .arg("-interaction=nonstopmode") @@ -163,7 +165,7 @@ impl BuildService { .arg("-recorder") .arg("-no-shell-escape") .arg(format!("-output-directory={}", out_dir.display())) - .arg(Path::new(".").join(filename)) + .arg(tex_source_operand(filename)) .stdin(Stdio::null()) .stdout(Stdio::from(File::create(&stdout_file)?)) .stderr(Stdio::from(File::create(&stderr_file)?)) @@ -249,6 +251,14 @@ impl BuildService { } } +/// Preserve native filename bytes while using an unambiguous TeX operand on +/// Windows and Unix. Do not normalize or rewrite the user's TeX source. +fn tex_source_operand(filename: &OsStr) -> OsString { + let mut operand = OsString::from("./"); + operand.push(filename); + operand +} + fn terminate_child(child: &mut Child) { let _ = child.kill(); let _ = child.wait(); @@ -388,6 +398,22 @@ fn tail(text: &str, max: usize) -> String { mod tests { use super::*; + #[test] + fn source_operand_is_portable_and_protects_dash_prefixed_names() { + assert_eq!( + tex_source_operand(OsStr::new("quickstart.tex")), + OsString::from("./quickstart.tex") + ); + assert_eq!( + tex_source_operand(OsStr::new("-shell-escape.tex")), + OsString::from("./-shell-escape.tex") + ); + assert_eq!( + tex_source_operand(OsStr::new("тест v2.tex")), + OsString::from("./тест v2.tex") + ); + } + #[test] fn dependencies_are_deduplicated() { let parsed = parse_fls( diff --git a/insights/2026-10-31_I05_INTEGRATION_QA.md b/insights/2026-10-31_I05_INTEGRATION_QA.md index aa7a1374..94dc3dd5 100644 --- a/insights/2026-10-31_I05_INTEGRATION_QA.md +++ b/insights/2026-10-31_I05_INTEGRATION_QA.md @@ -29,3 +29,8 @@ I05 D QA run 41179678610, Linux XeTeX 2026 / PGF 3.1.12: 4 positive fixtures out I05 now matches the *full canonical LuaTeX requirement phrase* while allowing XeTeX line wraps within words; tests cover graph/drawing/library internal breaks and unrelated errors. Status **PENDING a fresh exact-head CI**. Earlier Linux CLI workflow run 41179660717 succeeded: artifact 13033669833 was downloaded and independently inspected. Its `extensions-c03/audit.json` recorded `accepted=true`, `complete_suite=true`, `verified_fixtures=5`, `errors=[]`, source/tool TeX Live **2023/Debian**. Individual console, TeX and positive PDF SHA-256 were independently recomputed from all five ZIP members and matched observation TOML. That evidence is **Linux TeX Live 2023 only**, not a 2026/Windows/macOS verdict. + +## Windows Server 2022 incident — I05 `1db951c` (NOT PASS) +The exact-head [D QA run 41180076260](https://github.com/i-shutov/tikzarium/actions/runs/41180076260) recorded 5/6 success: hosted Windows Server 2022 XeLaTeX failed all five expected-valid cases (`g00-quickstart`, legacy Cyrillic, Cyrillic glyph audit, multifile Unicode path, `g02-tikz-fonts`); negative `g08` superficially PASS. The uploaded [Windows evidence 13035155032](https://github.com/i-shutov/tikzarium/actions/runs/41180076260/artifacts/13035155032) is retained. Linux and hosted macOS passed their D jobs, and font proof + 3-job Rust CI passed this *old head*; they cannot override the red Windows D job. Root cause matches independently audited A #10: native Windows `Path::join` emits `.\\quickstart.tex`, interpreted by XeTeX as a TeX command. Patch reuses the tested `OsString("./") + original OsStr filename` approach, tests Unicode and leading-dash filenames and **does not alter user TeX source**. No current-head success asserted until CI and JSON evidence are checked. + +The six D04 #27 negative-control assertions remain independent and must not be silently considered part of this I05 repair. Windows Server is CI_ONLY, and physical Windows 10 / Mac M1 / M4 GUI acceptance stays NOT_RUN. diff --git a/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md index 1546c2b3..1059aa22 100644 --- a/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md +++ b/plans/2026-10-31_I05_XELATEX_CORE_EXTENSION_AUDIT.md @@ -32,3 +32,9 @@ Integrate A's standalone #10 changes without force-merging its conflicting `diag - [x] Observed real TeX Live **2026 / PGF 3.1.12** failure text wraps inside “graph” (`grap\n h`), unlike TeX Live 2023. Four other extension fixtures compiled successfully. - [x] Keep Lua-required message semantics exact; tolerate intra-word TeX log line wraps; added unit regression examples for graph/drawing/library word splits. - [ ] Re-run I05 exact-head 3-host CI and inspect **actual** negative observation + independent auditor JSON. A previous Linux D job failed despite valid expected engine failure because the old recognizer missed the wrapped word — not a successful 5/5 until rerun. + +## Windows XeLaTeX regression — integration repair +- [x] At I05 head `1db951c02d7aa7a896a661d7f2c17bd7de91c973`, D QA [41180076260](https://github.com/i-shutov/tikzarium/actions/runs/41180076260) failed 5/6 jobs, specifically real XeLaTeX on Windows Server 2022: five valid fixtures produced exit 3. The old negative fixture PASS is not a trustworthy result (D04 #27 tracks fail-closed assertions). +- [x] Reconcile the already CI-tested A #10 fix: build the filename operand as a lossless `OsString` starting with literal `./`; `Path::new(".").join(filename)` emits `.\\` on Windows which TeX parses as a command. Add cross-platform operand unit regression tests. +- [ ] Require **fresh I05 SHA** CI 3/3, D QA 6/6, font proof 1/1, C03 extension JSON on all 3 hosted platforms; check source hashes/expected Lua-only failure. Do not merge failed head. +- [ ] Follow with D04 fail-closed negative source-specific gate; assess PR #27 separately after I05 stabilization.