Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,82 @@ jobs:
- name: Nightly-only fixtures
run: cargo nextest run --locked -p build-xtask --run-ignored only

# The workspace's own lints, the dylint library in lints/build-lints. It
# builds on the nightly in that crate's rust-toolchain.toml, never on the
# workspace's stable. The date is read from that file, never repeated
# here. `cargo dylint` checks every crate, the desktop app included, so
# this job installs what check-workshop-linux does. It runs on 24.04,
# not check-workshop-linux's 22.04: the prebuilt cargo-dylint and
# dylint-link binaries need glibc 2.38 or later, and 22.04 ships 2.35.
dylint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

# The same release as the `dylint_linting` the library depends on.
- name: Install cargo-dylint and dylint-link
uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2
with:
tool: cargo-dylint@6.3.2, dylint-link@6.3.2

- name: Install the lints' nightly
shell: bash
run: |
set -euo pipefail
nightly="$(sed -n 's/^channel = "\(nightly-[0-9-]*\)"$/\1/p' lints/build-lints/rust-toolchain.toml)"
case "$nightly" in
nightly-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;;
*)
echo "expected one nightly channel in lints/build-lints/rust-toolchain.toml, found: '$nightly'"
exit 1
;;
esac
rustup toolchain install "$nightly" --profile minimal --component rustc-dev,llvm-tools-preview

- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: |
. -> target
lints/build-lints -> target

- name: Install Tauri system packages
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: crates/workshop/package-lock.json

- name: Install UI dependencies
run: npm ci --prefix crates/workshop

- name: Stage a stub Gateway sidecar
run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-unknown-linux-gnu

- name: Lint UI tests
working-directory: lints/build-lints
run: cargo test --locked

- name: Dylint
run: cargo dylint --all -- --locked --workspace --all-targets --all-features

- name: Remove Gateway sidecar
if: always()
run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-unknown-linux-gnu

- name: Clean tree
shell: bash
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "the build dirtied the repository:"
git status --porcelain
exit 1
fi

# The Engine crates inherit `rust-version` from the root Cargo.toml's
# [workspace.package]; this job builds them at that toolchain. The version
# is read from the manifest, never repeated here, so the two cannot drift.
Expand Down Expand Up @@ -351,7 +427,7 @@ jobs:
# or that job bypasses this gate.
ci-green:
runs-on: ubuntu-latest
needs: [fmt, clippy, test, docs, check-workshop, check-workshop-linux, ui, supply-chain, api-surface, msrv]
needs: [fmt, clippy, test, docs, check-workshop, check-workshop-linux, ui, supply-chain, api-surface, dylint, msrv]
if: always()
steps:
- name: Verify every job succeeded
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c
- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` with `RUSTDOCFLAGS="-D warnings"`. Rustdoc lints are not covered by clippy; never skip the docs gate.
- Facade docs: `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps`, without `--all-features`, so the facade's docs build with default features.
- Facade surface: `cargo +<pinned nightly> xtask api --check`, where the pinned nightly is the one named in `crates/build-xtask/src/api/toolchain.rs`; on any other toolchain it fails at once, naming the nightly it needs. It checks that every path a surface item's signature, fields, bounds, impls, or doc links name is a facade re-export (or std, core, alloc, or an allowlisted crate), that no surface doc text names an internal crate, and that the surface listing matches the committed `crates/runvil/public-api.txt`. Run it with `CARGO_BUILD_WARNINGS` unset (in PowerShell, `Remove-Item Env:CARGO_BUILD_WARNINGS` after the clippy gate set it), because the nightly cargo turns its own warnings into errors while that variable is set.
- Custom lints: `cargo dylint --all -- --locked --workspace --all-targets --all-features`, with `cargo-dylint` and `dylint-link` installed. It builds the dylint library in `lints/build-lints` on the nightly that crate's `rust-toolchain.toml` names and checks every workspace crate with it. The library sits outside the workspace and outside `crates/`, so the gates above never build it; its UI tests run with `cargo test` in `lints/build-lints`. A new lint is a module in that crate, registered in its `register_lints`, with a UI test under its `ui/`.
- Boundary and structural checks: `cargo test -p build-xtask`. It enforces the product and container boundaries, the Workshop tier graph, the `## Invariants` marker, and lint inheritance.
- Wording and rulebook scan: `node --test crates/workshop/ui/test/docs-claims.mjs`. It enforces the Engine wording rule, the rulebook Definitions, and the Pack lifecycle wording (installed, snapshotted, or declared). It needs no `npm ci` and runs from any directory, because the file resolves the repository root from its own path.

Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ tauri-plugin-process = "2.3.1"
tauri-plugin-updater = { version = "2.11.0", default-features = false, features = ["system-proxy"] }
tauri-plugin-single-instance = "2.4.4"

