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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/runtime-scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Runtime Scripts

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: datascripts
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.7
- run: bun install --frozen-lockfile
- name: Typecheck and verify Runtime Script output
run: bun test tests/runtime-scripts.test.ts
11 changes: 6 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,17 @@ __pycache__/
# never client bytes, but it is the operator's own derived data and stays on the operator's machine.
/datascripts/generated/
/datascripts/node_modules/
/datascripts/runtime-scripts/node_modules/

# Packages installed on a host (`lyracore packages add`) are local to that checkout. Unknown
# Package directories stay out of Git status. Source files in the committed first-party Packages
# remain visible; a first-party Package that ever ships a Script Artifact gets its own negation
# under data/.generated/ then. None does today.
# remain visible.
/packages/*
!/packages/example/
!/packages/fire_nova/

# A generated Package Delta and its Build Identity sidecar, even inside an allowlisted first-party
# Package above: `packages build` reproduces both from source on every run, and a Package Delta is
# never committed — the same rule `datascripts/generated/` follows above.
# What `lyracore packages build` writes into a first-party Package: Package Deltas, Script Artifacts
# and their Build Identity sidecars. Here they are output the sources next to them reproduce, so
# none is committed. A Package Delta is never committed anywhere (licensing firewall); the Official
# Package Collection does commit Script Artifacts, so an Operator installs prebuilt Lua without Bun.
/packages/*/data/.generated/
26 changes: 21 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,11 +494,13 @@ records where a Package was installed from.
_Avoid_: apply log, history, audit trail

**Build Identity**:
The recorded inputs `packages build` writes next to a Package's generated artifact, in a sibling
file rather than inside it: hashes of the Package's Datascript source tree, the generated Module
typings, the Base Snapshot, the authoring library, and the pinned Datascript toolchain files, plus
the pinned Bun version and the artifact's own hash. `lyracore packages check` and preflight
recompute it against the checkout on disk and refuse, naming the input that changed.
The recorded inputs `packages build` writes next to a source-built artifact, in a sibling file
rather than inside it. A Package Delta records its Datascript source tree, generated Module
typings, Base Snapshot, authoring library, and pinned Datascript toolchain. A source-built Script
Artifact records its `scripts/` sources, Runtime Script Toolchain, Bun pin, and artifact hash. A
source-free prebuilt Script Artifact has no local author inputs or Build Identity; the authoritative
checker still parses and traces it. `lyracore packages check` and preflight recompute a present
identity against the checkout on disk and refuse, naming the input that changed.
_Avoid_: identity file, build fingerprint, artifact metadata

**Claim**:
Expand Down Expand Up @@ -553,6 +555,20 @@ event: lower priority runs first and the script identifier breaks a tie, so ever
plan in one order.
_Avoid_: hook registration, subscription, listener

**Script Directive**:
A `@key value` comment line at the top of a Runtime Script source, declaring what the file cannot
say in its own code: `@event` and `@id` are required, `@priority` and `@enabled` have defaults. The
identifier is written down rather than derived, because it is durable — deriving it from a file
index would renumber a Package's scripts the moment an author added one.
_Avoid_: annotation, frontmatter, pragma, metadata header

**Runtime Script Toolchain**:
The pinned compiler that turns a Package's `scripts/` sources into its Script Artifact: Bun plus
`typescript-to-lua`, its config, the hand-maintained Host API typings, and the emitter that keeps
generated Lua off the interpreter's known call-shape fault. It lives in
`datascripts/runtime-scripts/` and runs at author time only; an Operator installs the prebuilt Lua.
_Avoid_: transpiler, build pipeline, SDK

**Package Item Range**:
The items family's Package Identifier Range: 7,000,000 to 7,999,999. Above every reserved band, and
one whole decade above the Package Spell Range so the millions column stays a family-at-a-glance
Expand Down
139 changes: 112 additions & 27 deletions crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
//! `lyracore-delta-check <artifact.json>...` — parse a set of Package Delta artifacts, trace them
//! together, and print the plan they form.
//! `lyracore-delta-check <artifact.json>...` — parse a set of generated Package artifacts, trace
//! them together, and print the plan they form.
//!
//! Two artifact kinds arrive, because a Package ships every kind it has into one generated
//! directory: Package Deltas and Script Artifacts. Each file is routed on its own `kind` member and
//! traced with its own kind, and both traces have to come out clear.
//!
//! This is the check a Datascript author gets after a build, before anything reaches a shard. It
//! needs no client data, no database and no realm: it reads the named files and nothing else, which
//! is why it can run on any machine in under a second.
//!
//! Exit status is the whole answer: 0 when every artifact parses and the claims merge cleanly,
//! 1 when an artifact is refused or two Packages claim the same column of one row. The output names
//! the file and the exact claim in both cases, because the reader is the author who has to fix one
//! line of a Datascript.
//! Exit status is the whole answer: 0 when every artifact parses and the Packages agree, 1 when an
//! artifact is refused, two Packages claim the same column of one row, or two Packages claim one
//! Package twice, or two Packages claim one Runtime Script identity. The output names the file and
//! the exact conflict in every case,
//! because the reader is the author who has to fix one line of a Datascript or one script file.
//!
//! Trace ALL of a realm's enabled artifacts in one invocation. Conflicts exist BETWEEN Packages, so
//! checking one file at a time can only ever prove that one file parses.
Expand All @@ -19,7 +24,10 @@
use std::path::Path;
use std::process::ExitCode;

