From f46cff38f84c891db5d52fe46445979c9318ec01 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 27 Jul 2026 23:03:35 +0100 Subject: [PATCH 1/8] Update README for release --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index efbe4491..43cc53cd 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,13 @@ Netsuke currently requires: ### Installation -Until the v0.1.0 release is published, install the current source checkout with -Cargo: +To install from crates.io: + +```sh +cargo install netsuke +``` + +To install the current source checkout with Cargo: From c195a500b0b53db360e989d6a6958676443c25bd Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 27 Jul 2026 23:07:19 +0100 Subject: [PATCH 2/8] Bump whitaker user's guide --- docs/whitaker-users-guide.md | 95 ++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/docs/whitaker-users-guide.md b/docs/whitaker-users-guide.md index fd088bf3..69f08d0e 100644 --- a/docs/whitaker-users-guide.md +++ b/docs/whitaker-users-guide.md @@ -40,11 +40,12 @@ This: source-build path is taken, the installer reports that it is falling back to Cargo, and on success it prints `Installed from source with cargo install.`. After installation, - `cargo-dylint` is verified by running `cargo dylint --version`, while - `dylint-link` is verified by resolving the executable on `PATH` and then - invoking it with `--help`. The installer sets `RUSTUP_TOOLCHAIN` for that - probe when needed, which avoids false failures from upstream - `dylint-link --version` while still rejecting stale shims and broken scripts. + `cargo-dylint` is verified by running `cargo dylint --version`. + `dylint-link` is never executed: it is a linker wrapper that forwards its + arguments to the underlying linker, so it has no reliable self-reporting + subcommand. A release artefact is trusted once its checksum, extraction, and + executable permissions have been established, and a Cargo-managed copy is + checked by resolving it on `PATH` and comparing the version Cargo recorded. 2. Clones the Whitaker repository to a platform-specific data directory 3. Builds the lint libraries 4. Creates `whitaker` and `whitaker-ls` wrapper scripts. `whitaker` invokes @@ -58,8 +59,8 @@ After installation, run `whitaker --all` in any Rust project to lint it. Use On Windows, the installer's `PATH` check honours `PATHEXT` and falls back to the usual executable suffixes when `PATHEXT` is unset, so a normal Cargo-installed executable such as `dylint-link.exe` in -`%USERPROFILE%\.cargo\bin` is located correctly and then verified with the same -invocation-based probe without needing a separate wrapper or manual +`%USERPROFILE%\.cargo\bin` is located correctly and then matched against the +version Cargo recorded for it, without needing a separate wrapper or manual environment-variable workaround. **Options:** @@ -67,8 +68,8 @@ environment-variable workaround. - `--cranelift` — Tell the installer to add the `rustc-codegen-cranelift` component via `rustup component add`. The `rustc-codegen-cranelift` component is not included in the standard nightly - toolchain, so enable `--cranelift` when your project or CI requires the - Cranelift back-end and you would otherwise need an explicit + toolchain, so enable `--cranelift` when a project or CI pipeline requires the + Cranelift back-end and would otherwise need an explicit `rustc-codegen-cranelift` component-add step before running the installer. - `--skip-deps` — Skip `cargo-dylint`/`dylint-link` installation check - `--skip-wrapper` — Skip wrapper script generation (prints @@ -128,10 +129,10 @@ Stable releases differ from `rolling`: a stable tag is expected to contain the complete artefact set for the release. For production installs, pin to a stable release tag rather than consuming `rolling`. -If you consume rolling-release archives from scripts or CI, verify that the -required target archive exists before proceeding. Treat missing archives as an -expected condition for rolling releases rather than assuming the artefact set -is complete. +Scripts or CI pipelines that consume rolling-release archives should verify +that the required target archive exists before proceeding. Treat missing +archives as an expected condition for rolling releases rather than assuming the +artefact set is complete. ### Selecting individual lints @@ -326,17 +327,6 @@ user-written source location (macro-only glue) are silently excluded from the ordering check, so the lint never fires on compiler- or macro-generated code that the developer cannot edit. -The lint checks: - -- All functions, methods, and trait methods that carry at least one outer - attribute — including macro-heavy cases such as `#[rstest]` and `#[test]`. - -The lint ignores: - -- Attributes whose recovered span is macro-only (for example, inline hints - injected by `#[derive(...)]`). -- Inner attributes (`#![...]`). - #### Configuration @@ -459,6 +449,7 @@ Set `additional_test_attributes` to an array of attribute paths written as strings. Each entry should match the path Whitaker sees on the test function, for example `my_framework::test` or `wasm_bindgen_test`. + #### Ancestor context propagation `additional_test_attributes` now apply during ancestor context detection as @@ -474,7 +465,7 @@ ancestry chain. #[my_framework::test] async fn my_test() { - helper(); // allowed — ancestor is a recognised test function + helper(); // allowed — ancestor is a recognized test function } fn helper() { @@ -526,11 +517,12 @@ calls inside `#[rstest]` tests into injected `#[fixture]` parameters. #### Scope and behaviour -This lint is experimental. The current implementation registers the lint and -loads configuration defaults so teams can opt into the forthcoming rule without -yet receiving helper-call diagnostics. Call-site collection, cross-test -aggregation, and actionable diagnostics are tracked by the later roadmap items -8.2.2 through 8.2.4. +This lint is experimental. The current implementation registers the lint, loads +configuration defaults, and passively collects local helper calls inside strict +`#[rstest]` tests, fingerprinting fixture-local, literal, `const`, and `static` +arguments for later aggregation. The lint remains diagnostic-silent: threshold +evaluation and actionable diagnostics are tracked by 8.2.3, while UI pass/fail +coverage is tracked by 8.2.4. #### Configuration @@ -698,3 +690,46 @@ useful diagnostic. The rule denies only closures whose `panic!` message does not meet the interpolated-only test exception. When the closure contains a static string literal in tests, prefer `.expect("static message")`; only interpolated-only `panic!` fallbacks are permitted there. + +## Clone Detection: AST Feature Extraction + +Whitaker's experimental clone detector runs in two passes. Pass A is a token +scan over the workspace; Pass B lifts each candidate span into an abstract +syntax tree (AST) substrate that later scoring will consume. This release ships +the Pass B substrate only — it does not yet report clones (see below). + +**What the substrate does.** Given a source file and a candidate byte range, +`lower_span` validates a non-empty, UTF-8-aligned `ByteSpan`, parses the +supplied source, and lowers the smallest syntax subtree that covers the span +into a parser-independent `NormalizedTree`. Working from the smallest covering +subtree keeps the representation tied to the candidate rather than the whole +file. + +**Feature vectors.** From a `NormalizedTree`, extraction derives a +deterministic set of features: + +- exact counts of each syntax kind; +- dyadic fixed-point depth weighting (`2^63 >> depth`), so weights halve with + depth and collapse to zero beyond depth 63; +- production bigrams and trigrams (parent-to-child and + grandparent-to-parent-to-child kind sequences); and +- an opaque hexadecimal canonical hash of the normalized subtree. + +**Schema-versioned hashes.** Every canonical hash mixes in +`PARSER_SCHEMA_VERSION`, which is tied to the pinned parser snapshot. A +parser-schema change therefore changes every hash by design, intentionally +invalidating persisted snapshots and caches so stale AST fingerprints are never +reused across incompatible parser versions. + +**Parser feature.** `whitaker_clones_core` enables its exact-pinned +`ra_ap_syntax` parser adapter through the default `parser` feature. The feature +is on by default; building the crate with it disabled makes `lower_span` return +`AstError::ParserUnavailable` instead of lowering anything. + +**Not yet emitted.** Type-3 clone scoring and SARIF Run 1 emission are deferred +to roadmap item 7.3.2. This release builds and exposes the AST substrate only; +it does not score clones or emit AST-based SARIF results. + +Contributors maintaining the pinned parser should follow the +[`ra_ap_syntax` re-pinning runbook](developers-guide.md#ra_ap_syntax-re-pinning-runbook) +in the Developer's Guide. From 59acf9b311e82384177b8d0cec856b97ed6d071a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 27 Jul 2026 23:10:20 +0100 Subject: [PATCH 3/8] Ignore vim swap files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a4ca8d45..9dd7a95b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ **/*.rs.bk **/*~ +.*.swp .crush .claude/ .memdb/ From 193baff2daee0ad3f6c5643f78478030f0f1105d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 27 Jul 2026 23:22:19 +0100 Subject: [PATCH 4/8] Make Netsuke publishable as a single crate Remove the unpublished `ambient_fs` workspace dependency and keep the necessary ambient operations at their application call sites. Exclude `netsuke` from `no_std_fs_operations` through `dylint.toml`, as Whitaker requires. Keep dependency automation, documentation, and lockfiles aligned with the single-crate layout. Register the crates.io installation fence so the README continues to satisfy the executable-documentation contract. --- .github/dependabot.yml | 1 - Cargo.lock | 11 - Cargo.toml | 10 - README.md | 2 + ambient_fs/Cargo.toml | 54 ---- ambient_fs/src/lib.rs | 254 ------------------- docs/developers-guide.md | 18 +- dylint.toml | 9 +- src/runner/process/file_io.rs | 6 +- src/stdlib/which/lookup/mod.rs | 25 +- src/stdlib/which/lookup/workspace/mod.rs | 2 +- src/stdlib/which/lookup/workspace/posix.rs | 2 +- src/stdlib/which/lookup/workspace/windows.rs | 2 +- test_support/Cargo.lock | 151 ++++++++++- tests/documentation_examples_tests.rs | 1 + 15 files changed, 181 insertions(+), 367 deletions(-) delete mode 100644 ambient_fs/Cargo.toml delete mode 100644 ambient_fs/src/lib.rs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0e65e047..cbba9d6e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,7 +18,6 @@ updates: - package-ecosystem: "cargo" directories: - "/" - - "/ambient_fs" - "/test_support" open-pull-requests-limit: 5 labels: diff --git a/Cargo.lock b/Cargo.lock index f5bef9cb..0f6d5db9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,16 +38,6 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" -[[package]] -name = "ambient_fs" -version = "0.1.0" -dependencies = [ - "camino", - "rstest", - "tempfile", - "walkdir", -] - [[package]] name = "anstream" version = "0.6.19" @@ -1434,7 +1424,6 @@ dependencies = [ name = "netsuke" version = "0.1.0" dependencies = [ - "ambient_fs", "anyhow", "assert_cmd", "camino", diff --git a/Cargo.toml b/Cargo.toml index 71dde08e..8b81a480 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] } serde_json = { version = "1", features = ["preserve_order"] } serde_json_canonicalizer = "0.3" tempfile = "3.8.0" -ambient_fs = { path = "ambient_fs" } shell-quote = { version = "0.7.2", default-features = false, features = ["sh"] } shlex = "2.0.1" time = { version = "0.3.44", features = ["formatting", "macros", "parsing", "serde"] } @@ -182,15 +181,6 @@ trybuild = "1.0.116" rustix = { version = "1.0.8", features = ["fs"] } [workspace] -# `ambient_fs` is the sanctioned ambient-filesystem escape hatch, so it must be a -# workspace member for the root-level `cargo fmt`, `cargo doc`, `cargo clippy`, and -# `cargo test` gates to format, document, lint, and run its doctests and unit tests. -members = ["ambient_fs"] -# Operate on both packages by default so the bare-cargo gates (`cargo test`, -# `cargo check`, `cargo clippy`, `cargo doc`) cover `ambient_fs` without needing -# `--workspace`; the root package (netsuke, ".") is listed explicitly because a -# default set that omitted it would stop gating netsuke itself. -default-members = [".", "ambient_fs"] # `test_support` is a path dependency that Cargo would otherwise auto-include as a # member. It is excluded here to keep its gate coverage unchanged in this focused # change; folding it into the workspace is tracked separately as it requires diff --git a/README.md b/README.md index 43cc53cd..9500e8ae 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Netsuke currently requires: To install from crates.io: + + ```sh cargo install netsuke ``` diff --git a/ambient_fs/Cargo.toml b/ambient_fs/Cargo.toml deleted file mode 100644 index c67ff25a..00000000 --- a/ambient_fs/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "ambient_fs" -version = "0.1.0" -edition = "2024" -rust-version = "1.89.0" -publish = false - -[dependencies] -camino = "1.2.0" -walkdir = "2.5" - -[dev-dependencies] -rstest = "0.26.1" -tempfile = "3.8.0" - -[lints.clippy] -pedantic = { level = "warn", priority = -1 } - -# 1. hygiene -# Unfortunately, due to an 'unused-braces' false positive with -# single line rstest fixtures, and the fact that fixtures do -# not permit 'expect' attributes, we must disable this deny. -# allow_attributes = "deny" -allow_attributes_without_reason = "deny" -blanket_clippy_restriction_lints = "deny" - -# 2. debugging leftovers -# Disabled pending further investigation into applicability. -# dbg_macro = "deny" -# print_stdout = "deny" -# print_stderr = "deny" - -# 3. panic-prone operations -unwrap_used = "deny" -# Expect used in tests. Discouraged in implementation code. -# expect_used = "deny" -indexing_slicing = "deny" -string_slice = "deny" -integer_division = "deny" -integer_division_remainder_used = "deny" - -# 4. portability -host_endian_bytes = "deny" -little_endian_bytes = "deny" -big_endian_bytes = "deny" - -# 5. nursery idiom polish -or_fun_call = "deny" -option_if_let_else = "deny" -use_self = "deny" -string_lit_as_bytes = "deny" - -# 6. numerical foot-guns -float_arithmetic = "deny" diff --git a/ambient_fs/src/lib.rs b/ambient_fs/src/lib.rs deleted file mode 100644 index f32cebc4..00000000 --- a/ambient_fs/src/lib.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Deliberately ambient filesystem probes for executable lookup. -//! -//! Netsuke routes filesystem access through capability-scoped `cap_std` -//! handles, enforced by Whitaker's `no_std_fs_operations` lint. The `which` -//! resolver is the one deliberate exception: it consults directories taken -//! from the ambient `PATH` environment variable and canonicalises candidates -//! across arbitrary symlinks, which capability-scoped handles cannot express -//! (a `cap_std` directory handle refuses to follow a symlink that leaves the -//! directory, breaking common layouts such as `/usr/bin/cc -> -//! /etc/alternatives/cc`). -//! -//! This crate confines those ambient operations to a single leaf crate that -//! is excluded from the lint via `dylint.toml`, mirroring the pattern used by -//! other df12 projects. Scope and reuse policy: only the `which` resolver and -//! runner tempfile plumbing may call into this crate; new ambient access -//! elsewhere should use `cap_std` instead. - -use std::{fs, io}; - -use camino::Utf8Path; - -/// Return `true` when `path` names an executable regular file. -/// -/// Follows symlinks, matching `std::fs::metadata` semantics. On Unix an -/// executable file needs at least one execute permission bit; on other -/// platforms any regular file qualifies. -/// -/// # Errors -/// -/// Propagates metadata errors, including `NotFound` when the path is absent; -/// callers decide whether absence is an error. -/// -/// # Examples -/// -/// ``` -/// use camino::Utf8Path; -/// -/// let sh = Utf8Path::new("/bin/sh"); -/// if let Ok(executable) = ambient_fs::is_executable_file(sh) { -/// assert!(executable, "/bin/sh should be executable where it exists"); -/// } -/// ``` -pub fn is_executable_file(path: &Utf8Path) -> io::Result { - let metadata = fs::metadata(path.as_std_path())?; - Ok(metadata.is_file() && has_execute_permission(&metadata)) -} - -#[cfg(unix)] -fn has_execute_permission(metadata: &fs::Metadata) -> bool { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 -} - -#[cfg(not(unix))] -fn has_execute_permission(metadata: &fs::Metadata) -> bool { - metadata.is_file() -} - -/// Canonicalise `path`, resolving symlinks across the whole filesystem. -/// -/// Returns the canonical path as a raw `std::path::PathBuf` so callers keep -/// control over UTF-8 validation and its error mapping. -/// -/// # Errors -/// -/// Propagates the underlying `canonicalize` failure (missing path, permission -/// denied, and so on). -/// -/// # Examples -/// -/// ``` -/// use camino::Utf8Path; -/// -/// let canonical = ambient_fs::canonicalize(Utf8Path::new(".")).expect("cwd resolves"); -/// assert!(canonical.is_absolute()); -/// ``` -pub fn canonicalize(path: &Utf8Path) -> io::Result { - fs::canonicalize(path.as_std_path()) -} - -/// Return `true` when a walk entry refers to a regular file. -/// -/// # Examples -/// -/// ``` -/// let mut walker = walkdir::WalkDir::new(".").max_depth(1).into_iter(); -/// let root = walker.next().expect("walk yields the root").expect("root is readable"); -/// assert!(!ambient_fs::entry_is_file(&root), "the root directory is not a file"); -/// ``` -#[must_use] -pub fn entry_is_file(entry: &walkdir::DirEntry) -> bool { - entry.file_type().is_file() -} - -/// Return `true` when a walk entry refers to a directory. -/// -/// # Examples -/// -/// ``` -/// let mut walker = walkdir::WalkDir::new(".").max_depth(1).into_iter(); -/// let root = walker.next().expect("walk yields the root").expect("root is readable"); -/// assert!(ambient_fs::entry_is_dir(&root), "the walk root is a directory"); -/// ``` -#[must_use] -pub fn entry_is_dir(entry: &walkdir::DirEntry) -> bool { - entry.file_type().is_dir() -} - -/// Flush a file's contents and metadata to disk via its open handle. -/// -/// Used for tempfiles that live in the ambient system temp directory, where -/// no capability-scoped directory handle exists to sync through. -/// -/// # Errors -/// -/// Propagates the underlying `sync_all` failure. -/// -/// # Examples -/// -/// ``` -/// let file = tempfile::tempfile().expect("create tempfile"); -/// ambient_fs::sync_file(&file).expect("sync tempfile"); -/// ``` -pub fn sync_file(file: &fs::File) -> io::Result<()> { - file.sync_all() -} - -#[cfg(test)] -mod tests { - //! Unit tests for the ambient filesystem probes. - use super::{canonicalize, is_executable_file, sync_file}; - use camino::{Utf8Path, Utf8PathBuf}; - use rstest::{fixture, rstest}; - use std::fs; - use std::io; - - /// Assert that probing `path` with `is_executable_file` yields - /// `expected`. - /// - /// Splits the fallible probe (the command) from the assertion on its - /// result (the query) so an I/O failure reads as a probe error, not a - /// wrong answer; a macro rather than a helper fn keeps panic line - /// numbers pointing at the calling test. - macro_rules! assert_executable_probe { - ($path:expr, $expected:expr) => { - let probed = is_executable_file($path).expect("probe executable state"); - assert_eq!( - probed, $expected, - "unexpected executable probe for {}", - $path - ); - }; - } - - /// Create a temporary directory and its owned UTF-8 path. The `TempDir` is - /// returned so the caller keeps it alive for the directory's lifetime. - /// - /// Returns a `Result` because fixtures arrange state rather than assert - /// on it: arrangement failures must propagate to the test body, which is - /// the only place a panic is a test verdict (and the only context - /// Whitaker's `no_expect_outside_tests` recognises). - #[fixture] - fn temp_dir() -> io::Result<(tempfile::TempDir, Utf8PathBuf)> { - let dir = tempfile::tempdir()?; - let path = Utf8Path::from_path(dir.path()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "temp path is not UTF-8"))? - .to_owned(); - Ok((dir, path)) - } - - #[cfg(unix)] - #[rstest] - #[case(0o755, true)] - #[case(0o644, false)] - fn is_executable_file_reflects_unix_execute_bits(#[case] mode: u32, #[case] expected: bool) { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().expect("create tempdir"); - let path = dir.path().join("candidate"); - fs::write(&path, b"").expect("write candidate file"); - let mut perms = fs::metadata(&path).expect("read metadata").permissions(); - perms.set_mode(mode); - fs::set_permissions(&path, perms).expect("set permissions"); - let utf8 = Utf8Path::from_path(&path).expect("temp path is UTF-8"); - assert_executable_probe!(utf8, expected); - } - - #[cfg(not(unix))] - #[rstest] - fn is_executable_file_accepts_any_regular_file() { - let dir = tempfile::tempdir().expect("create tempdir"); - let path = dir.path().join("candidate"); - fs::write(&path, b"").expect("write candidate file"); - let utf8 = Utf8Path::from_path(&path).expect("temp path is UTF-8"); - assert_executable_probe!(utf8, true); - } - - #[rstest] - fn is_executable_file_rejects_directories( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - let (_dir, utf8) = temp_dir?; - assert_executable_probe!(&utf8, false); - Ok(()) - } - - #[rstest] - fn is_executable_file_reports_missing_paths( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - let (_dir, dir_path) = temp_dir?; - let missing = dir_path.join("does-not-exist"); - let err = is_executable_file(&missing).expect_err("missing path should error"); - assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - Ok(()) - } - - #[rstest] - fn canonicalize_resolves_relative_traversal( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - // Force real filesystem resolution: a `..` round-trip only collapses - // back to the original directory if `canonicalize` consults the - // filesystem rather than returning its input unchanged. `std::fs` - // canonicalization of the un-traversed path is the oracle. - let (_dir, root) = temp_dir?; - let nested = root.join("nested"); - fs::create_dir(nested.as_std_path()).expect("create nested dir"); - let traversed = nested.join("..").join("nested"); - let resolved = canonicalize(&traversed).expect("canonicalize traversal"); - let expected = fs::canonicalize(nested.as_std_path()).expect("canonicalize nested"); - assert_eq!(resolved, expected); - Ok(()) - } - - #[rstest] - fn sync_file_persists_written_bytes( - temp_dir: io::Result<(tempfile::TempDir, Utf8PathBuf)>, - ) -> io::Result<()> { - // Behavioural oracle: sync then reopen by path and read back, proving - // the bytes are persisted rather than merely that `sync_all` returned - // `Ok`. (True crash durability cannot be verified without OS-level - // fault injection, which is out of scope here.) - use std::io::Write as _; - let (_dir, root) = temp_dir?; - let path = root.join("synced"); - let payload = b"ambient_fs sync payload".to_vec(); - let mut file = fs::File::create(path.as_std_path()).expect("create file for sync"); - file.write_all(&payload).expect("write payload"); - sync_file(&file).expect("sync file"); - let read_back = fs::read(path.as_std_path()).expect("read synced file"); - assert_eq!(read_back, payload); - Ok(()) - } -} diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 8baf6b6a..b9b7a0cf 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -94,8 +94,11 @@ Whitaker is configured by `dylint.toml` at the repository root. The `no_std_fs_operations` lint currently ignores in-source `allow`/`expect` attributes, so `dylint.toml` excludes each sanctioned ambient-filesystem scope with a documented rationale: the `build_script_build` Cargo build-script crate, -the `ambient_fs` application leaf crate, the `test_support` test-fixture crate, -and the enumerated integration-test and workflow-contract crates. +the `netsuke` application crate, the `test_support` test-fixture crate, and the +enumerated integration-test and workflow-contract crates. Netsuke itself needs +ambient access for executable discovery through `PATH`, cross-directory symlink +canonicalization, and temporary-file synchronization; other filesystem access +should remain capability-scoped. When command output is long, preserve exit codes and logs: @@ -176,13 +179,10 @@ The caller passes two configuration inputs, each carrying intent: feature) as untested. The caller does not set `extra-crate-dirs`, the input reserved for crate -directories outside the Cargo workspace. `ambient_fs`, the repository's -sanctioned ambient-filesystem escape hatch, is a genuine workspace member -(`[workspace] members = ["ambient_fs"]`, with -`default-members = [".", "ambient_fs"]`), not a non-workspace companion crate, -so it needs no separate mutation invocation here. `test_support` is -deliberately excluded from the workspace (`exclude = ["test_support"]`) and -this workflow does not mutate it. +directories outside the Cargo workspace. Netsuke is a single publishable crate; +its sanctioned ambient-filesystem operations live at their application call +sites. `test_support` is deliberately excluded from the workspace +(`exclude = ["test_support"]`) and this workflow does not mutate it. The `uses:` reference pins the shared workflow to a full 40-character commit SHA rather than a branch or tag, so a force-push upstream cannot silently diff --git a/dylint.toml b/dylint.toml index eb80648d..726c02c9 100644 --- a/dylint.toml +++ b/dylint.toml @@ -5,9 +5,10 @@ # - build_script_build: the Cargo build script writes generated man pages and # audit artefacts to ambient paths supplied by Cargo (`OUT_DIR`, `target/`), # where capability-based handles offer no benefit. -# - ambient_fs: the leaf crate that deliberately confines the ambient -# filesystem probes needed by the `which` resolver (PATH lookup, full-path -# canonicalisation, workspace walking); see ambient_fs/src/lib.rs. +# - netsuke: the application crate deliberately performs executable probes in +# directories supplied by ambient PATH, follows cross-directory symlinks when +# canonicalising those executables, and syncs its ambient temporary file. +# These application-boundary operations cannot be capability-scoped. # - test_support: the shared test-fixture crate; fixture management uses # ambient tempdir access by design (see test_support/src/fs.rs), matching # the "test support utilities" exclusion in the Whitaker user's guide. @@ -18,7 +19,7 @@ [no_std_fs_operations] excluded_crates = [ "build_script_build", - "ambient_fs", + "netsuke", "test_support", "advanced_usage_tests", "assert_cmd_tests", diff --git a/src/runner/process/file_io.rs b/src/runner/process/file_io.rs index ea68a0b5..56c965ee 100644 --- a/src/runner/process/file_io.rs +++ b/src/runner/process/file_io.rs @@ -35,10 +35,10 @@ pub fn create_temp_ninja_file(content: &NinjaContent) -> AnyResult io::Result<()> { - ambient_fs::sync_file(tmp.as_file()) + tmp.as_file().sync_all() } pub fn write_text_file_utf8(dir: &cap_fs::Dir, path: &Utf8Path, content: &str) -> AnyResult<()> { diff --git a/src/stdlib/which/lookup/mod.rs b/src/stdlib/which/lookup/mod.rs index 369bb6be..223c926d 100644 --- a/src/stdlib/which/lookup/mod.rs +++ b/src/stdlib/which/lookup/mod.rs @@ -1,10 +1,10 @@ //! Filesystem search utilities for resolving commands for the `which` feature. //! -//! Executable probes and canonicalisation go through the `ambient_fs` crate: -//! PATH lookup is deliberately ambient, so it cannot use the capability-based -//! handles mandated elsewhere in Netsuke. +//! PATH lookup is deliberately ambient, so executable probes and +//! canonicalisation cannot use the capability-based handles mandated elsewhere +//! in Netsuke. -use std::io; +use std::{fs, io}; use camino::{Utf8Path, Utf8PathBuf}; use indexmap::IndexSet; @@ -208,8 +208,8 @@ fn candidates_for_dir(env: &EnvSnapshot, dir: &Utf8Path, command: &str) -> Vec Result { - match ambient_fs::is_executable_file(path) { - Ok(executable) => Ok(executable), + match fs::metadata(path.as_std_path()) { + Ok(metadata) => Ok(is_executable_metadata(&metadata)), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), Err(source) => Err(ResolveError::IsExecutable { path: path.to_owned(), @@ -218,6 +218,17 @@ pub(super) fn is_executable(path: &Utf8Path) -> Result { } } +#[cfg(unix)] +fn is_executable_metadata(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable_metadata(metadata: &fs::Metadata) -> bool { + metadata.is_file() +} + #[derive(Clone, Copy)] struct HandleMissContext<'a> { env: &'a EnvSnapshot, @@ -254,7 +265,7 @@ pub(super) fn canonicalise(paths: Vec) -> Result, let mut resolved = Vec::new(); for path in paths { let canonical = - ambient_fs::canonicalize(&path).map_err(|source| ResolveError::Canonicalise { + fs::canonicalize(path.as_std_path()).map_err(|source| ResolveError::Canonicalise { path: path.clone(), source, })?; diff --git a/src/stdlib/which/lookup/workspace/mod.rs b/src/stdlib/which/lookup/workspace/mod.rs index c4b06017..05521478 100644 --- a/src/stdlib/which/lookup/workspace/mod.rs +++ b/src/stdlib/which/lookup/workspace/mod.rs @@ -99,7 +99,7 @@ pub(super) fn search_workspace( } pub(super) fn should_visit_entry(entry: &walkdir::DirEntry, skip_dirs: &WorkspaceSkipList) -> bool { - if !ambient_fs::entry_is_dir(entry) { + if !entry.file_type().is_dir() { return true; } let name = entry.file_name().to_string_lossy(); diff --git a/src/stdlib/which/lookup/workspace/posix.rs b/src/stdlib/which/lookup/workspace/posix.rs index 32b0b094..76c01463 100644 --- a/src/stdlib/which/lookup/workspace/posix.rs +++ b/src/stdlib/which/lookup/workspace/posix.rs @@ -55,7 +55,7 @@ fn process_workspace_entry( command: &str, _skip_dirs: &WorkspaceSkipList, ) -> Result, ResolveError> { - if !ambient_fs::entry_is_file(&entry) { + if !entry.file_type().is_file() { return Ok(None); } diff --git a/src/stdlib/which/lookup/workspace/windows.rs b/src/stdlib/which/lookup/workspace/windows.rs index b1b33436..9e777e0b 100644 --- a/src/stdlib/which/lookup/workspace/windows.rs +++ b/src/stdlib/which/lookup/workspace/windows.rs @@ -90,7 +90,7 @@ fn match_workspace_entry( command: &str, ctx: &WorkspaceMatchContext, ) -> Result, ResolveError> { - if !ambient_fs::entry_is_file(&entry) { + if !entry.file_type().is_file() { return Ok(None); } diff --git a/test_support/Cargo.lock b/test_support/Cargo.lock index ca344df7..8584156e 100644 --- a/test_support/Cargo.lock +++ b/test_support/Cargo.lock @@ -38,14 +38,6 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" -[[package]] -name = "ambient_fs" -version = "0.1.0" -dependencies = [ - "camino", - "walkdir", -] - [[package]] name = "anstream" version = "0.6.21" @@ -168,6 +160,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.10.0" @@ -250,7 +257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 1.3.0", ] [[package]] @@ -568,6 +575,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1126,7 +1139,6 @@ dependencies = [ name = "netsuke" version = "0.1.0" dependencies = [ - "ambient_fs", "anyhow", "camino", "cap-std", @@ -1150,7 +1162,7 @@ dependencies = [ "serde_json_canonicalizer", "sha2", "shell-quote", - "shlex", + "shlex 2.0.1", "sys-locale", "tempfile", "thiserror 1.0.69", @@ -1356,6 +1368,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "2.1.5" @@ -1434,6 +1455,31 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -1449,6 +1495,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "rapidhash" version = "4.4.2" @@ -1658,6 +1742,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1832,6 +1928,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1976,6 +2078,7 @@ dependencies = [ "cap-std", "mockable", "netsuke", + "proptest", "rstest", "sha2", "tempfile", @@ -2256,6 +2359,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "uncased" version = "0.9.10" @@ -2750,6 +2859,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index a083ec17..97dd4853 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -33,6 +33,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-source-install", "guide-utility-commands", "guide-windows-help", + "readme-crates-io-install", "readme-first-build-commands", "readme-first-build-manifest", "readme-source-install", From c7bb33bf0273d751d893b30e4e05f11748cbbfa0 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 27 Jul 2026 23:31:00 +0100 Subject: [PATCH 5/8] Require rust-analyzer --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 908d2ecb..8f0ca5d1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.89.0" -components = ["rustfmt", "clippy"] +components = ["rustfmt", "clippy", "rust-analyzer"] From b27e73d46be6e7071105a2a02c9fbe548b85bb82 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 28 Jul 2026 09:45:06 +0100 Subject: [PATCH 6/8] Ignore all swap files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9dd7a95b..b9505125 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ target/ **/*.rs.bk **/*~ -.*.swp +*.swp .crush .claude/ .memdb/ From 598961dafa36a202ad949b5c1c9e54c5e52c939d Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 28 Jul 2026 09:50:15 +0100 Subject: [PATCH 7/8] Document crates.io installation in the user guide Add the registry installation command alongside the source checkout flow. Extend the executable documentation contract so the user guide and README cannot silently diverge. --- docs/users-guide.md | 10 +++++++++- tests/documentation_examples_tests.rs | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index dbed2bb1..cd142a9f 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -13,7 +13,15 @@ change before 1.0. Pin the Netsuke version in automated workflows. Netsuke requires [Ninja](https://ninja-build.org/) on `PATH`. A source build also requires Rust 1.89 or later. -Until v0.1.0 is published, the current checkout can be installed with Cargo: +To install a published release from crates.io: + + + +```sh +cargo install netsuke +``` + +To install the current source checkout with Cargo: diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 97dd4853..4f6d65a3 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -21,6 +21,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-cli-usage", "guide-command-available-manifest", "guide-complete-manifest", + "guide-crates-io-install", "guide-first-build-commands", "guide-first-build-manifest", "guide-foreach-manifest", @@ -149,6 +150,18 @@ fn documented_first_run_flow_builds( #[test] fn installation_examples_match_source_and_release_contracts() -> Result<()> { + let readme_release = documented_example("readme-crates-io-install")?; + let guide_release = documented_example("guide-crates-io-install")?; + let expected_release = "cargo install netsuke\n"; + ensure!( + readme_release.body == expected_release, + "README crates.io install drifted" + ); + ensure!( + guide_release.body == expected_release, + "user's guide crates.io install drifted" + ); + let readme = documented_example("readme-source-install")?; let guide = documented_example("guide-source-install")?; let expected = concat!( From 953ced25ae83b3492bf1b78ce2fc58e3fb49ea0e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 28 Jul 2026 09:50:55 +0100 Subject: [PATCH 8/8] Ignore .grepai/ dir --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b9505125..d1103426 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,10 @@ target/ **/*.rs.bk **/*~ *.swp -.crush +.crush/ .claude/ .memdb/ +.grepai/ build.ninja *:Zone.Identifier /graph.dot