1102d776df
Iteration stderr-markers-2, tasks 3-4 of the stderr-class-markers plan. aura-runner's four continuing-run diagnostics join the class grammar as hand-written literals (the #295 boundary keeps the macro owner in the presentation crate): the stale-dylib warning gains the `aura: ` namespace, and the three tap/no-nominee notices become `aura: note: ` — the latter two extracted into pure note-builder fns with unit pins. The two e2e pins on the cost-model tap string moved with the retag (research_docs.rs). A new project_nodes e2e exercises the real stale- dylib load path: fresh build emits nothing, a source newer than the dylib emits exactly one class-marked warning and still succeeds. The #313 notice ships on both walk-forward paths: `aura: note: all N walk-forward windows recorded zero trades` before the summary JSON, exit 0 untouched (a null result is a valid research result, #198). Wording and condition live in one shared diag helper taking the per-window trade counts (orchestrator tidy of the reviewer's duplicated-predicate residue); the call sites keep only the path-specific field extraction. Three e2es pin it: the equal-length degenerate on the synthetic path (exactly one note, no warnings, exit 0, summary unchanged), a traded negative twin, and the real/sugar-path degenerate gated on the GER40 archive. refs #278, refs #313
296 lines
14 KiB
Rust
296 lines
14 KiB
Rust
//! `aura nodes new` (#241): sibling node-crate scaffold + [nodes] attach.
|
|
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
|
|
fn temp_cwd(tag: &str) -> std::path::PathBuf {
|
|
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
|
|
Command::new(env!("CARGO_BIN_EXE_aura"))
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("spawn aura")
|
|
}
|
|
|
|
/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must
|
|
/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so
|
|
/// its own pre-create `remove_dir_all` reclaims the previous run instead of
|
|
/// stranding one `aura-nodes-*` directory per test-binary invocation.
|
|
///
|
|
/// The helper today keys the name on `std::process::id()`, so every run mints a
|
|
/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs
|
|
/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling
|
|
/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This
|
|
/// pins that property at one representative site — the assertion reads the path
|
|
/// value only, so it is deterministic and does not depend on any external state.
|
|
#[test]
|
|
fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() {
|
|
let sandbox = temp_cwd("leakguard");
|
|
// Read the path value, then reclaim immediately so a red run of this very
|
|
// test does not itself add to the leak it describes.
|
|
let path = sandbox.clone();
|
|
let _ = std::fs::remove_dir_all(&sandbox);
|
|
|
|
let pid = std::process::id().to_string();
|
|
assert!(
|
|
!path.to_string_lossy().contains(&pid),
|
|
"sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \
|
|
can reclaim it — one directory leaks per run: {}",
|
|
path.display(),
|
|
);
|
|
assert!(
|
|
!path.starts_with(std::env::temp_dir()),
|
|
"sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}",
|
|
path.display(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
|
|
let cwd = temp_cwd("attach");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(stdout.contains("lab_nodes"), "names the namespace: {stdout}");
|
|
let crate_dir = cwd.join("lab-nodes");
|
|
assert!(crate_dir.join("Cargo.toml").is_file());
|
|
assert!(crate_dir.join("src/lib.rs").is_file());
|
|
assert!(!crate_dir.join("Aura.toml").exists(), "a node crate is not a project");
|
|
let toml = std::fs::read_to_string(proj.join("Aura.toml")).unwrap();
|
|
assert!(toml.contains("[nodes]"), "{toml}");
|
|
assert!(toml.contains("../lab-nodes"), "{toml}");
|
|
}
|
|
|
|
/// Property (#243): a freshly scaffolded node crate stamps resolvable
|
|
/// provenance too — `aura nodes new` follows the same `git init` with an
|
|
/// initial commit as `aura new` (`scaffold_node_crate`, `scaffold.rs`), so
|
|
/// `git rev-parse HEAD` resolves from the crate's very first run rather than
|
|
/// leaving HEAD unborn. This half of #243's property runs and is asserted
|
|
/// separately from `new_outside_a_work_tree_leaves_a_resolvable_head`
|
|
/// (`project_new.rs`), which covers the `aura new` project half only.
|
|
#[test]
|
|
fn nodes_new_leaves_a_resolvable_head() {
|
|
let cwd = temp_cwd("resolvable-head");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
let out = aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let crate_dir = cwd.join("lab-nodes");
|
|
let head = Command::new("git")
|
|
.args(["rev-parse", "HEAD"])
|
|
.current_dir(&crate_dir)
|
|
.output()
|
|
.expect("run git rev-parse HEAD");
|
|
assert!(
|
|
head.status.success(),
|
|
"fresh node-crate scaffold has no HEAD (`git rev-parse HEAD` failed): {}",
|
|
String::from_utf8_lossy(&head.stderr)
|
|
);
|
|
}
|
|
|
|
/// Property (#241): `--namespace` sets the vocabulary prefix independently
|
|
/// of the crate's own (directory) name — a crate can be named anything, but
|
|
/// the blueprints that reference its nodes address them under the namespace
|
|
/// the flag chose, never a name silently derived from the crate name. This
|
|
/// is exercised transitively by the big sweep/campaign fixture
|
|
/// (`project_sweep_campaign.rs`, `--namespace knob_lab` on a `knob-lab-nodes`
|
|
/// crate) but a failure there would not cleanly bisect to this property; this
|
|
/// test isolates it.
|
|
#[test]
|
|
fn nodes_new_namespace_flag_overrides_crate_derived_default() {
|
|
let cwd = temp_cwd("ns-override");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
let out = aura(
|
|
&["nodes", "new", "weird-crate-name", "--namespace", "custom_ns", "--engine-path", &engine],
|
|
&proj,
|
|
);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(stdout.contains("custom_ns"), "reports the override namespace: {stdout}");
|
|
assert!(
|
|
!stdout.contains("weird_crate_name"),
|
|
"must not fall back to the crate-derived default namespace: {stdout}"
|
|
);
|
|
let lib = std::fs::read_to_string(cwd.join("weird-crate-name/src/lib.rs")).unwrap();
|
|
assert!(
|
|
lib.contains("custom_ns::Scale") && lib.contains("namespace: \"custom_ns\""),
|
|
"the generated vocabulary is registered under the override namespace: {lib}"
|
|
);
|
|
assert!(
|
|
!lib.contains("weird_crate_name"),
|
|
"the crate-derived name must not leak into the vocabulary: {lib}"
|
|
);
|
|
}
|
|
|
|
/// Property (#241): the single-crate attach guard checks for the *presence*
|
|
/// of a `[nodes]` section in `Aura.toml`, not merely a non-empty `crates`
|
|
/// list — a hand-edited `Aura.toml` carrying an empty `[nodes]` section still
|
|
/// refuses a second attach. Without this, `aura nodes new` would scaffold a
|
|
/// crate that `append_nodes_pointer` then refuses to wire in, leaving an
|
|
/// orphaned crate on disk.
|
|
#[test]
|
|
fn nodes_new_refuses_when_nodes_section_present_but_crates_empty() {
|
|
let cwd = temp_cwd("empty-section");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let toml_path = proj.join("Aura.toml");
|
|
let mut toml = std::fs::read_to_string(&toml_path).unwrap();
|
|
toml.push_str("\n[nodes]\ncrates = []\n");
|
|
std::fs::write(&toml_path, toml).unwrap();
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
let out = aura(&["nodes", "new", "x-nodes", "--engine-path", &engine], &proj);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already attached"),
|
|
"{}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
assert!(
|
|
!cwd.join("x-nodes").exists(),
|
|
"the guard must fire before scaffolding the crate (no orphaned crate on disk)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_nodes_new_refuses_single_crate() {
|
|
let cwd = temp_cwd("second");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
assert!(aura(&["nodes", "new", "a-nodes", "--engine-path", &engine], &proj).status.success());
|
|
let out = aura(&["nodes", "new", "b-nodes", "--engine-path", &engine], &proj);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("already attached"),
|
|
"{}", String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nodes_new_outside_a_project_refuses() {
|
|
let cwd = temp_cwd("outside");
|
|
let out = aura(&["nodes", "new", "x-nodes"], &cwd);
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stderr).contains("needs a project"),
|
|
"{}", String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
/// The full role-2 loop: scaffold, build, and the project resolves the
|
|
/// crate's namespace end to end (vocabulary listing names the node).
|
|
#[test]
|
|
fn attached_crate_namespace_resolves_after_build() {
|
|
let cwd = temp_cwd("resolve");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
|
let build = Command::new("cargo")
|
|
.arg("build")
|
|
.current_dir(cwd.join("lab-nodes"))
|
|
.output()
|
|
.expect("cargo build");
|
|
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
|
|
let out = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
|
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
|
assert!(
|
|
String::from_utf8_lossy(&out.stdout).contains("lab_nodes::Scale"),
|
|
"{}", String::from_utf8_lossy(&out.stdout)
|
|
);
|
|
}
|
|
|
|
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
|
|
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
|
|
/// the stale dylib, and still succeeds (the run proceeds with the existing
|
|
/// dylib rather than refusing) — staleness is a continuing-run warning, never
|
|
/// a fault. A freshly built dylib (no touched source since) emits none. Real
|
|
/// `cargo build` + a real `libloading::Library::new` load, exercising
|
|
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
|
|
/// `stale_warning` helper alone.
|
|
#[test]
|
|
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
|
|
let cwd = temp_cwd("stale-warn");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
|
let crate_root = cwd.join("lab-nodes");
|
|
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
|
|
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
|
|
|
|
// Fresh build, untouched source: no staleness warning.
|
|
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
|
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
|
|
assert!(
|
|
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
|
|
"a freshly built dylib is not stale: {}",
|
|
String::from_utf8_lossy(&fresh.stderr)
|
|
);
|
|
|
|
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
|
|
// dylib's just-completed build time) without rebuilding — the exact
|
|
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
|
|
let lib_rs = crate_root.join("src/lib.rs");
|
|
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
|
|
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
|
|
.expect("bump lib.rs mtime past the dylib's build time");
|
|
|
|
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
|
assert!(
|
|
stale.status.success(),
|
|
"staleness is a warning, not a refusal: {}",
|
|
String::from_utf8_lossy(&stale.stderr)
|
|
);
|
|
let stderr = String::from_utf8_lossy(&stale.stderr);
|
|
assert_eq!(
|
|
stderr.matches("may be stale").count(),
|
|
1,
|
|
"exactly one staleness warning: {stderr}"
|
|
);
|
|
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
|
|
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
|
|
}
|
|
|
|
/// Property (#316 self-description): the scaffold's own seed node ships a
|
|
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
|
|
/// — non-empty and not a bare restatement of the type name. This exact text
|
|
/// is what a new extension author copy-pastes as the very first node of
|
|
/// their crate; if the scaffold's own doc failed the gate it would teach the
|
|
/// wrong pattern (an alibi doc) to every project built from it, and nothing
|
|
/// else would catch that — `NodeSchema.doc` is required at compile time
|
|
/// (E0063 without it) but its *shape* is never checked by the compiler.
|
|
#[test]
|
|
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
|
|
let cwd = temp_cwd("doc-gate");
|
|
assert!(aura(&["new", "lab"], &cwd).status.success());
|
|
let proj = cwd.join("lab");
|
|
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
|
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
|
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
|
|
let doc_line = lib
|
|
.lines()
|
|
.find(|l| l.trim_start().starts_with("doc:"))
|
|
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
|
|
let doc_text = doc_line
|
|
.split_once('"')
|
|
.and_then(|(_, rest)| rest.rsplit_once('"'))
|
|
.map(|(text, _)| text)
|
|
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
|
|
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
|
|
panic!("scaffold's own doc line fails its own gate: {f:?} — {doc_text:?}")
|
|
});
|
|
}
|