# The dylint libraries `cargo dylint --all` runs. Each lives outside the
# workspace because it builds on its own nightly with `rustc_private`.
[workspace.metadata.dylint]
libraries = [{ path = "lints/build-lints" }]

[workspace.lints.rust]
unsafe_code = "deny"
missing_docs = "warn"
Expand Down
22 changes: 16 additions & 6 deletions crates/build-ui/tests/it/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Integration tests for the `build-ui` helper.

use std::collections::BTreeSet;

use anyhow::Context as _;
use std::path::{Path, PathBuf};
use std::process::Command;

Expand Down Expand Up @@ -30,8 +32,8 @@ fn both_implementers_emit_the_same_layout() -> anyhow::Result<()> {
return Ok(());
}

let rust_dist = tempfile::TempDir::new()?;
let node_dist = tempfile::TempDir::new()?;
let rust_dist = tempfile::TempDir::new().context("create the Rust build's temp dir")?;
let node_dist = tempfile::TempDir::new().context("create the Node build's temp dir")?;

build_ui::build_in(
&ui_dir,
Expand All @@ -48,7 +50,8 @@ fn both_implementers_emit_the_same_layout() -> anyhow::Result<()> {
.arg("--out")
.arg(node_dist.path())
.current_dir(&ui_dir)
.output()?;
.output()
.with_context(|| format!("run node build.mjs in {}", ui_dir.display()))?;
anyhow::ensure!(
output.status.success(),
"node build.mjs --out failed (status {}):\n{}",
Expand Down Expand Up @@ -99,8 +102,12 @@ fn file_set(root: &Path) -> anyhow::Result<BTreeSet<String>> {
let mut files = BTreeSet::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
let entries =
std::fs::read_dir(&dir).with_context(|| format!("read dir {}", dir.display()))?;
for entry in entries {
let path = entry
.with_context(|| format!("read an entry of {}", dir.display()))?
.path();
if path.is_dir() {
stack.push(path);
} else {
Expand All @@ -117,7 +124,10 @@ fn file_set(root: &Path) -> anyhow::Result<BTreeSet<String>> {

/// Reads one file under `root` with its content hashes normalized.
fn normalized_file(root: &Path, name: &str) -> anyhow::Result<String> {
Ok(normalize_hashes(&std::fs::read_to_string(root.join(name))?))
let path = root.join(name);
let text =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
Ok(normalize_hashes(&text))
}

/// Replaces esbuild content hashes (`-XXXXXXXX` in base32, A-Z and 2-7)
Expand Down
22 changes: 15 additions & 7 deletions crates/build-xtask/src/new_crate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::Context as _;

/// Scaffold `crates/<name>/` with a manifest, a facade `lib.rs` holding
/// the invariant docs, a build script that runs the compile-time file-size
/// check, and the crate's integration-test binary.
Expand All @@ -19,14 +21,20 @@ pub(crate) fn scaffold(root: &Path, name: &str) -> anyhow::Result<PathBuf> {
"crate directory already exists: {}",
dir.display()
);
fs::create_dir_all(dir.join("src"))?;
fs::create_dir_all(dir.join("tests").join("it"))?;
fs::write(dir.join("Cargo.toml"), manifest(name))?;
fs::write(dir.join("build.rs"), BUILD_RS)?;
fs::write(dir.join("src").join("lib.rs"), lib_rs(name))?;
fs::write(
let create = |path: PathBuf| {
fs::create_dir_all(&path).with_context(|| format!("create {}", path.display()))
};
let write = |path: PathBuf, contents: &str| {
fs::write(&path, contents).with_context(|| format!("write {}", path.display()))
};
create(dir.join("src"))?;
create(dir.join("tests").join("it"))?;
write(dir.join("Cargo.toml"), &manifest(name))?;
write(dir.join("build.rs"), BUILD_RS)?;
write(dir.join("src").join("lib.rs"), &lib_rs(name))?;
write(
dir.join("tests").join("it").join("main.rs"),
test_main_rs(name),
&test_main_rs(name),
)?;
Ok(dir)
}
Expand Down
4 changes: 3 additions & 1 deletion crates/runvil/tests/suite/parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ fn invalid_prompt_files_report_public_error_contracts() -> TestResult {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/prompts/invalid");
for (stem, code, message_fragment) in INVALID_FIXTURES {
let name = format!("invalid/{stem}.md");
let source = fs::read_to_string(dir.join(format!("{stem}.md")))?;
let path = dir.join(format!("{stem}.md"));
let source =
fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let Err(error) = Prompt::parse(&source, &name, &name).0 else {
return Err(format!("fixture {name} unexpectedly parsed").into());
};
Expand Down
4 changes: 3 additions & 1 deletion crates/runvil/tests/suite/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ fn two_runs_writing_the_same_real_file_through_the_shared_base_conflict() -> Tes
matches!(error, VfsError::Conflict { .. }),
"a determinism violation, not a backend error: {error}"
);
assert_eq!(std::fs::read(temp.path().join("shared.txt"))?, b"from a");
let shared = temp.path().join("shared.txt");
let bytes = std::fs::read(&shared).map_err(|e| format!("read {}: {e}", shared.display()))?;
assert_eq!(bytes, b"from a");
Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion crates/runvil/tests/suite/shipped.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ fn research_ada_lovelace() -> TestResult<Trace> {
)
.args("Ada Lovelace")
.config(move |ctx| ctx.with_tools(catalog.clone()))
.run(&fs::read_to_string(path)?)
.run(&fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?)
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion crates/runvil/tests/suite/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ fn temp_roots_with_one_label_get_distinct_paths_and_drop_removes_the_folder() ->
let (first, second) = (TempRoot::new(label), TempRoot::new(label));
assert_ne!(first.path(), second.path());
let dropped = first.path().to_owned();
fs::write(dropped.join("f.txt"), b"x")?;
let file = dropped.join("f.txt");
fs::write(&file, b"x").map_err(|e| format!("write {}: {e}", file.display()))?;
drop(first);
assert!(!dropped.exists(), "{} survived its drop", dropped.display());
assert!(second.path().is_dir(), "the second root outlives the first");
Expand Down
12 changes: 9 additions & 3 deletions crates/workshop/desktop/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,17 @@ fn install_gateway_sidecar() -> Result<(), Box<dyn Error>> {

/// Copies `source` over `sidecar`, creating the directory and reporting
/// the copy so a surprising sidecar is visible in the build log.
fn copy_forward(source: &Path, sidecar: &Path) -> std::io::Result<()> {
fn copy_forward(source: &Path, sidecar: &Path) -> Result<(), String> {
if let Some(parent) = sidecar.parent() {
fs::create_dir_all(parent)?;
fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
fs::copy(source, sidecar)?;
fs::copy(source, sidecar).map_err(|e| {
format!(
"copy the gateway sidecar {} to {}: {e}",
source.display(),
sidecar.display()
)
})?;
println!(
"cargo:warning=installed gateway sidecar {} from {}",
sidecar.display(),
Expand Down
5 changes: 4 additions & 1 deletion crates/workshop/run-log/src/append.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ impl RunLog {
/// apply the schema.
pub async fn open(path: &Path) -> Result<Self, LogError> {
let name = path.to_str().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "run log path must be utf-8")
LogError::io(
format!("open {}", path.display()),
io::Error::new(io::ErrorKind::InvalidInput, "run log path must be utf-8"),
)
})?;
let conn = connect(name).await?;
if layout::is_stale(&conn).await? {
Expand Down
20 changes: 15 additions & 5 deletions crates/workshop/run-log/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,13 @@ pub enum LogError {
#[source]
source: DatabaseSource,
},
/// The log file could not be addressed; the I/O error is the source.
#[error("run log file")]
/// The log file could not be addressed; `context` names the operation
/// and the path, and the I/O error is the source.
#[error("run log file: {context}")]
Io {
/// The operation and the path it addressed, such as
/// `rename /a/runs.db to /a/runs.db.stale-1`.
context: String,
/// The I/O error.
#[source]
source: io::Error,
Expand All @@ -49,9 +53,15 @@ pub enum LogError {
Corrupt(String),
}

impl From<io::Error> for LogError {
fn from(source: io::Error) -> Self {
LogError::Io { source }
impl LogError {
/// [`LogError::Io`] for `source`, with `context` naming the operation
/// and the path. There is no `From<io::Error>`: a bare `?` would drop
/// both.
pub(crate) fn io(context: impl Into<String>, source: io::Error) -> Self {
LogError::Io {
context: context.into(),
source,
}
}
}

Expand Down
16 changes: 13 additions & 3 deletions crates/workshop/run-log/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,26 @@ pub(crate) async fn stamp(conn: &turso::Connection) -> Result<(), LogError> {
/// Returns [`LogError::Io`] when a rename fails.
pub(crate) async fn set_aside(path: &Path, stamp: i64) -> Result<PathBuf, LogError> {
let aside = with_suffix(path, &format!(".stale-{stamp}"));
tokio::fs::rename(path, &aside).await?;
rename(path, &aside).await?;
for sidecar in SIDECARS {
let from = with_suffix(path, sidecar);
if tokio::fs::try_exists(&from).await? {
tokio::fs::rename(&from, with_suffix(&aside, sidecar)).await?;
let exists = tokio::fs::try_exists(&from)
.await
.map_err(|e| LogError::io(format!("check {}", from.display()), e))?;
if exists {
rename(&from, &with_suffix(&aside, sidecar)).await?;
}
}
Ok(aside)
}

/// Renames `from` to `to`, naming both in the error.
async fn rename(from: &Path, to: &Path) -> Result<(), LogError> {
tokio::fs::rename(from, to)
.await
.map_err(|e| LogError::io(format!("rename {} to {}", from.display(), to.display()), e))
}

/// The version the file was stamped with, `None` when it holds no stamp.
async fn stamped(conn: &turso::Connection) -> Result<Option<i64>, LogError> {
let mut rows = conn.query(schema::SELECT_LAYOUT, ()).await?;
Expand Down
4 changes: 3 additions & 1 deletion crates/workshop/run-log/src/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ impl TursoRecorder {
if let Some(log) = slot {
Ok(log)
} else {
tokio::fs::create_dir_all(&self.dir).await?;
tokio::fs::create_dir_all(&self.dir)
.await
.map_err(|e| LogError::io(format!("create {}", self.dir.display()), e))?;
let log = RunLog::open(&self.path).await?;
Ok(slot.insert(log))
}
Expand Down
10 changes: 7 additions & 3 deletions crates/workshop/server/src/agents/language_guide/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ const TEXT: &str = "# Hello from a local page\n";

/// A directory holding the one page.
fn local_directory() -> Result<tempfile::TempDir, Box<dyn Error>> {
let directory = tempfile::tempdir()?;
std::fs::write(directory.path().join(PAGE), TEXT)?;
let directory = tempfile::tempdir().map_err(|e| format!("create a temp dir: {e}"))?;
let page = directory.path().join(PAGE);
std::fs::write(&page, TEXT).map_err(|e| format!("write {}: {e}", page.display()))?;
Ok(directory)
}

Expand Down Expand Up @@ -96,7 +97,10 @@ fn the_mapping_refuses_a_write_and_leaves_the_page_alone() -> Outcome {
matches!(replaced, Err(VfsError::ReadOnly { .. })),
"{replaced:?}"
);
assert_eq!(std::fs::read_to_string(directory.path().join(PAGE))?, TEXT);
let page = directory.path().join(PAGE);
let text =
std::fs::read_to_string(&page).map_err(|e| format!("read {}: {e}", page.display()))?;
assert_eq!(text, TEXT);
assert!(!directory.path().join("new.md").exists());
Ok(())
}
Expand Down
Loading
Loading