diff --git a/Cargo.toml b/Cargo.toml index 47d8230..5ecf1e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,6 @@ blake3 = "1" # Build Identity hashing (LyraCore#316): the recorded inputs are specified as SHA-256, distinct # from `artifact.rs`'s BLAKE3 canonical-form digest, which this crate also carries. sha2 = "0.10" +tempfile = "3" [dev-dependencies] -tempfile = "3" diff --git a/src/cmd/packages/artifact.rs b/src/cmd/packages/artifact.rs index 777d947..c61c473 100644 --- a/src/cmd/packages/artifact.rs +++ b/src/cmd/packages/artifact.rs @@ -141,12 +141,33 @@ pub struct Enabled { pub scripts: Vec, } +impl Enabled { + /// The Script Artifact files, for the steps that hand a path to a subprocess or a sidecar + /// rather than reading the parsed artifact. + #[must_use] + pub fn script_paths(&self) -> Vec { + self.scripts + .iter() + .map(|artifact| artifact.path.clone()) + .collect() + } +} + /// Every enabled Package's artifacts, ordered by Package folder then file name so the same tree /// always produces the same plan. /// /// A missing root is an error, never an empty set: "no Package claims this family" is a statement /// the operator makes by pointing at a real Package Inventory that happens to hold no artifacts. pub fn read_enabled(root: &Path) -> Result { + read_enabled_except(root, &[]) +} + +/// The same walk, passing over files the caller is about to retire. +/// +/// `packages build` holds a Package's prebuilt Script Artifact on disk until its compiled +/// replacement validates, and for that moment two files name one Package — which the plain walk +/// refuses, rightly, for every other caller. +pub fn read_enabled_except(root: &Path, retiring: &[PathBuf]) -> Result { if !root.is_dir() { return Err(Error::Usage(format!( "enabled packages root `{}` is not a directory — name the directory holding the \ @@ -170,6 +191,7 @@ pub fn read_enabled(root: &Path) -> Result { let mut files: Vec = std::fs::read_dir(&generated)? .filter_map(|entry| entry.ok().map(|e| e.path())) .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .filter(|path| !retiring.contains(path)) .collect(); files.sort(); diff --git a/src/cmd/packages/build.rs b/src/cmd/packages/build.rs index 322d651..2f9b019 100644 --- a/src/cmd/packages/build.rs +++ b/src/cmd/packages/build.rs @@ -1,7 +1,7 @@ //! `lyracore packages build` — regenerate the Datascript typings, typecheck against them, then run -//! and validate every enabled Package's Datascripts. +//! every enabled Package's Datascripts, compile its Runtime Scripts, and validate what came out. //! -//! A version gate, then up to seven steps, in this order, and the order is the contract: +//! A version gate, then up to eight steps, in this order, and the order is the contract: //! //! 0. `bun --version` must match the checkout's exact pin. A hard failure, unlike `doctor`'s Bun //! check: `doctor` only reports drift on a machine that may never run Bun at all, but this @@ -26,22 +26,34 @@ //! `Bun.main`, the running process's own entry script, into the artifact's `source_hash`; //! importing the script into one host process instead would hash the host, not the script. The //! first script to fail stops the build: later scripts, and later Packages, never run. -//! 6. `cargo run -p lyracore-package-delta --bin lyracore-delta-check` traces every enabled -//! Package's generated artifacts TOGETHER, in one invocation. A Claim Conflict is between two -//! Packages, so checking one artifact at a time could only ever prove that one artifact parses. -//! This is the authoritative Rust-side check — the same trace `packages replay` runs before it -//! writes to a Shard — so a Package Delta a Datascript just emitted is validated by the code that -//! also decides whether it may apply, not by a second, looser implementation of the same rules. -//! 7. A Build Identity sidecar (`packages::identity`) is written next to each artifact that just -//! validated: every input the artifact was built from, so `packages check`, `preflight` and CI -//! can tell later whether it is still current. Writing it only after step 6 succeeds means a -//! sidecar never describes an artifact this build itself would have refused. +//! 6. Every enabled Package with an immediate `.ts` or `.lua` file under +//! `packages//scripts/` compiles its Runtime Scripts into one Script Artifact, again one +//! `bun run` subprocess per Package. A source-built artifact is removed when its last source +//! disappears. A prebuilt artifact stays available until its source-built replacement passes +//! step 7, and is restored if compilation or validation fails. See [`super::script`] for the +//! source-free prebuilt mode. +//! 7. `cargo run -p lyracore-package-delta --bin lyracore-delta-check` traces every enabled +//! Package's generated artifacts TOGETHER, in one invocation, Package Deltas and Script +//! Artifacts alike. A Claim Conflict and a Runtime Script collision are both BETWEEN Packages, +//! so checking one artifact at a time could only ever prove that one artifact parses. This is +//! the authoritative Rust-side check — the same trace `packages replay` runs before it writes to +//! a Shard — so what a build just emitted is validated by the code that also decides whether it +//! may apply, not by a second, looser implementation of the same rules. +//! 8. A Build Identity sidecar (`packages::identity`) is written next to each source-built artifact +//! that just validated: every input it was built from, so `packages check`, `preflight` and CI +//! can tell later whether it is still current. A source-free prebuilt Script Artifact has no +//! local author inputs or sidecar; the authoritative checker still parses and traces it. The +//! sidecar persists before its prebuilt predecessor retires. Before compilation, the old +//! entries gain unique same-directory hard-link backups, so an in-process failure restores them +//! without rewriting their contents. A process interruption may leave a named backup and an +//! inconsistent candidate; the next build refuses that stale backup without changing either, +//! and `packages check` refuses an uncertified candidate. //! -//! Steps 1-3, 5 and 6 STREAM to the terminal rather than being captured. `tsc` writes its +//! Steps 1-3 and 5-7 STREAM to the terminal rather than being captured. `tsc` writes its //! diagnostics to stdout, so a captured run surfaced an empty error and lost the file and line this //! command promises; a Datascript's own thrown error and the validator's own conflict report are the //! same kind of thing. Step 0 captures instead: a `--version` banner is one line, and the gate needs -//! to read it, not relay it. +//! to read it, not relay it. So does step 6's first call, which reads the Event Binding catalogue. //! //! Generating FIRST among the streamed steps is what gives the gate teeth. The typings are derived //! from the Module every run, so a Datascript naming a column the Module renamed fails here, at @@ -64,7 +76,7 @@ use std::path::{Path, PathBuf}; use crate::cmd::doctor::{self, BunVersionCheck}; -use crate::cmd::packages::{artifact, identity}; +use crate::cmd::packages::{artifact, identity, script}; use crate::cmd::preflight; use crate::proc::{CommandSpec, ProcessRunner}; use crate::project::ProjectLayout; @@ -225,26 +237,30 @@ fn run_datascripts( Ok(()) } -/// The Rust-side authoritative check: every enabled Package's generated artifacts, traced together -/// in one `lyracore-delta-check` invocation. Discovered the same way `packages replay` discovers -/// them — a folder listing, not a re-parse by this command — so build-time validation and -/// replay-time preflight can never disagree about which files are in play. +/// The Rust-side authoritative check: every enabled Package's generated artifacts, of both kinds, +/// traced together in one `lyracore-delta-check` invocation. Discovered the same way `packages +/// replay` discovers them — a folder listing, not a re-parse by this command — so build-time +/// validation and replay-time preflight can never disagree about which files are in play. /// -/// Returns what it discovered and validated, so step 7 can write each artifact's Build Identity +/// Returns what it discovered and validated, so step 8 can write each artifact's Build Identity /// without re-reading a tree this step just finished checking. -fn validate_generated_deltas( +fn validate_generated_artifacts( project: &ProjectLayout, runner: &dyn ProcessRunner, -) -> Result> { - let artifacts = artifact::read_enabled(&project.packages_dir())?.deltas; - if artifacts.is_empty() { - return Ok(artifacts); + transition: Option<&script::BuildArtifactTransition>, +) -> Result { + let retiring = transition.map(script::BuildArtifactTransition::retiring); + let enabled = + artifact::read_enabled_except(&project.packages_dir(), retiring.as_deref().unwrap_or(&[]))?; + if enabled.deltas.is_empty() && enabled.scripts.is_empty() { + return Ok(enabled); } println!(); println!( - "checking {} generated Package Delta artifact(s)", - artifacts.len() + "checking {} generated Package Delta artifact(s) and {} Script Artifact(s)", + enabled.deltas.len(), + enabled.scripts.len() ); let mut command = CommandSpec::new("cargo") .arg("run") @@ -254,17 +270,51 @@ fn validate_generated_deltas( .arg("lyracore-delta-check") .cwd(project.root.clone()) .arg("--"); - for artifact in &artifacts { + for artifact in &enabled.deltas { + command = command.arg(artifact.path.to_string_lossy().to_string()); + } + for artifact in &enabled.scripts { command = command.arg(artifact.path.to_string_lossy().to_string()); } runner.run_streaming(&command).map_err(|e| { Error::Process(format!( - "the generated Package Delta artifacts do not check out. The validator's own report is \ - above, naming the file and the exact claim to fix.\n ({e})" + "the generated Package artifacts do not check out. The validator's own report is \ + above, naming the file and the exact claim or Runtime Script to fix.\n ({e})" )) })?; - Ok(artifacts) + Ok(enabled) +} + +/// Run the authoritative core parser and tracer over prebuilt Script Artifacts. +/// +/// This compiles only the pure Package Delta checker crate. It does not run Bun or build the +/// Module wasm. +pub(crate) fn validate_script_artifacts( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + paths: &[PathBuf], +) -> Result<()> { + if paths.is_empty() { + return Ok(()); + } + let mut command = CommandSpec::new("cargo") + .arg("run") + .arg("-p") + .arg("lyracore-package-delta") + .arg("--bin") + .arg("lyracore-delta-check") + .cwd(project.root.clone()) + .arg("--"); + for path in paths { + command = command.arg(path.to_string_lossy().to_string()); + } + runner.run_streaming(&command).map_err(|e| { + Error::Process(format!( + "the Script Artifacts do not check out. The authoritative validator's report is \ + above, naming the malformed contract or conflicting Package.\n ({e})" + )) + }) } pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { @@ -283,6 +333,7 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { ))); } + script::refuse_stale_transition(project)?; verify_bun(runner)?; println!( @@ -325,25 +376,58 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { println!("Datascripts typecheck against the current Module schema."); let datascript_packages = packages_with_datascripts(project)?; - if datascript_packages.is_empty() { + let script_packages = script::packages_with_scripts(project)?; + for path in script::remove_artifacts_without_sources(project)? { + println!( + "removed source-built Script Artifact with no Runtime Script sources: {}", + path.display() + ); + } + if datascript_packages.is_empty() && script_packages.is_empty() { return Ok(()); } - verify_base_snapshot(project, &datascript_packages)?; + if !datascript_packages.is_empty() { + verify_base_snapshot(project, &datascript_packages)?; - println!(); - println!( - "running Datascripts for {} enabled Package(s): {}", - datascript_packages.len(), - datascript_packages.join(", ") - ); - run_datascripts(project, runner, &datascript_packages)?; + println!(); + println!( + "running Datascripts for {} enabled Package(s): {}", + datascript_packages.len(), + datascript_packages.join(", ") + ); + run_datascripts(project, runner, &datascript_packages)?; + } - let artifacts = validate_generated_deltas(project, runner)?; - identity::write_all(project, &artifacts)?; + let transition = script::BuildArtifactTransition::capture(project, &script_packages)?; + if !script_packages.is_empty() { + println!(); + println!( + "compiling Runtime Scripts for {} enabled Package(s): {}", + script_packages.len(), + script_packages.join(", ") + ); + if let Err(error) = script::run_builds(project, runner, &script_packages) { + transition.rollback()?; + return Err(error); + } + } + + let enabled = match validate_generated_artifacts(project, runner, Some(&transition)) { + Ok(enabled) => enabled, + Err(error) => { + transition.rollback()?; + return Err(error); + } + }; + if let Err(error) = identity::write_all(project, &enabled.deltas) { + transition.rollback()?; + return Err(error); + } + transition.certify(project, &enabled.script_paths())?; println!(); - println!("Package Deltas emitted and validated."); + println!("Package artifacts emitted and validated."); Ok(()) } @@ -743,6 +827,148 @@ mod tests { ); } + // ---- Runtime Scripts ---- + + /// An enabled Package carrying one Runtime Script source, and the Script Artifact its build + /// writes. `FakeStack` records the `bun run` but does not execute Bun, so the artifact is put + /// on disk directly — the same way the Datascript tests supply theirs. + fn with_runtime_script(project: &ProjectLayout, package: &str) { + with_package(project, package); + let scripts = project.package_scripts_dir(package); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write( + scripts.join("greet.ts"), + "// @event on_login\n// @id 100200\n", + ) + .unwrap(); + std::fs::create_dir_all(project.runtime_scripts_dir()).unwrap(); + std::fs::write(project.script_builder_file(), "// the builder\n").unwrap(); + + let dir = project.packages_dir().join(package).join("data/.generated"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(format!("{package}.script.json")), + concat!( + r#"{"kind":"script","version":1,"package":"fire_nova","#, + r#""source_hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","#, + r#""scripts":[]}"#, + ), + ) + .unwrap(); + } + + #[test] + fn a_package_shipping_only_runtime_scripts_still_compiles_and_validates_them() { + // No Datascript anywhere, so no Base Snapshot and no `datascripts/src/` folder: the + // Runtime Script half must not be gated on the Datascript half. + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_runtime_script(&project, "fire_nova"); + let stack = FakeStack::new().with_stdout("--print-events", "on_login\n"); + + run(&project, &stack.runner()).unwrap(); + + let calls = stack.rendered(); + assert_eq!(calls.len(), 7, "{calls:?}"); + assert!(calls[4].contains("--print-events"), "{calls:?}"); + assert!(calls[5].contains("build-scripts.ts"), "{calls:?}"); + assert!(calls[5].ends_with("fire_nova"), "{calls:?}"); + assert!(calls[6].contains("lyracore-delta-check"), "{calls:?}"); + assert!( + calls[6].contains("fire_nova/data/.generated/fire_nova.script.json"), + "{calls:?}" + ); + } + + #[test] + fn a_validated_script_artifact_gets_its_own_build_identity_sidecar() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_runtime_script(&project, "fire_nova"); + let stack = FakeStack::new().with_stdout("--print-events", "on_login\n"); + + run(&project, &stack.runner()).unwrap(); + + let sidecar = project + .packages_dir() + .join("fire_nova/data/.generated") + .join(identity::SCRIPT_IDENTITY_FILE); + assert!(sidecar.is_file(), "no Script Artifact sidecar was written"); + } + + #[test] + fn removing_the_last_script_source_retires_the_artifact_and_check_stays_clean() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_runtime_script(&project, "fire_nova"); + let stack = FakeStack::new().with_stdout("--print-events", "on_login\n"); + + run(&project, &stack.runner()).unwrap(); + let generated = project.packages_dir().join("fire_nova/data/.generated"); + assert!(generated.join("fire_nova.script.json").is_file()); + assert!(generated.join(identity::SCRIPT_IDENTITY_FILE).is_file()); + + std::fs::remove_dir_all(project.package_scripts_dir("fire_nova")).unwrap(); + run(&project, &stack.runner()).unwrap(); + + assert!(!generated.join("fire_nova.script.json").exists()); + assert!(!generated.join(identity::SCRIPT_IDENTITY_FILE).exists()); + super::super::check::run(&project, &stack.runner()).unwrap(); + let enabled = artifact::read_enabled(&project.packages_dir()).unwrap(); + assert!( + enabled.scripts.is_empty(), + "a later replay must see no old Lua" + ); + } + + #[test] + fn a_source_free_prebuilt_script_artifact_survives_build_and_passes_check() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_package(&project, "playerbots"); + let generated = project.packages_dir().join("playerbots/data/.generated"); + std::fs::create_dir_all(&generated).unwrap(); + let artifact = generated.join("personality.json"); + std::fs::write( + &artifact, + concat!( + r#"{"kind":"script","version":1,"package":"playerbots","#, + r#""source_hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","#, + r#""scripts":[]}"#, + ), + ) + .unwrap(); + let stack = FakeStack::new(); + + run(&project, &stack.runner()).unwrap(); + super::super::check::run(&project, &stack.runner()).unwrap(); + + assert!(artifact.is_file()); + assert!(!generated.join(identity::SCRIPT_IDENTITY_FILE).exists()); + } + + #[test] + fn a_refused_runtime_script_stops_the_build_and_writes_no_sidecar() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_runtime_script(&project, "fire_nova"); + let stack = FakeStack::new() + .with_stdout("--print-events", "on_login\n") + .fail_on("build-scripts.ts", "unknown @event"); + + let error = run(&project, &stack.runner()).unwrap_err(); + + assert!(error.to_string().contains("fire_nova"), "{error}"); + assert!( + !project + .packages_dir() + .join("fire_nova/data/.generated") + .join(identity::SCRIPT_IDENTITY_FILE) + .exists(), + "a refused build must write no sidecar" + ); + } + #[test] fn a_build_with_no_datascript_anywhere_stays_exactly_as_it_was() { // Packages without Datascripts must keep building — the emission and validation stages do diff --git a/src/cmd/packages/check.rs b/src/cmd/packages/check.rs index 28bb7ec..baaadee 100644 --- a/src/cmd/packages/check.rs +++ b/src/cmd/packages/check.rs @@ -1,29 +1,36 @@ -//! `lyracore packages check` — is every enabled Package's generated Package Delta still current? +//! `lyracore packages check` — is every enabled Package's generated artifact still current? //! //! `packages build` records a Build Identity next to each artifact it emits (see [`super:: //! identity`]). This verb recomputes every recorded input from the checkout ON DISK right now and //! refuses, naming the specific input, the moment one no longer matches — the same report `preflight` //! folds into its own gate on `publish`'s behalf, so a stale artifact never reaches a Shard. //! +//! Both artifact kinds are checked, each against its own sidecar: a Package Delta against the +//! Datascript inputs, a Script Artifact against its `scripts/` sources and the Runtime Script +//! toolchain. +//! //! # What "current" means here //! //! `datascripts/generated/` is regenerated FRESH, every run, with the same `spacetime generate` //! invocation `packages build`'s typegen step uses — so a Module schema change makes a committed -//! artifact stale even on a clean checkout that never ran `packages build` itself. Nothing else is -//! regenerated: this verb never runs Bun and never re-emits a Datascript, so it needs neither Bun nor -//! a Base Snapshot to do its job. +//! artifact stale even on a clean checkout that never ran `packages build` itself. It is an input of +//! a Package Delta alone, so a checkout shipping only Runtime Scripts skips that step. Nothing else +//! is regenerated: this verb never runs Bun and never re-emits anything, so it needs neither Bun nor +//! a Base Snapshot to do its job. Every Script Artifact still passes through the authoritative +//! Rust parser and tracer, including source-free prebuilt artifacts that have no sidecar. //! //! A missing Base Snapshot is reported as UNVERIFIABLE, not stale: the snapshot is the Operator's own //! client-derived data, and a CI machine holding none cannot regenerate one to compare against. A //! Base Snapshot that IS present and no longer matches its recorded hash is a real mismatch and fails //! like any other input. //! -//! A missing sidecar is treated as stale — it predates identity tracking, so there is nothing to -//! compare against and no way to call the artifact current. +//! A source-built artifact without a sidecar is stale. A source-free artifact without one is +//! prebuilt Lua: there is no source tree in this checkout to compare, and an Operator can install +//! and replay it without Bun. use crate::cmd::packages::artifact::{self, Artifact}; use crate::cmd::packages::build; -use crate::cmd::packages::identity; +use crate::cmd::packages::{identity, script}; use crate::proc::ProcessRunner; use crate::project::ProjectLayout; use crate::{Error, Result}; @@ -31,16 +38,25 @@ use crate::{Error, Result}; /// One artifact's problems, and whether its Base Snapshot comparison had to be skipped. fn check_one(project: &ProjectLayout, artifact: &Artifact) -> Result<(Vec, bool)> { let sidecar_path = artifact.path.with_file_name(identity::IDENTITY_FILE); - let Ok(text) = std::fs::read_to_string(&sidecar_path) else { - return Ok(( - vec![format!( - "`{}`: no Build Identity sidecar next to {} (predates identity tracking, or was \ - removed). Rebuild with `lyracore packages build`.", - artifact.package, - artifact.path.display() - )], - false, - )); + let text = match std::fs::read_to_string(&sidecar_path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(( + vec![format!( + "`{}`: no Build Identity sidecar next to {} (predates identity tracking, or \ + was removed). Rebuild with `lyracore packages build`.", + artifact.package, + artifact.path.display() + )], + false, + )) + } + Err(error) => { + return Err(Error::Process(format!( + "cannot read Package Delta Build Identity {}: {error}", + sidecar_path.display() + ))) + } }; let recorded = identity::Identity::parse(&text, &sidecar_path)?; let dir = identity::package_dir(project, &artifact.path)?; @@ -73,45 +89,45 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { return Ok(()); } let enabled = artifact::read_enabled(&project.packages_dir())?; - // A Script Artifact has no Build Identity sidecar and no Base Snapshot to drift against, so - // this verb has nothing to check about one. `packages replay` is what applies them. - match enabled.scripts.len() { - 0 => {} - 1 => println!( - "1 Script Artifact is not a Package Delta; `lyracore packages replay` applies it" - ), - n => println!( - "{n} Script Artifacts are not Package Deltas; `lyracore packages replay` applies them" - ), - } - let artifacts = enabled.deltas; - if artifacts.is_empty() { - println!("no Package claims a Datascript-generated artifact; nothing to check."); + let checked = enabled.deltas.len() + enabled.scripts.len(); + if checked == 0 { + println!("no Package claims a generated artifact; nothing to check."); return Ok(()); } - println!( - "regenerating Module schema typings -> {} (so a schema change is visible even on a clean \ - checkout)", - project.datascript_types_dir().display() - ); - runner - .run_streaming(&build::typegen_command(project)) - .map_err(|e| { - Error::Process(format!( - "could not extract the Module schema as TypeScript, so `packages check` cannot \ - tell whether the generated typings are current. Nothing was checked.\n ({e})" - )) - })?; - let mut problems = Vec::new(); let mut snapshot_unverifiable = false; - for artifact in &artifacts { - let (found, unverifiable) = check_one(project, artifact)?; - problems.extend(found); - snapshot_unverifiable |= unverifiable; + + // The shared core parser owns Script Artifact structure, Event Binding contracts, and the + // one-artifact-per-Package rule. This Rust-only check needs neither Bun nor the Module wasm. + build::validate_script_artifacts(project, runner, &enabled.script_paths())?; + + // The typings are an input of a Package Delta only, so a checkout that ships Runtime Scripts + // and no Datascript is checked without building the module wasm at all. + if !enabled.deltas.is_empty() { + println!( + "regenerating Module schema typings -> {} (so a schema change is visible even on a \ + clean checkout)", + project.datascript_types_dir().display() + ); + runner + .run_streaming(&build::typegen_command(project)) + .map_err(|e| { + Error::Process(format!( + "could not extract the Module schema as TypeScript, so `packages check` cannot \ + tell whether the generated typings are current. Nothing was checked.\n ({e})" + )) + })?; + + for artifact in &enabled.deltas { + let (found, unverifiable) = check_one(project, artifact)?; + problems.extend(found); + snapshot_unverifiable |= unverifiable; + } } + problems.extend(script::stale(project, &enabled.script_paths())?); + if snapshot_unverifiable { println!( "no Base Snapshot at {} — snapshot drift cannot be checked here (it is the Operator's \ @@ -121,14 +137,13 @@ pub fn run(project: &ProjectLayout, runner: &dyn ProcessRunner) -> Result<()> { } if problems.is_empty() { - println!("{} Package Delta artifact(s) are current.", artifacts.len()); + println!("{checked} generated Package artifact(s) are current."); return Ok(()); } Err(Error::Process(format!( - "{} of {} Package Delta artifact(s) are stale:\n{}", + "{} of {checked} generated Package artifact(s) are stale:\n{}", problems.len(), - artifacts.len(), problems .iter() .map(|p| format!(" {p}")) @@ -197,6 +212,49 @@ mod tests { project } + /// A Runtime Script source, its Script Artifact and the sidecar `packages build` would leave + /// behind, beside the Package Delta `checked_out` already wrote. + fn with_built_script_artifact(project: &ProjectLayout) -> std::path::PathBuf { + let scripts = project.package_scripts_dir("fire_nova"); + std::fs::create_dir_all(&scripts).unwrap(); + std::fs::write( + scripts.join("greet.ts"), + "// @event on_login\n// @id 100001\n", + ) + .unwrap(); + std::fs::create_dir_all(project.runtime_scripts_dir()).unwrap(); + std::fs::write(project.script_builder_file(), "// the builder\n").unwrap(); + + let path = project + .packages_dir() + .join("fire_nova/data/.generated/script.json"); + std::fs::write( + &path, + concat!( + r#"{"kind":"script","version":1,"package":"fire_nova","#, + r#""source_hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","#, + r#""scripts":[{"script_id":100001,"name":"fire_nova.greet","event":"on_login","#, + r#""priority":0,"enabled":true,"source":"grant_xp(event.actor, 10)"}]}"#, + ), + ) + .unwrap(); + identity::write_script_identities(project, std::slice::from_ref(&path)).unwrap(); + path + } + + fn with_prebuilt_script_artifact( + project: &ProjectLayout, + package: &str, + name: &str, + body: &str, + ) -> std::path::PathBuf { + let generated = project.packages_dir().join(package).join("data/.generated"); + std::fs::create_dir_all(&generated).unwrap(); + let path = generated.join(name); + std::fs::write(&path, body).unwrap(); + path + } + // ---- the clean pass ---- #[test] @@ -213,27 +271,143 @@ mod tests { assert!(calls[0].contains("--lang typescript"), "{calls:?}"); } - /// The seam this verb sits on globs every `*.json` beside the Delta, so a Package that also - /// ships Runtime Scripts must not make `packages check` refuse the Package Deltas it can check. + /// A Package may ship both kinds into one generated directory. Each is checked against its own + /// Build Identity, and a current pair passes. #[test] - fn a_script_artifact_beside_the_delta_is_skipped_rather_than_failing_the_check() { + fn a_script_artifact_beside_the_delta_is_checked_on_its_own_terms() { let tmp = TempDir::new().unwrap(); let project = checked_out(&tmp); - std::fs::write( - project - .packages_dir() - .join("fire_nova/data/.generated/script.json"), - concat!( - r#"{"kind":"script","version":1,"package":"fire_nova","#, - r#""source_hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","#, - r#""scripts":[{"script_id":100001,"name":"fire_nova.greet","event":"on_login","#, - r#""priority":0,"enabled":true,"source":"grant_xp(event.actor, 10)"}]}"#, + with_built_script_artifact(&project); + let stack = FakeStack::new(); + + run(&project, &stack.runner()).unwrap(); + } + + #[test] + fn a_source_free_prebuilt_artifact_uses_only_the_authoritative_rust_checker() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let project = ProjectLayout::from_root(tmp.path()).unwrap(); + with_prebuilt_script_artifact( + &project, + "playerbots", + "personality.json", + &format!( + r#"{{"kind":"script","version":1,"package":"playerbots","source_hash":"{HASH_A}","scripts":[]}}"# ), + ); + let stack = FakeStack::new(); + + run(&project, &stack.runner()).unwrap(); + + let calls = stack.rendered(); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert!(calls[0].contains("lyracore-delta-check"), "{calls:?}"); + assert!(!calls[0].contains("spacetime generate"), "{calls:?}"); + assert!(!calls[0].contains("bun"), "{calls:?}"); + } + + /// Structure is the CLI's own refusal: the enabled Package Inventory is read before anything + /// is run, so a file no Shard could apply never reaches the authoritative checker. + #[test] + fn a_malformed_prebuilt_artifact_is_refused_before_the_authoritative_checker_runs() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let project = ProjectLayout::from_root(tmp.path()).unwrap(); + let path = with_prebuilt_script_artifact( + &project, + "playerbots", + "personality.json", + r#"{"kind":"script","version":99}"#, + ); + let stack = FakeStack::new(); + + let error = run(&project, &stack.runner()).unwrap_err(); + + assert!( + error.to_string().contains(path.to_string_lossy().as_ref()), + "{error}" + ); + assert!(error.to_string().contains("version 99"), "{error}"); + assert!(stack.rendered().is_empty(), "{:?}", stack.rendered()); + } + + /// Two artifacts naming one Package is the plan the module refuses; the CLI names both files + /// rather than letting the pair reach a Shard. + #[test] + fn duplicate_prebuilt_artifacts_are_refused_before_the_authoritative_check() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let project = ProjectLayout::from_root(tmp.path()).unwrap(); + let first = with_prebuilt_script_artifact( + &project, + "playerbots", + "one.json", + &format!( + r#"{{"kind":"script","version":1,"package":"playerbots","source_hash":"{HASH_A}","scripts":[]}}"# + ), + ); + let second = with_prebuilt_script_artifact( + &project, + "playerbots", + "two.json", + &format!( + r#"{{"kind":"script","version":1,"package":"playerbots","source_hash":"{HASH_A}","scripts":[]}}"# + ), + ); + let stack = FakeStack::new(); + + let error = run(&project, &stack.runner()).unwrap_err(); + + assert!(error.to_string().contains("appears twice"), "{error}"); + assert!( + error.to_string().contains(first.to_string_lossy().as_ref()), + "{error}" + ); + assert!( + error + .to_string() + .contains(second.to_string_lossy().as_ref()), + "{error}" + ); + assert!(stack.rendered().is_empty(), "{:?}", stack.rendered()); + } + + #[test] + fn a_script_artifact_without_a_sidecar_is_stale() { + let tmp = TempDir::new().unwrap(); + let project = checked_out(&tmp); + let path = with_built_script_artifact(&project); + std::fs::remove_file(path.with_file_name(identity::SCRIPT_IDENTITY_FILE)).unwrap(); + let stack = FakeStack::new(); + + let error = run(&project, &stack.runner()).unwrap_err(); + + assert!(error.to_string().contains("script.json"), "{error}"); + assert!( + error.to_string().contains("no Build Identity sidecar"), + "{error}" + ); + } + + #[test] + fn an_edited_runtime_script_source_makes_its_script_artifact_stale() { + let tmp = TempDir::new().unwrap(); + let project = checked_out(&tmp); + with_built_script_artifact(&project); + std::fs::write( + project.package_scripts_dir("fire_nova").join("greet.ts"), + "// @event on_death\n// @id 100001\n", ) .unwrap(); let stack = FakeStack::new(); - run(&project, &stack.runner()).unwrap(); + let error = run(&project, &stack.runner()).unwrap_err(); + + assert!( + error.to_string().contains("Runtime Script sources"), + "{error}" + ); } // ---- no-op checkouts ---- diff --git a/src/cmd/packages/identity.rs b/src/cmd/packages/identity.rs index 52ed235..609e08a 100644 --- a/src/cmd/packages/identity.rs +++ b/src/cmd/packages/identity.rs @@ -39,6 +39,7 @@ //! asking Bun to confirm its own version a second time would only be a slower way to read the same //! constant. +use std::io::Write as _; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; @@ -54,6 +55,10 @@ use crate::{Error, Result}; /// put this file inside `artifact::read_enabled`'s own artifact glob. pub const IDENTITY_FILE: &str = "spell.identity"; +/// The same sidecar, for the Script Artifact next to it. A Package may ship both kinds, and they +/// are built from different inputs, so each records its own. +pub const SCRIPT_IDENTITY_FILE: &str = "script.identity"; + /// The only sidecar envelope version this CLI reads or writes. const IDENTITY_VERSION: u64 = 1; @@ -70,6 +75,8 @@ pub enum Input { PackageJson, BunLock, Artifact, + ScriptSource, + Toolchain, } impl Input { @@ -89,6 +96,10 @@ impl Input { Input::Artifact => { "the artifact file itself (its bytes no longer match what was last built — hand-edited?)" } + Input::ScriptSource => "its Runtime Script sources under packages//scripts/", + Input::Toolchain => { + "the pinned Runtime Script toolchain in datascripts/runtime-scripts/" + } } } } @@ -170,23 +181,7 @@ impl Identity { /// Read a sidecar back. `path` is used only to name the file in a refusal. pub fn parse(text: &str, path: &Path) -> Result { let refuse = |what: String| Error::Usage(format!("{}: {what}", path.display())); - - let root: serde_json::Value = - serde_json::from_str(text).map_err(|e| refuse(format!("not valid JSON ({e})")))?; - let object = root - .as_object() - .ok_or_else(|| refuse("a Build Identity sidecar is a JSON object".to_string()))?; - - let version = object - .get("version") - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| refuse("no `version`".to_string()))?; - if version != IDENTITY_VERSION { - return Err(refuse(format!( - "sidecar version {version}; this CLI reads version {IDENTITY_VERSION}. Rebuild \ - the Package with `lyracore packages build`, or update this checkout." - ))); - } + let object = parse_sidecar(text, path)?; let field = |name: &str| -> Result { object @@ -386,6 +381,206 @@ pub fn write_all(project: &ProjectLayout, artifacts: &[super::artifact::Artifact Ok(()) } +// ---- the Script Artifact's own Build Identity ---- + +/// What a Script Artifact was built from. Four inputs, not the Delta's nine: a Runtime Script reads +/// no Base Snapshot, imports no authoring library, and never sees the Module schema typings. +/// +/// `artifact_hash` here is SHA-256 of the artifact file's raw bytes, not the Delta's BLAKE3 +/// canonical-form digest: this CLI reproduces the canonical form for Package Deltas only, and the +/// question a sidecar answers is "were these bytes hand-edited", which raw bytes answer exactly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScriptIdentity { + pub source_hash: String, + pub toolchain_hash: String, + pub bun_version: String, + pub bun_lock_hash: String, + pub artifact_hash: String, +} + +impl ScriptIdentity { + /// Every recorded input that differs between `self` and `current`, in the sidecar's field order. + pub fn changed_against(&self, current: &ScriptIdentity) -> Vec { + let mut changed = Vec::with_capacity(5); + let mut check = |same: bool, input: Input| { + if !same { + changed.push(input); + } + }; + check(self.source_hash == current.source_hash, Input::ScriptSource); + check( + self.toolchain_hash == current.toolchain_hash, + Input::Toolchain, + ); + check(self.bun_version == current.bun_version, Input::BunVersion); + check(self.bun_lock_hash == current.bun_lock_hash, Input::BunLock); + check(self.artifact_hash == current.artifact_hash, Input::Artifact); + changed + } + + /// Canonical bytes, in the same spelling [`Identity::render`] uses. + pub fn render(&self) -> String { + format!( + "{{\"artifact_hash\":{a},\"bun_lock_hash\":{bl},\"bun_version\":{bv},\ + \"source_hash\":{s},\"toolchain_hash\":{t},\"version\":{v}}}", + a = json_string(&self.artifact_hash), + bl = json_string(&self.bun_lock_hash), + bv = json_string(&self.bun_version), + s = json_string(&self.source_hash), + t = json_string(&self.toolchain_hash), + v = IDENTITY_VERSION, + ) + } + + /// Read a sidecar back. `path` is used only to name the file in a refusal. + pub fn parse(text: &str, path: &Path) -> Result { + let object = parse_sidecar(text, path)?; + let refuse = |what: String| Error::Usage(format!("{}: {what}", path.display())); + let field = |name: &str| -> Result { + object + .get(name) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| refuse(format!("no `{name}`"))) + }; + Ok(ScriptIdentity { + source_hash: field("source_hash")?, + toolchain_hash: field("toolchain_hash")?, + bun_version: field("bun_version")?, + bun_lock_hash: field("bun_lock_hash")?, + artifact_hash: field("artifact_hash")?, + }) + } +} + +/// SHA-256 over the FILES directly inside `root`, sorted by name, each length-prefixed. +/// +/// Shallow on purpose: `datascripts/runtime-scripts/` holds an installed `node_modules/` beside its +/// checked-in files, and the pin for what is in there is `bun.lock`, which is a recorded input of +/// its own. A new toolchain file dropped in that directory is picked up without editing this. +fn hash_dir_files(root: &Path) -> Result { + let mut names: Vec = Vec::new(); + if root.is_dir() { + for entry in std::fs::read_dir(root)? { + let path = entry?.path(); + if path.is_file() { + names.push(path); + } + } + } + names.sort(); + + let mut hasher = Sha256::new(); + for path in names { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let bytes = std::fs::read(&path)?; + hasher.update(name.as_bytes()); + hasher.update([0u8]); + hasher.update(bytes.len().to_string().as_bytes()); + hasher.update([0u8]); + hasher.update(&bytes); + } + Ok(format!("sha256-dir-v1:{:x}", hasher.finalize())) +} + +/// Compute the Build Identity of one Package's Script Artifact from the checkout on disk right now. +/// +/// `artifact_path` is the Script Artifact itself; the Package folder it sits in selects the +/// `scripts/` sources, exactly as the Delta side derives its Datascript folder. +pub fn compute_script(project: &ProjectLayout, artifact_path: &Path) -> Result { + let dir = package_dir(project, artifact_path)?; + let name = dir.file_name().unwrap_or_default().to_string_lossy(); + Ok(ScriptIdentity { + source_hash: hash_script_sources(&super::script::source_files(project, &name)?)?, + toolchain_hash: hash_dir_files(&project.runtime_scripts_dir())?, + bun_version: doctor::REQUIRED_BUN.to_string(), + bun_lock_hash: hash_file(&project.datascripts_dir().join("bun.lock"))?, + artifact_hash: hash_file(artifact_path)?, + }) +} + +/// SHA-256 over the exact Runtime Script source inventory, mirrored in `build-scripts.ts`. +fn hash_script_sources(files: &[PathBuf]) -> Result { + let mut hasher = Sha256::new(); + for path in files { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let bytes = std::fs::read(path)?; + hasher.update(name.as_bytes()); + hasher.update([0u8]); + hasher.update(bytes.len().to_string().as_bytes()); + hasher.update([0u8]); + hasher.update(&bytes); + } + Ok(format!("sha256-script-sources-v1:{:x}", hasher.finalize())) +} + +/// Write one Script Artifact Build Identity sidecar next to each of `artifacts`. Called only after +/// the artifacts have validated, for the same reason the Delta side is. +pub fn write_script_identities(project: &ProjectLayout, artifacts: &[PathBuf]) -> Result<()> { + for path in artifacts { + let dir = package_dir(project, path)?; + let name = dir.file_name().unwrap_or_default().to_string_lossy(); + if super::script::source_files(project, &name)?.is_empty() { + continue; + } + let sidecar = path + .parent() + .ok_or_else(|| { + Error::State(format!( + "artifact path {} has no parent directory", + path.display() + )) + })? + .join(SCRIPT_IDENTITY_FILE); + write_atomically(&sidecar, compute_script(project, path)?.render().as_bytes())?; + } + Ok(()) +} + +/// Replace a Build Identity only after its complete replacement is durable in a sibling temporary +/// file. A failed write or rename leaves the prior sidecar untouched. +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + Error::State(format!( + "Build Identity {} has no parent directory", + path.display() + )) + })?; + std::fs::create_dir_all(parent)?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(bytes)?; + temporary.as_file().sync_all()?; + temporary.persist(path).map_err(|error| { + Error::Process(format!( + "cannot replace Build Identity {} atomically: {}", + path.display(), + error.error + )) + })?; + Ok(()) +} + +/// The JSON object of a sidecar of either kind, with its envelope version already checked. +fn parse_sidecar(text: &str, path: &Path) -> Result> { + let refuse = |what: String| Error::Usage(format!("{}: {what}", path.display())); + let root: serde_json::Value = + serde_json::from_str(text).map_err(|e| refuse(format!("not valid JSON ({e})")))?; + let object = root + .as_object() + .ok_or_else(|| refuse("a Build Identity sidecar is a JSON object".to_string()))?; + let version = object + .get("version") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| refuse("no `version`".to_string()))?; + if version != IDENTITY_VERSION { + return Err(refuse(format!( + "sidecar version {version}; this CLI reads version {IDENTITY_VERSION}. Rebuild \ + the Package with `lyracore packages build`, or update this checkout." + ))); + } + Ok(object.clone()) +} + #[cfg(test)] mod tests { use super::*; @@ -762,4 +957,38 @@ mod tests { assert!(error.to_string().contains("unreachable"), "{error}"); assert!(!generated_dir.join(IDENTITY_FILE).exists()); } + + #[test] + fn script_source_hash_matches_the_toolchains_shallow_inventory() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let scripts = project.package_scripts_dir("fire_nova"); + std::fs::create_dir_all(scripts.join("nested")).unwrap(); + std::fs::write( + scripts.join("alpha.ts"), + "// @event on_login\n// @id 100201\nfunction script(): void {}\n", + ) + .unwrap(); + std::fs::write( + scripts.join("zeta.lua"), + "-- @event on_login\n-- @id 100202\nreturn 2\n", + ) + .unwrap(); + std::fs::write(scripts.join("README.md"), "ignored\n").unwrap(); + std::fs::write(scripts.join("nested/hidden.ts"), "ignored\n").unwrap(); + std::fs::create_dir_all(project.runtime_scripts_dir()).unwrap(); + std::fs::write(project.script_builder_file(), "// builder\n").unwrap(); + let artifact = project + .packages_dir() + .join("fire_nova/data/.generated/fire_nova.script.json"); + std::fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + std::fs::write(&artifact, "{}\n").unwrap(); + + let identity = compute_script(&project, &artifact).unwrap(); + + assert_eq!( + identity.source_hash, + "sha256-script-sources-v1:8395ead00aad341a7daa23658447385da94dabf6932b406cbfbdb5e2fd664002" + ); + } } diff --git a/src/cmd/packages/review.rs b/src/cmd/packages/review.rs index 1d1275b..e40f247 100644 --- a/src/cmd/packages/review.rs +++ b/src/cmd/packages/review.rs @@ -14,9 +14,11 @@ //! Everything else in the Rust is code that runs inside the module with full database access, and //! the report says so. //! -//! Datascripts and Runtime Scripts have no tooling in this checkout yet. They are reported as an -//! explicit "none detected" row rather than omitted, so the review's silence about them is a -//! deliberate statement and not an oversight the reader has to notice. +//! Runtime Scripts ARE counted: a Package ships its `scripts/` sources inside its own folder, and +//! that Lua runs on the realm once the Package is built. Datascripts are not — they live in +//! `datascripts/`, outside any Package folder, so a candidate cannot carry one. That row still +//! prints, as an explicit "none detected", so the review's silence about them is a deliberate +//! statement and not an oversight the reader has to notice. //! //! DUPLICATION, ON PURPOSE: `strip_comments_and_strings` and the three marker matchers are a port //! of `module/build.rs`, which lives in the server repository and is not a dependency of this CLI. @@ -66,6 +68,8 @@ pub struct TrustReview { pub addons: Vec, /// Files under `client/mpq/` — each one shadows a stock client file. pub client_overrides: usize, + /// Runtime Script sources under `scripts/` — Lua this Package would run on the realm. + pub runtime_scripts: Vec, pub rust_files: usize, pub rust_lines: usize, } @@ -119,6 +123,27 @@ impl TrustReview { }) .count(); + review.runtime_scripts = entries + .iter() + .filter(|entry| { + entry.kind == EntryKind::File + && entry.relative.parent() == Some(Path::new("scripts")) + && entry + .relative + .extension() + .is_some_and(|ext| ext == "ts" || ext == "lua") + }) + .map(|entry| { + entry + .relative + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + }) + .collect(); + review.runtime_scripts.sort(); + Ok(review) } @@ -172,6 +197,7 @@ impl TrustReview { && self.character_owned.is_empty() && self.addons.is_empty() && self.client_overrides == 0 + && self.runtime_scripts.is_empty() } /// The full block `packages add` prints before it asks. @@ -203,9 +229,11 @@ impl TrustReview { out.push_str( " datascripts none detected (nothing in this checkout reads them yet)\n", ); - out.push_str( - " runtime scripts none detected (nothing in this checkout reads them yet)\n", - ); + out.push_str(&row( + "runtime scripts", + self.runtime_scripts.len(), + &self.runtime_scripts, + )); out.push_str(&format!( " trusted Rust {} file(s), {} line(s)\n", self.rust_files, self.rust_lines @@ -230,6 +258,7 @@ impl TrustReview { ("character-owned", self.character_owned.len()), ("addons", self.addons.len()), ("client overrides", self.client_overrides), + ("runtime scripts", self.runtime_scripts.len()), ] { if count > 0 { parts.push(format!("{count} {label}")); @@ -688,13 +717,35 @@ mod tests { assert!(text.contains("TRUSTED"), "{text}"); assert!(text.contains("not a security guarantee"), "{text}"); - // The two kinds with no tooling are stated, not omitted. + // Datascripts, the one kind a candidate cannot carry, are stated rather than omitted. assert!(text.contains("datascripts"), "{text}"); - assert!(text.contains("runtime scripts"), "{text}"); assert!(text.contains("none detected"), "{text}"); + assert!(text.contains("runtime scripts"), "{text}"); assert!(review.registers_nothing(), "{review:?}"); } + /// Runtime Scripts run ON THE REALM once the Package is built, so a candidate carrying them + /// must never be reviewed as if it carried none. + #[test] + fn runtime_script_sources_are_named_in_the_review() { + let tmp = package(&[ + ("scripts/ember_echo.ts", "// @event on_login\n"), + ("scripts/bonus.lua", "-- @event on_login\n"), + ("scripts/README.md", "not a script\n"), + ]); + + let review = TrustReview::scan(tmp.path()).unwrap(); + + assert_eq!(review.runtime_scripts, ["bonus.lua", "ember_echo.ts"]); + assert!(!review.registers_nothing(), "{review:?}"); + let text = review.render(tmp.path()); + assert!(text.contains("ember_echo.ts"), "{text}"); + assert!( + review.kinds_summary().contains("2 runtime scripts"), + "{review:?}" + ); + } + #[test] fn the_scan_is_deterministic_over_the_same_tree() { let tmp = package(&[ diff --git a/src/cmd/packages/script.rs b/src/cmd/packages/script.rs index a64899b..c72b4f3 100644 --- a/src/cmd/packages/script.rs +++ b/src/cmd/packages/script.rs @@ -1,5 +1,7 @@ //! The enabled Packages' Script Artifacts: the Runtime Scripts they ship, the digest a Shard //! records for each one, and the payload `apply_package_deltas` reads for the script Import Family. +//! The same module builds them: `packages build` compiles a Package's `scripts/` sources into the +//! artifact this parser then reads back. //! //! A Runtime Script has no base import and no other owner, so two Packages meeting on one //! `script_id` or name is always a collision, never the row merge [`super::artifact`] does. @@ -11,10 +13,26 @@ //! This parser refuses only what would corrupt the bytes or the plan — unknown version, an //! undeclared member, an in-Package id/name clash — and leaves the event catalogue, identifier //! bands, and name rules to the Module, which refuses an over-permissive plan on the first Shard. +//! +//! The build half is the Datascript step's sibling and deliberately shaped like it: one `bun run` +//! subprocess per Package, streamed so the toolchain's own diagnostics reach the author, fail-fast, +//! and the artifact validated afterwards by the same `lyracore-delta-check` that traces Package +//! Deltas. Two things differ, both because a Runtime Script is Package-authored rather than +//! schema-derived: +//! +//! * The sources live INSIDE the Package (`packages//scripts/`), not under `datascripts/src/`. +//! A Datascript sits outside a Package folder because only artifacts belong in one; a Runtime +//! Script is a Package's own content, and the Official Package Collection ships both halves. +//! * The builder is handed the hook Event Binding catalogue, read from `lyracore-delta-check +//! --print-events`, so a `@event` typo fails while the author can still see which file it is in. +//! The catalogue is never copied into the toolchain: the Module owns it, and a copy would drift. use std::path::{Path, PathBuf}; use super::artifact::write_string; +use crate::cmd::packages::identity; +use crate::proc::{CommandSpec, ProcessRunner}; +use crate::project::ProjectLayout; use crate::{Error, Result}; /// The Import Family a Script Artifact belongs to — the name `apply_package_deltas` takes and @@ -318,9 +336,581 @@ pub fn pack(artifacts: &[ScriptArtifact]) -> String { .join("\n") } +// ---- building a Script Artifact ---- + +/// The environment variable the builder reads its Event Binding catalogue from, one event per line. +const HOOK_EVENTS_ENV: &str = "LYRACORE_HOOK_EVENTS"; + +/// A hard-linked old artifact or sidecar. The unique tail has no `.json` suffix, so artifact +/// discovery cannot mistake it for a candidate. A leftover name means a process stopped outside +/// the in-memory rollback boundary; a later build refuses it without guessing what to restore. +const TRANSITION_BACKUP_PREFIX: &str = ".lyracore-script-build-backup-"; +const LEGACY_TRANSITION_BACKUP_SUFFIX: &str = ".lyracore-script-build-backup"; + +/// The Runtime Script sources the builder reads, in file-name order. +/// +/// The inventory is deliberately shallow and contains only regular `.ts` and `.lua` files. This +/// is the same definition as the Runtime Script Toolchain's `scriptSources`: a nested file, a +/// symlink, or a README cannot affect emitted Lua and therefore cannot affect its Build Identity. +pub fn source_files(project: &ProjectLayout, package: &str) -> Result> { + let scripts_dir = project.package_scripts_dir(package); + if !scripts_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut sources = Vec::new(); + for entry in std::fs::read_dir(scripts_dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension == "ts" || extension == "lua") + { + sources.push(path); + } + } + sources.sort(); + Ok(sources) +} + +/// Enabled Packages carrying at least one Runtime Script source, in folder-name order. The same +/// listing already means "enabled", so a disabled Package's scripts are not there to find. +pub fn packages_with_scripts(project: &ProjectLayout) -> Result> { + let packages_dir = project.packages_dir(); + if !packages_dir.is_dir() { + return Ok(Vec::new()); + } + let mut names = Vec::new(); + for entry in std::fs::read_dir(&packages_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if !source_files(project, &name)?.is_empty() { + names.push(name); + } + } + names.sort(); + Ok(names) +} + +/// Remove Script Artifacts previously emitted by this build after their last source disappears. +/// +/// A `script.identity` sidecar marks the source-built mode. A source-free Script Artifact without +/// that sidecar is prebuilt Lua and remains untouched, so an Operator can install and replay it +/// without Bun. Artifact files are removed before the sidecar. If removal fails, the remaining +/// sidecar keeps any surviving artifact from being mistaken for prebuilt Lua by `packages check`. +pub fn remove_artifacts_without_sources(project: &ProjectLayout) -> Result> { + let packages_dir = project.packages_dir(); + if !packages_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut removed = Vec::new(); + for entry in std::fs::read_dir(packages_dir)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let Ok(package) = entry.file_name().into_string() else { + continue; + }; + if !source_files(project, &package)?.is_empty() { + continue; + } + + let generated = entry.path().join("data/.generated"); + let sidecar = generated.join(identity::SCRIPT_IDENTITY_FILE); + if !sidecar.is_file() { + continue; + } + + for path in script_artifact_files(&generated)? { + std::fs::remove_file(&path)?; + removed.push(path); + } + std::fs::remove_file(sidecar)?; + } + Ok(removed) +} + +/// Every Script Artifact file in one Package's generated directory, in file-name order. +/// +/// A path walk rather than a plan read: `packages build` handles artifacts a Shard would refuse, +/// including the prebuilt file it is about to replace. +fn script_artifact_files(generated: &Path) -> Result> { + if !generated.is_dir() { + return Ok(Vec::new()); + } + let mut files: Vec = std::fs::read_dir(generated)? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "json")) + .collect(); + files.sort(); + let mut found = Vec::new(); + for path in files { + if is_script_artifact(&std::fs::read_to_string(&path)?) { + found.push(path); + } + } + Ok(found) +} + +/// Print the Module's Event Binding catalogue. Captured, not streamed: the answer is the output. +pub fn hook_events_command(project: &ProjectLayout) -> CommandSpec { + CommandSpec::new("cargo") + .arg("run") + .arg("-p") + .arg("lyracore-package-delta") + .arg("--bin") + .arg("lyracore-delta-check") + .arg("--") + .arg("--print-events") + .cwd(project.root.clone()) +} + +/// One Package's Runtime Script build, as a subprocess. +pub fn build_command(project: &ProjectLayout, package: &str, events: &str) -> CommandSpec { + CommandSpec::new("bun") + .arg("run") + .arg(project.script_builder_file().to_string_lossy().to_string()) + .arg(package.to_string()) + .env( + "LYRACORE_PACKAGES_ROOT", + project.packages_dir().to_string_lossy().to_string(), + ) + .env(HOOK_EVENTS_ENV, events.to_string()) + .cwd(project.root.clone()) +} + +/// Read the Event Binding catalogue once, then compile each Package's scripts in folder-name order. +/// The first Package to fail stops the build; later Packages never run. +pub fn run_builds( + project: &ProjectLayout, + runner: &dyn ProcessRunner, + packages: &[String], +) -> Result<()> { + let events = runner + .run_and_wait(&hook_events_command(project)) + .map_err(|e| { + Error::Process(format!( + "could not read the Event Binding catalogue from `lyracore-delta-check \ + --print-events`, so a script's `@event` could not be checked against the Module. \ + Nothing was compiled.\n ({e})" + )) + })?; + + for package in packages { + println!( + "compiling Runtime Scripts in {} ({package})", + project.package_scripts_dir(package).display() + ); + runner + .run_streaming(&build_command(project, package, &events)) + .map_err(|e| { + Error::Process(format!( + "the Runtime Scripts of `{package}` did not compile into a Script Artifact. \ + The toolchain's own diagnostic is above, naming the file and the directive or \ + line to fix. A refused build writes nothing, so the Package's artifact is \ + exactly what it was before this run.\n ({e})" + )) + })?; + } + Ok(()) +} + +#[derive(Debug)] +struct PriorArtifact { + path: PathBuf, + backup: PathBuf, +} + +#[derive(Debug)] +struct PackageBuildArtifacts { + target: PathBuf, + prior: Vec, + sidecar: PathBuf, + prior_sidecar: Option, +} + +#[derive(Debug)] +struct PackagePriorPaths { + target: PathBuf, + prior: Vec, + sidecar: PathBuf, + has_sidecar: bool, +} + +/// The Script Artifacts in place before source compilation starts. +/// +/// Before compilation, old files gain unique same-directory hard-link backup entries. That leaves +/// the original inventory intact until every backup exists and lets in-process rollback restore it +/// with directory operations rather than a content rewrite. This transition deliberately has no +/// crash-recovery protocol. A later process cannot know where the stopped process was, so it refuses +/// a leftover backup without changing the Package inventory. +#[derive(Debug)] +pub struct BuildArtifactTransition { + packages: Vec, +} + +impl BuildArtifactTransition { + pub fn capture(project: &ProjectLayout, packages: &[String]) -> Result { + refuse_stale_transition(project)?; + let mut prior_paths = Vec::with_capacity(packages.len()); + for package in packages { + let generated = project.packages_dir().join(package).join("data/.generated"); + let target = generated.join(format!("{package}.script.json")); + let sidecar = generated.join(identity::SCRIPT_IDENTITY_FILE); + let mut prior = Vec::new(); + for path in script_artifact_files(&generated)? { + require_regular_file(&path, "Script Artifact")?; + prior.push(path); + } + let has_sidecar = regular_file_exists(&sidecar, "Runtime Script Build Identity")?; + prior_paths.push(PackagePriorPaths { + target, + prior, + sidecar, + has_sidecar, + }); + } + + let mut created = Vec::new(); + let mut captured = Vec::with_capacity(prior_paths.len()); + for package in prior_paths { + let mut prior = Vec::with_capacity(package.prior.len()); + for path in package.prior { + let backup = match link_to_unique_backup(&path) { + Ok(backup) => backup, + Err(error) => return backup_setup_failed(error, &created), + }; + created.push(backup.clone()); + prior.push(PriorArtifact { path, backup }); + } + let prior_sidecar = if package.has_sidecar { + let backup = match link_to_unique_backup(&package.sidecar) { + Ok(backup) => backup, + Err(error) => return backup_setup_failed(error, &created), + }; + created.push(backup.clone()); + Some(backup) + } else { + None + }; + captured.push(PackageBuildArtifacts { + target: package.target, + prior, + sidecar: package.sidecar, + prior_sidecar, + }); + } + Ok(Self { packages: captured }) + } + + /// The prior names this transition will retire — the files the checker must not see, because + /// they and their compiled replacement name one Package. + pub fn retiring(&self) -> Vec { + self.packages + .iter() + .flat_map(|package| { + package + .prior + .iter() + .map(|prior| prior.path.clone()) + .filter(|path| *path != package.target) + }) + .collect() + } + + /// Retire prior noncanonical paths after every candidate and Build Identity is in place. + fn commit(&self) -> Result<()> { + for package in &self.packages { + if !regular_file_exists(&package.target, "compiled Script Artifact")? { + return Err(Error::State(format!( + "Runtime Script compilation reported success but wrote no Script Artifact at {}", + package.target.display() + ))); + } + } + for package in &self.packages { + for prior in &package.prior { + if prior.path != package.target { + remove_regular_file_if_present(&prior.path, "prior Script Artifact")?; + } + } + } + Ok(()) + } + + /// Persist source-built identities, then retire the old inventory. Any failure before retirement + /// completes restores the old names by rename. Once retirement completes, the candidate is + /// logically committed and backup cleanup never changes the Package inventory. + pub fn certify(&self, project: &ProjectLayout, artifacts: &[PathBuf]) -> Result<()> { + if let Err(error) = identity::write_script_identities(project, artifacts) { + self.rollback()?; + return Err(error); + } + if let Err(error) = self.commit() { + self.rollback()?; + return Err(error); + } + self.discard_backups().map_err(|error| { + Error::State(format!( + "the Runtime Script build committed, but an internal backup could not be removed. \ + The candidate inventory and Build Identities are current; remove the named backup \ + after checking it, then rerun the build. ({error})" + )) + }) + } + + /// Restore the complete prior inventory after a failed transition without allocating file + /// contents. Backup creation happens before the builder can replace a candidate. + pub fn rollback(&self) -> Result<()> { + for package in &self.packages { + remove_regular_file_if_present(&package.target, "candidate Script Artifact")?; + for prior in &package.prior { + require_regular_file(&prior.backup, "Runtime Script build backup")?; + if regular_file_exists(&prior.path, "prior Script Artifact")? { + std::fs::remove_file(&prior.backup)?; + } else { + std::fs::rename(&prior.backup, &prior.path)?; + } + } + match &package.prior_sidecar { + Some(backup) => { + require_regular_file(backup, "Runtime Script Build Identity backup")?; + remove_regular_file_if_present( + &package.sidecar, + "candidate Runtime Script Build Identity", + )?; + std::fs::rename(backup, &package.sidecar)?; + } + None => remove_regular_file_if_present( + &package.sidecar, + "candidate Runtime Script Build Identity", + )?, + } + } + Ok(()) + } + + /// Remove backups after a successful logical commit. An error leaves only an internal hard-link + /// name behind; it never changes the committed candidate inventory. + fn discard_backups(&self) -> Result<()> { + for package in &self.packages { + for prior in &package.prior { + remove_regular_file_if_present(&prior.backup, "Runtime Script build backup")?; + } + if let Some(sidecar) = &package.prior_sidecar { + remove_regular_file_if_present(sidecar, "Runtime Script Build Identity backup")?; + } + } + Ok(()) + } +} + +fn link_to_unique_backup(path: &Path) -> Result { + require_regular_file(path, "Runtime Script transition input")?; + let parent = path.parent().ok_or_else(|| { + Error::State(format!( + "Runtime Script transition input {} has no parent directory", + path.display() + )) + })?; + let backup = tempfile::Builder::new() + .prefix(TRANSITION_BACKUP_PREFIX) + .make_in(parent, |backup| std::fs::hard_link(path, backup)) + .map_err(|error| { + Error::Process(format!( + "cannot create same-directory hard-link backup for {}: {error}", + path.display() + )) + })?; + backup.keep().map(|(_, path)| path).map_err(|error| { + Error::Process(format!( + "cannot retain same-directory hard-link backup for {}: {}", + path.display(), + error.error + )) + }) +} + +pub(crate) fn refuse_stale_transition(project: &ProjectLayout) -> Result<()> { + let packages_dir = project.packages_dir(); + if !directory_exists(&packages_dir, "enabled Package Inventory")? { + return Ok(()); + } + for package in std::fs::read_dir(packages_dir)? { + let package = package?; + if !package.file_type()?.is_dir() { + continue; + } + let generated = package.path().join("data/.generated"); + if !directory_exists(&generated, "generated Package artifact directory")? { + continue; + } + for entry in std::fs::read_dir(generated)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with(TRANSITION_BACKUP_PREFIX) + || name.ends_with(LEGACY_TRANSITION_BACKUP_SUFFIX) + { + return Err(Error::State(format!( + "stale Runtime Script build backup {} blocks a new build. A prior process may \ + have stopped during an artifact transition; inspect the Package artifact and \ + Build Identity, then remove this internal backup manually", + entry.path().display(), + ))); + } + } + } + Ok(()) +} + +fn directory_exists(path: &Path, description: &str) -> Result { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => Ok(true), + Ok(_) => Err(Error::State(format!( + "{description} {} is not a directory", + path.display() + ))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(Error::Process(format!( + "cannot inspect {description} {}: {error}", + path.display() + ))), + } +} + +fn regular_file_exists(path: &Path, description: &str) -> Result { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(true), + Ok(_) => Err(Error::State(format!( + "{description} {} is not a regular file", + path.display() + ))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(Error::Process(format!( + "cannot inspect {description} {}: {error}", + path.display() + ))), + } +} + +fn require_regular_file(path: &Path, description: &str) -> Result<()> { + if regular_file_exists(path, description)? { + Ok(()) + } else { + Err(Error::State(format!( + "{description} {} disappeared before it could be backed up", + path.display() + ))) + } +} + +fn remove_regular_file_if_present(path: &Path, description: &str) -> Result<()> { + if regular_file_exists(path, description)? { + std::fs::remove_file(path).map_err(|error| { + Error::Process(format!( + "cannot remove {description} {}: {error}", + path.display() + )) + })?; + } + Ok(()) +} + +fn backup_setup_failed(error: Error, created: &[PathBuf]) -> Result { + let mut cleanup_failure = None; + for backup in created { + if let Err(cleanup_error) = std::fs::remove_file(backup) { + if cleanup_error.kind() != std::io::ErrorKind::NotFound && cleanup_failure.is_none() { + cleanup_failure = Some(format!("{}: {cleanup_error}", backup.display())); + } + } + } + match cleanup_failure { + Some(cleanup) => Err(Error::State(format!( + "backup setup failed before candidate compilation and the original inventory is \ + unchanged, but an internal backup could not be removed ({cleanup}). The setup error \ + was: {error}" + ))), + None => Err(error), + } +} + +/// Verify every Script Artifact against its recorded Build Identity, the way `packages check` does +/// for Package Deltas. Returns one problem line per stale artifact. +pub fn stale(project: &ProjectLayout, artifacts: &[PathBuf]) -> Result> { + let mut problems = Vec::new(); + for path in artifacts { + let package_dir = identity::package_dir(project, path)?; + let package = package_dir + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + Error::State(format!( + "Package directory {} has no UTF-8 folder name", + package_dir.display() + )) + })?; + let has_sources = !source_files(project, package)?.is_empty(); + let sidecar = path.with_file_name(identity::SCRIPT_IDENTITY_FILE); + let text = match std::fs::read_to_string(&sidecar) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !has_sources { + // Source-free Script Artifacts are published prebuilt. They carry no Build + // Identity because this checkout has no source tree to compare against. + continue; + } + problems.push(format!( + "{}: no Build Identity sidecar (predates identity tracking, or was removed). \ + Rebuild with `lyracore packages build`.", + path.display() + )); + continue; + } + Err(error) => { + return Err(Error::Process(format!( + "cannot read Script Artifact Build Identity {}: {error}", + sidecar.display() + ))) + } + }; + if !has_sources { + problems.push(format!( + "{}: its Runtime Script sources were removed, but a source-built Script Artifact \ + remains. Run `lyracore packages build` to remove it before replay.", + path.display() + )); + continue; + } + let recorded = identity::ScriptIdentity::parse(&text, &sidecar)?; + let current = identity::compute_script(project, path)?; + for input in recorded.changed_against(¤t) { + problems.push(format!( + "{}: {} changed. Rebuild with `lyracore packages build`.", + path.display(), + input.description() + )); + } + } + Ok(problems) +} + #[cfg(test)] mod tests { use super::*; + use crate::proc::fake::FakeStack; + use tempfile::TempDir; const HASH_A: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -617,4 +1207,483 @@ mod tests { assert!(!is_script_artifact(r#"{"kind":"weather","version":1}"#)); assert!(!is_script_artifact("{ not even valid }")); } + + // ---- the build half ---- + + fn checkout(tmp: &TempDir) -> ProjectLayout { + std::fs::write(tmp.path().join("Cargo.toml"), "[workspace]\n").unwrap(); + let project = ProjectLayout::from_root(tmp.path()).unwrap(); + std::fs::create_dir_all(project.runtime_scripts_dir()).unwrap(); + std::fs::write(project.script_builder_file(), "// the builder\n").unwrap(); + std::fs::write(project.datascripts_dir().join("bun.lock"), "{}\n").unwrap(); + project + } + + fn with_scripts(project: &ProjectLayout, package: &str, file: &str) { + let dir = project.package_scripts_dir(package); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(file), "// @event on_login\n// @id 100200\n").unwrap(); + } + + /// The Script Artifact a build would have written, plus its sidecar. + fn with_built_artifact(project: &ProjectLayout, package: &str) -> PathBuf { + let dir = project.packages_dir().join(package).join("data/.generated"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{package}.script.json")); + std::fs::write(&path, "{\"kind\":\"script\"}\n").unwrap(); + identity::write_script_identities(project, std::slice::from_ref(&path)).unwrap(); + path + } + + /// Match the builder's sibling-temp-file replacement so a hard-link backup keeps the old inode. + fn replace_file(path: &Path, text: &str) { + let parent = path.parent().unwrap(); + let temporary = tempfile::NamedTempFile::new_in(parent).unwrap(); + std::fs::write(temporary.path(), text).unwrap(); + temporary.persist(path).unwrap(); + } + + #[test] + fn only_enabled_packages_carrying_a_scripts_folder_are_built() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "zeta", "a.ts"); + with_scripts(&project, "alpha", "a.ts"); + std::fs::create_dir_all(project.packages_dir().join("rust_only")).unwrap(); + let notes = project.package_scripts_dir("notes_only"); + std::fs::create_dir_all(¬es).unwrap(); + std::fs::write(notes.join("README.md"), "not executable\n").unwrap(); + std::fs::create_dir_all(notes.join("nested.ts")).unwrap(); + + assert_eq!(packages_with_scripts(&project).unwrap(), ["alpha", "zeta"]); + } + + #[test] + fn the_source_inventory_is_shallow_and_contains_only_regular_typescript_and_lua_files() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let scripts = project.package_scripts_dir("fire_nova"); + std::fs::create_dir_all(scripts.join("nested")).unwrap(); + std::fs::write(scripts.join("alpha.ts"), "one\n").unwrap(); + std::fs::write(scripts.join("zeta.lua"), "two\n").unwrap(); + std::fs::write(scripts.join("README.md"), "notes\n").unwrap(); + std::fs::write(scripts.join("nested/hidden.ts"), "ignored\n").unwrap(); + + let relative = source_files(&project, "fire_nova") + .unwrap() + .into_iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + + assert_eq!(relative, ["alpha.ts", "zeta.lua"]); + } + + #[test] + fn the_builder_subprocess_carries_the_packages_root_and_the_event_catalogue() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let stack = FakeStack::new().with_stdout("--print-events", "on_login\non_death\n"); + + run_builds(&project, &stack.runner(), &["fire_nova".to_string()]).unwrap(); + + let spec = stack + .calls() + .into_iter() + .find_map(|call| match call { + crate::proc::fake::Call::Stream(spec) if spec.render().starts_with("bun run") => { + Some(spec) + } + _ => None, + }) + .expect("the builder ran"); + assert!(spec.render().ends_with("fire_nova"), "{}", spec.render()); + assert_eq!( + spec.env_value("LYRACORE_PACKAGES_ROOT"), + Some(project.packages_dir().to_string_lossy().as_ref()) + ); + assert_eq!( + spec.env_value(HOOK_EVENTS_ENV), + Some("on_login\non_death\n") + ); + } + + #[test] + fn a_catalogue_that_cannot_be_read_stops_the_build_before_any_package_compiles() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let stack = FakeStack::new().fail_on("--print-events", "the delta crate does not compile"); + + let error = run_builds(&project, &stack.runner(), &["fire_nova".to_string()]).unwrap_err(); + + assert!(error.to_string().contains("Event Binding"), "{error}"); + for call in stack.rendered() { + assert!(!call.contains("bun run"), "{call}"); + } + } + + #[test] + fn a_mid_loop_failure_stops_the_build_and_a_later_package_never_compiles() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + let stack = FakeStack::new() + .with_stdout("--print-events", "on_login\n") + .fail_on("build-scripts.ts alpha", "the script does not compile"); + + let error = run_builds( + &project, + &stack.runner(), + &["alpha".to_string(), "zeta".to_string()], + ) + .unwrap_err(); + + assert!(error.to_string().contains("alpha"), "{error}"); + for call in stack.rendered() { + assert!(!call.contains("zeta"), "{call}"); + } + } + + // ---- staleness ---- + + #[test] + fn a_freshly_built_script_artifact_is_current() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + + assert!(stale(&project, &[path]).unwrap().is_empty()); + } + + #[test] + fn an_edited_script_source_is_stale_and_names_that_input() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + + std::fs::write( + project + .package_scripts_dir("fire_nova") + .join("ember_echo.ts"), + "// @event on_death\n// @id 100200\n", + ) + .unwrap(); + + let problems = stale(&project, &[path]).unwrap(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!( + problems[0].contains("Runtime Script sources"), + "{problems:?}" + ); + } + + #[test] + fn an_edited_toolchain_is_stale_and_an_installed_node_modules_is_not() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + + // An install under the toolchain is not an input: `bun.lock` is the pin for what it holds. + let modules = project.runtime_scripts_dir().join("node_modules/whatever"); + std::fs::create_dir_all(&modules).unwrap(); + std::fs::write(modules.join("index.js"), "module.exports = {};\n").unwrap(); + assert!(stale(&project, std::slice::from_ref(&path)) + .unwrap() + .is_empty()); + + std::fs::write(project.script_builder_file(), "// a different builder\n").unwrap(); + + let problems = stale(&project, &[path]).unwrap(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("toolchain"), "{problems:?}"); + } + + #[test] + fn a_hand_edited_artifact_is_stale() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + std::fs::write(&path, "{\"kind\":\"script\",\"edited\":true}\n").unwrap(); + + let problems = stale(&project, &[path]).unwrap(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("hand-edited"), "{problems:?}"); + } + + #[test] + fn a_missing_sidecar_is_stale_and_names_the_rebuild_command() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + std::fs::remove_file(path.with_file_name(identity::SCRIPT_IDENTITY_FILE)).unwrap(); + + let problems = stale(&project, &[path]).unwrap(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!( + problems[0].contains("lyracore packages build"), + "{problems:?}" + ); + } + + #[test] + fn a_source_free_prebuilt_artifact_needs_no_build_identity() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.lua"); + let path = with_built_artifact(&project, "playerbots"); + std::fs::remove_dir_all(project.package_scripts_dir("playerbots")).unwrap(); + std::fs::remove_file(path.with_file_name(identity::SCRIPT_IDENTITY_FILE)).unwrap(); + + assert!(stale(&project, &[path]).unwrap().is_empty()); + } + + #[test] + fn a_source_built_artifact_without_its_sources_is_stale_even_if_its_identity_matches() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "fire_nova", "ember_echo.ts"); + let path = with_built_artifact(&project, "fire_nova"); + std::fs::remove_dir_all(project.package_scripts_dir("fire_nova")).unwrap(); + let incorrectly_recertified = identity::compute_script(&project, &path).unwrap(); + std::fs::write( + path.with_file_name(identity::SCRIPT_IDENTITY_FILE), + incorrectly_recertified.render(), + ) + .unwrap(); + + let problems = stale(&project, &[path]).unwrap(); + + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("sources were removed"), "{problems:?}"); + } + + #[test] + fn a_non_not_found_sidecar_read_error_is_not_treated_as_prebuilt() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.lua"); + let path = with_built_artifact(&project, "playerbots"); + std::fs::remove_dir_all(project.package_scripts_dir("playerbots")).unwrap(); + let sidecar = path.with_file_name(identity::SCRIPT_IDENTITY_FILE); + std::fs::remove_file(&sidecar).unwrap(); + std::fs::create_dir(&sidecar).unwrap(); + + let error = stale(&project, &[path]).unwrap_err(); + + assert!(error.to_string().contains("cannot read"), "{error}"); + assert!(error.to_string().contains("script.identity"), "{error}"); + } + + #[test] + fn a_prebuilt_to_source_transition_retires_the_old_name_only_after_commit() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.ts"); + let generated = project.packages_dir().join("playerbots/data/.generated"); + std::fs::create_dir_all(&generated).unwrap(); + let prebuilt = generated.join("personality.json"); + std::fs::write(&prebuilt, artifact_json("playerbots", &[])).unwrap(); + let transition = + BuildArtifactTransition::capture(&project, &["playerbots".to_string()]).unwrap(); + let target = generated.join("playerbots.script.json"); + replace_file(&target, &artifact_json("playerbots", &[])); + let backup = transition.packages[0].prior[0].backup.clone(); + + // Both names hold a Script Artifact for one Package until the new one validates, so only + // the walk that passes over the retiring name can read this tree at all. + let inventory = crate::cmd::packages::artifact::read_enabled_except( + &project.packages_dir(), + &transition.retiring(), + ) + .unwrap(); + assert_eq!(inventory.script_paths(), *std::slice::from_ref(&target)); + assert!( + backup.is_file(), + "the prior artifact is linked before candidate compilation" + ); + assert_eq!(backup.parent(), prebuilt.parent()); + assert!(backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(TRANSITION_BACKUP_PREFIX)); + assert!(prebuilt.exists()); + + identity::write_script_identities(&project, std::slice::from_ref(&target)).unwrap(); + transition.commit().unwrap(); + assert!(!prebuilt.exists()); + assert!(backup.exists()); + assert!(target.is_file()); + + let error = refuse_stale_transition(&project) + .expect_err("an interrupted committed transition leaves its backup for inspection"); + assert!(error.to_string().contains(&backup.display().to_string())); + assert!(!prebuilt.exists() && target.is_file()); + + transition.discard_backups().unwrap(); + assert!(!backup.exists()); + } + + #[test] + fn a_failed_transition_restores_a_prebuilt_canonical_artifact_byte_for_byte() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.ts"); + let generated = project.packages_dir().join("playerbots/data/.generated"); + std::fs::create_dir_all(&generated).unwrap(); + let target = generated.join("playerbots.script.json"); + let prior = b"{\"kind\":\"script\",\"prior\":true}\n"; + std::fs::write(&target, prior).unwrap(); + let transition = + BuildArtifactTransition::capture(&project, &["playerbots".to_string()]).unwrap(); + let backup = transition.packages[0].prior[0].backup.clone(); + assert!(backup.is_file()); + assert!(target.exists()); + + replace_file(&target, "{\"kind\":\"script\",\"candidate\":true}\n"); + transition.rollback().unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), prior); + assert!( + !backup.exists(), + "rollback renames the backup back in place" + ); + assert_eq!(std::fs::read_dir(&generated).unwrap().count(), 1); + } + + #[test] + fn a_sidecar_write_failure_restores_the_prebuilt_artifact() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.ts"); + let generated = project.packages_dir().join("playerbots/data/.generated"); + std::fs::create_dir_all(&generated).unwrap(); + let prebuilt = generated.join("personality.json"); + let prior = b"{\"kind\":\"script\",\"prior\":true}\n"; + std::fs::write(&prebuilt, prior).unwrap(); + let transition = + BuildArtifactTransition::capture(&project, &["playerbots".to_string()]).unwrap(); + let target = generated.join("playerbots.script.json"); + replace_file(&target, "{\"kind\":\"script\",\"candidate\":true}\n"); + + // The directory makes the real sidecar replacement fail without a mock below the + // filesystem seam. The old artifact itself has a same-directory backup link, so restoring + // it allocates no content blocks. + std::fs::create_dir(generated.join(identity::SCRIPT_IDENTITY_FILE)).unwrap(); + let error = transition + .certify(&project, std::slice::from_ref(&target)) + .expect_err("the sidecar cannot replace a directory"); + + assert!(error.to_string().contains("script.identity"), "{error}"); + assert_eq!(std::fs::read(&prebuilt).unwrap(), prior); + assert!( + !target.exists(), + "the uncertified canonical artifact must be removed" + ); + } + + #[test] + fn rollback_renames_back_a_canonical_artifact_and_an_earlier_replaced_sidecar() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "alpha", "first.ts"); + with_scripts(&project, "beta", "second.ts"); + let alpha = with_built_artifact(&project, "alpha"); + let beta = with_built_artifact(&project, "beta"); + let alpha_sidecar = alpha.with_file_name(identity::SCRIPT_IDENTITY_FILE); + let old_artifact = std::fs::read(&alpha).unwrap(); + let old_sidecar = std::fs::read(&alpha_sidecar).unwrap(); + let transition = + BuildArtifactTransition::capture(&project, &["alpha".to_string(), "beta".to_string()]) + .unwrap(); + + let alpha_backup = transition.packages[0].prior[0].backup.clone(); + let alpha_sidecar_backup = transition.packages[0].prior_sidecar.clone().unwrap(); + assert!(alpha_backup.is_file()); + assert!(alpha_sidecar_backup.is_file()); + assert_ne!(alpha_backup, alpha_sidecar_backup); + replace_file(&alpha, "{\"kind\":\"script\",\"candidate\":true}\n"); + replace_file(&beta, "{\"kind\":\"script\",\"candidate\":true}\n"); + + let invalid_later_artifact = project.root.join("not-a-package/script.json"); + let error = transition + .certify(&project, &[alpha.clone(), invalid_later_artifact]) + .expect_err("the second identity cannot be computed"); + assert!(error.to_string().contains("not inside"), "{error}"); + + assert_eq!(std::fs::read(&alpha).unwrap(), old_artifact); + assert_eq!(std::fs::read(&alpha_sidecar).unwrap(), old_sidecar); + assert!(!alpha_backup.exists() && !alpha_sidecar_backup.exists()); + } + + #[test] + fn a_stale_backup_refuses_without_changing_the_package_inventory() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.ts"); + let target = with_built_artifact(&project, "playerbots"); + let sidecar = target.with_file_name(identity::SCRIPT_IDENTITY_FILE); + let prior = std::fs::read(&target).unwrap(); + let prior_sidecar = std::fs::read(&sidecar).unwrap(); + let stale = target.with_file_name(format!("notes{LEGACY_TRANSITION_BACKUP_SUFFIX}")); + std::fs::write(&stale, "unrelated bytes\n").unwrap(); + + let error = BuildArtifactTransition::capture(&project, &["playerbots".to_string()]) + .expect_err("stale internal state blocks the build"); + + assert!( + error.to_string().contains(&stale.display().to_string()), + "{error}" + ); + assert!(error.to_string().contains("manually"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), prior); + assert_eq!(std::fs::read(&sidecar).unwrap(), prior_sidecar); + assert_eq!(std::fs::read(&stale).unwrap(), b"unrelated bytes\n"); + } + + #[test] + fn a_legacy_commit_marker_has_no_recovery_meaning_and_is_untouched() { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + std::fs::create_dir_all(project.packages_dir()).unwrap(); + let marker = project + .packages_dir() + .join(".lyracore-script-build-committed"); + std::fs::write(&marker, "unrelated bytes\n").unwrap(); + + refuse_stale_transition(&project).unwrap(); + + assert_eq!(std::fs::read(marker).unwrap(), b"unrelated bytes\n"); + } + + #[cfg(unix)] + #[test] + fn symlinked_artifacts_and_sidecars_are_refused_before_backup() { + for sidecar_link in [false, true] { + let tmp = TempDir::new().unwrap(); + let project = checkout(&tmp); + with_scripts(&project, "playerbots", "personality.ts"); + let target = with_built_artifact(&project, "playerbots"); + let linked = if sidecar_link { + target.with_file_name(identity::SCRIPT_IDENTITY_FILE) + } else { + target.clone() + }; + let bytes = std::fs::read(&linked).unwrap(); + std::fs::remove_file(&linked).unwrap(); + let outside = tmp.path().join("outside"); + std::fs::write(&outside, bytes).unwrap(); + std::os::unix::fs::symlink(&outside, &linked).unwrap(); + + let error = BuildArtifactTransition::capture(&project, &["playerbots".to_string()]) + .expect_err("a symlink is not a regular transition input"); + + assert!(error.to_string().contains("not a regular file"), "{error}"); + assert!(linked.symlink_metadata().unwrap().file_type().is_symlink()); + } + } } diff --git a/src/project.rs b/src/project.rs index b860593..6eaaac3 100644 --- a/src/project.rs +++ b/src/project.rs @@ -379,6 +379,16 @@ impl ProjectLayout { /// see. It rides `generated/`'s own git-ignore rule for a second reason — it is the OPERATOR's /// own client-derived data, not something to commit. pub const BASE_SNAPSHOT_FILE: &'static str = "base-snapshot.json"; + /// The pinned Runtime Script toolchain: the `typescript-to-lua` config, the Host API typings, + /// the emitter plugin and the builder `packages build` runs once per Package that ships + /// scripts. It sits inside `datascripts/` as a Bun workspace, so one `bun install` covers both. + pub const RUNTIME_SCRIPTS_DIR: &'static str = "runtime-scripts"; + /// The builder entry point inside [`Self::RUNTIME_SCRIPTS_DIR`]. + pub const SCRIPT_BUILDER_FILE: &'static str = "build-scripts.ts"; + /// Where a Package keeps its Runtime Script sources, relative to the Package folder. The + /// sources are immediate regular `.ts` and `.lua` files. They are Package-authored, so unlike + /// a Datascript they live inside the Package. + pub const PACKAGE_SCRIPTS_DIR: &'static str = "scripts"; /// The pinned wire-harness release this checkout consumes (#246). pub const WIRE_HARNESS_PIN: &'static str = ".wire-harness-rev"; @@ -530,6 +540,24 @@ impl ProjectLayout { self.datascript_types_dir().join(Self::BASE_SNAPSHOT_FILE) } + /// The pinned Runtime Script toolchain, e.g. `datascripts/runtime-scripts/`. + pub fn runtime_scripts_dir(&self) -> PathBuf { + self.datascripts_dir().join(Self::RUNTIME_SCRIPTS_DIR) + } + + /// The Runtime Script builder `packages build` runs, e.g. + /// `datascripts/runtime-scripts/build-scripts.ts`. + pub fn script_builder_file(&self) -> PathBuf { + self.runtime_scripts_dir().join(Self::SCRIPT_BUILDER_FILE) + } + + /// One Package's Runtime Script sources, e.g. `packages/fire_nova/scripts/`. + pub fn package_scripts_dir(&self, package: &str) -> PathBuf { + self.packages_dir() + .join(package) + .join(Self::PACKAGE_SCRIPTS_DIR) + } + pub fn standalone_unit(&self) -> PathBuf { self.root.join(Self::STANDALONE_UNIT) }