use lyracore_package_delta::{trace, ClaimTrace, Operation, PackageDelta, HOOK_EVENT_NAMES};
use lyracore_package_delta::{
artifact_kind, trace, trace_scripts, ArtifactKind, ClaimTrace, Operation, PackageDelta,
ScriptArtifact, ScriptTrace, HOOK_EVENT_NAMES,
};

fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
Expand All @@ -31,44 +39,80 @@ fn main() -> ExitCode {
if paths.is_empty() || paths.iter().any(|a| a == "-h" || a == "--help") {
eprintln!(
"usage: lyracore-delta-check <artifact.json>...\n\n\
Parses each Package Delta artifact, traces them together, and prints the plan.\n\
Exits non-zero on a refused artifact or a Claim Conflict. Reads no client data and \
no database.\n\n\
Name every enabled Package's artifacts in ONE run: a Claim Conflict is between \
Parses each generated Package artifact — a Package Delta or a Script Artifact — \
traces each kind together, and prints the plan.\n\
Exits non-zero on a refused artifact, a Claim Conflict or a Script Artifact \
conflict. Reads no client data and no database.\n\n\
Name every enabled Package's artifacts in ONE run: a conflict is between \
Packages, so one file alone cannot show one.\n\n\
lyracore-delta-check --print-events prints the Event Binding catalogue instead."
);
return ExitCode::FAILURE;
}

let mut deltas = Vec::with_capacity(paths.len());
let mut deltas: Vec<PackageDelta> = Vec::new();
let mut delta_paths: Vec<&String> = Vec::new();
let mut scripts: Vec<ScriptArtifact> = Vec::new();
let mut script_paths: Vec<&String> = Vec::new();
for path in &paths {
match read_artifact(Path::new(path)) {
Ok(delta) => deltas.push(delta),
Ok(Artifact::Delta(delta)) => {
deltas.push(delta);
delta_paths.push(path);
}
Ok(Artifact::Script(artifact)) => {
scripts.push(artifact);
script_paths.push(path);
}
Err(refusal) => {
eprintln!("{refusal}");
return ExitCode::FAILURE;
}
}
}

let traced = trace(&deltas);
print!("{}", report(&paths, &deltas, &traced));
let claim_trace = trace(&deltas);
let script_trace = trace_scripts(&scripts);
print!(
"{}",
report(
&delta_paths,
&deltas,
&claim_trace,
&script_paths,
&scripts,
&script_trace
)
);

if traced.is_clear() {
if claim_trace.is_clear() && script_trace.is_clear() {
eprintln!(
"{} artifact(s) check out over {} row(s).",
deltas.len(),
traced.rows().len()
"{} artifact(s) check out over {} row(s) and {} Runtime Script(s).",
deltas.len() + scripts.len(),
claim_trace.rows().len(),
script_trace.scripts().len()
);
ExitCode::SUCCESS
} else {
return ExitCode::SUCCESS;
}
if !claim_trace.is_clear() {
eprintln!(
"{} claim conflict(s) between the named Packages — a human chooses.",
traced.conflicts().len()
claim_trace.conflicts().len()
);
ExitCode::FAILURE
}
if !script_trace.is_clear() {
eprintln!(
"{} Script Artifact conflict(s) between the named Packages; a human chooses.",
script_trace.conflicts().len()
);
}
ExitCode::FAILURE
}

/// One generated artifact, already routed to the parser its own `kind` chose.
enum Artifact {
Delta(PackageDelta),
Script(ScriptArtifact),
}

/// Prints [`HOOK_EVENT_NAMES`], one event per line, in catalogue order, and exits.
Expand All @@ -95,19 +139,37 @@ fn print_events(args: &[String]) -> ExitCode {
ExitCode::SUCCESS
}

/// Read one file and hand it to the parser its own `kind` names.
///
/// Routing on `kind` rather than on the file name: a Package chooses its own artifact file names,
/// and the member inside the file is the only thing that says what it holds. A kind this build does
/// not read reaches [`PackageDelta::parse`], which refuses it by name.
///
/// A refusal names the FILE, so an author with several Packages open knows which one to fix.
fn read_artifact(path: &Path) -> Result<PackageDelta, String> {
fn read_artifact(path: &Path) -> Result<Artifact, String> {
let json = std::fs::read_to_string(path)
.map_err(|e| format!("{}: cannot read: {e}", path.display()))?;
PackageDelta::parse(&json).map_err(|e| format!("{}: {e}", path.display()))
let refuse = |e| format!("{}: {e}", path.display());
match artifact_kind(&json) {
Some(ArtifactKind::Script) => ScriptArtifact::parse(&json).map(Artifact::Script),
_ => PackageDelta::parse(&json).map(Artifact::Delta),
}
.map_err(refuse)
}

/// The plan: every Package with its file and row counts, then every claimed row, then every
/// conflict. The same shape the importer's Package Delta stage prints, so an author and an operator
/// read the same report.
fn report(paths: &[String], deltas: &[PackageDelta], traced: &ClaimTrace) -> String {
fn report(
delta_paths: &[&String],
deltas: &[PackageDelta],
traced: &ClaimTrace,
script_paths: &[&String],
scripts: &[ScriptArtifact],
script_trace: &ScriptTrace,
) -> String {
let mut out = String::from("\n=== Package Deltas ===\n");
for (path, delta) in paths.iter().zip(deltas) {
for (path, delta) in delta_paths.iter().zip(deltas) {
let counts = delta.claim_counts();
out.push_str(&format!(
" {:<32} {:>3} updated {:>3} spells {:>3} effects {path}\n",
Expand Down Expand Up @@ -139,6 +201,29 @@ fn report(paths: &[String], deltas: &[PackageDelta], traced: &ClaimTrace) -> Str
for conflict in traced.conflicts() {
out.push_str(&format!(" CONFLICT {conflict}\n"));
}

out.push_str("=== Runtime Scripts ===\n");
for (path, artifact) in script_paths.iter().zip(scripts) {
out.push_str(&format!(
" {:<32} {:>3} script(s) {path}\n",
artifact.package(),
artifact.scripts().len()
));
}
for traced in script_trace.scripts() {
let script = &traced.script;
out.push_str(&format!(
" {:>6} {:<32} {} priority {}{}\n",
script.script_id(),
script.name(),
script.event(),
script.priority(),
if script.enabled() { "" } else { " (disabled)" },
));
}
for conflict in script_trace.conflicts() {
out.push_str(&format!(" CONFLICT {conflict}\n"));
}
out.push_str("======================\n\n");
out
}
33 changes: 26 additions & 7 deletions crates/lyracore-package-delta/src/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
//!
//! # What it traces
//!
//! [`trace_scripts`] puts several Packages' artifacts together. Two Packages shipping one
//! `script_id`, or one name, is a [`ScriptConflict`] naming both Packages. There is no merge case
//! and there are no priority numbers to break the tie with: `priority` orders scripts at an event,
//! [`trace_scripts`] puts several Packages' artifacts together. A Package appearing twice, or two
//! Packages shipping one `script_id` or name, is a [`ScriptConflict`]. There is no merge case and
//! there are no priority numbers to break the tie with: `priority` orders scripts at an event,
//! never Packages against each other.

use core::fmt;
Expand Down Expand Up @@ -601,9 +601,14 @@ pub struct TracedScript {
pub source_hash: SourceHash,
}

/// Two Packages claiming one Runtime Script identity. Only a human can settle it.
/// Script Artifacts that cannot belong to one plan. Only a human can settle the inventory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScriptConflict {
/// One Package supplied more than one Script Artifact.
Package {
/// The Package named by both artifacts.
package: PackageId,
},
/// Both Packages ship a script at this identifier.
Id {
/// The contested identifier.
Expand All @@ -627,6 +632,12 @@ pub enum ScriptConflict {
impl fmt::Display for ScriptConflict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Package { package } => {
write!(
f,
"package `{package}` appears in more than one Script Artifact"
)
}
Self::Id {
script_id,
first,
Expand Down Expand Up @@ -668,7 +679,7 @@ impl ScriptTrace {
&self.conflicts
}

/// True when no two Packages disagree, which is the only state an applier may write.
/// True when every Package supplied one artifact and no two Packages disagree.
#[must_use]
pub fn is_clear(&self) -> bool {
self.conflicts.is_empty()
Expand All @@ -677,16 +688,24 @@ impl ScriptTrace {

/// Merge several Packages' Script Artifacts into one plan, reporting every collision.
///
/// A script is never merged with another: the whole row belongs to one Package. So the only thing
/// to trace is identity — an identifier or a name that two Packages both claim.
/// A script is never merged with another: the whole row belongs to one Package. The tracer first
/// requires one artifact per Package, then checks each identifier and name across Packages.
///
/// A conflicting script is still carried into [`ScriptTrace::scripts`], from whichever Package
/// shipped it first. That keeps the plan describable in a report; it is not applyable, because an
/// applier refuses a plan that is not clear.
#[must_use]
pub fn trace_scripts(artifacts: &[ScriptArtifact]) -> ScriptTrace {
let mut trace = ScriptTrace::default();
let mut packages: Vec<PackageId> = Vec::new();
for artifact in artifacts {
if packages.contains(&artifact.package) {
trace.conflicts.push(ScriptConflict::Package {
package: artifact.package.clone(),
});
continue;
}
packages.push(artifact.package.clone());
for script in &artifact.scripts {
if let Some(seen) = trace
.scripts
Expand Down
Loading