commit af5c5f7a25c236898591e5088cfe5a75179b9e36 Author: Codex Bot Date: Tue Oct 20 01:10:02 2026 +0800 feat: establish Lyra CLI with pinned Aster engine and remote CI diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d2e1d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +target +data +work +outputs +ci-output +.env +*.db +*.db-shm +*.db-wal +**/.git +**/target +**/AGENTS.md +**/work +**/ci-output diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..91118b6 --- /dev/null +++ b/.gitea/README.md @@ -0,0 +1,22 @@ +# Lyra CLI automation + +[workflows](workflows/README.md) contains repository workflows. + +## CI checkout prerequisites + +All workflows use `runs-on: ces-control`; no shared Runner configuration is changed. +Configure repository or organization secrets `CODEX_BOT_SSH_KEY` (the dedicated +codex-bot private SSH key) and `GITEA_SSH_KNOWN_HOSTS` (the independently verified +known_hosts entry for `[gitea.entroia.com]:22222`). The bot needs read access to the +repository and its Aster submodule. Workflows use an isolated SSH command with +`-F /dev/null`, `IdentitiesOnly=yes`, an explicit key and port, strict host-key +checking, and temporary credential files removed after checkout. Keys are never +written into tracked files. Missing secrets fail checkout explicitly. + +The SSH submodule URL's repository owner is separate from the authenticated bot. +Local Git operations must use the same dedicated bot identity; configure the +checkout's isolated command in `GIT_SSH_COMMAND` for submodule initialization. + +Workflow definitions do not prove CI passed. CI/deployment state is not queried +automatically. Local runtime checks are not performed for Aster, Lyra or Hestia; +Tessera permits local native builds/window runs, with automated tests in CI. diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md new file mode 100644 index 0000000..e4f9da0 --- /dev/null +++ b/.gitea/workflows/README.md @@ -0,0 +1,5 @@ +# Lyra CLI workflows + +[ci.yml](ci.yml) verifies this project on main pushes, pull requests and manual dispatch. It runs on ces-control, with a 60-minute timeout. Checkout and secret requirements are in [the parent README](../README.md). + +There is no production service deployment in this workflow. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..115ef23 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,68 @@ +name: Verify Lyra CLI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + verify: + runs-on: ces-control + timeout-minutes: 60 + env: + DOCKER_BUILDKIT: "0" + steps: + - name: Checkout with codex-bot + env: + CODEX_BOT_SSH_KEY: ${{ secrets.CODEX_BOT_SSH_KEY }} + GITEA_SSH_KNOWN_HOSTS: ${{ secrets.GITEA_SSH_KNOWN_HOSTS }} + SOURCE_SHA: ${{ gitea.sha }} + SOURCE_REPOSITORY: ${{ gitea.repository }} + run: | + set -eu + test -n "$CODEX_BOT_SSH_KEY" + test -n "$GITEA_SSH_KNOWN_HOSTS" + case "$SOURCE_SHA" in *[!0-9a-f]*|'') exit 1;; esac + test "${#SOURCE_SHA}" -eq 40 + umask 077 + checkout_keys=$(mktemp -d) + trap 'rm -rf "$checkout_keys"' EXIT + printf '%s\n' "$CODEX_BOT_SSH_KEY" > "$checkout_keys/key" + printf '%s\n' "$GITEA_SSH_KNOWN_HOSTS" > "$checkout_keys/known_hosts" + unset CODEX_BOT_SSH_KEY GITEA_SSH_KNOWN_HOSTS + export GIT_SSH_COMMAND="ssh -F /dev/null -o IdentitiesOnly=yes -i $checkout_keys/key -p 22222 -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$checkout_keys/known_hosts" + git init . + source_url="ssh://git@gitea.entroia.com:22222/$SOURCE_REPOSITORY.git" + if git remote get-url origin >/dev/null 2>&1; then + git remote set-url origin "$source_url" + else + git remote add origin "$source_url" + fi + git fetch --no-tags --depth=1 origin "$SOURCE_SHA" + git checkout --detach --force FETCH_HEAD + git submodule update --init --recursive + - name: Verify CLI and record Endor output + env: + LYRA_RELEASE_SHA: ${{ gitea.sha }} + run: sh scripts/ci-ces-control.sh + - name: Show Endor output + if: always() + run: cat ci-output/endor-examples.md + - name: Upload Endor output + if: always() + uses: actions/upload-artifact@v3 + with: + name: endor-execution-output-${{ gitea.sha }} + path: | + ci-output/endor-examples.md + ci-output/endor-examples.json + if-no-files-found: error + retention-days: 30 + - name: Upload Linux CLI + uses: actions/upload-artifact@v3 + with: + name: lyra-linux-${{ gitea.sha }} + path: ci-output/lyra + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cbc573 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target/ +/data/ +/work/ +/outputs/ +/ci-output/ +.env +*.db +*.db-shm +*.db-wal +__pycache__/ +AGENTS.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..aa04b20 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/aster"] + path = vendor/aster + url = ssh://git@gitea.entroia.com:22222/Zayden-Jung/endorphin-aster-vm.git diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cc275b6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,163 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cli" +version = "0.1.0" +dependencies = [ + "compiler", + "core", + "frontend", +] + +[[package]] +name = "compiler" +version = "0.1.0" +dependencies = [ + "core", + "frontend", +] + +[[package]] +name = "core" +version = "0.1.0" +dependencies = [ + "objects", +] + +[[package]] +name = "frontend" +version = "0.1.0" +dependencies = [ + "peg", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "objects" +version = "0.1.0" + +[[package]] +name = "peg" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aad070be5b63aa72103f2fcdd70a83adbd5e90112ce5b574171ff1c65501773" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd8ef6825cae95355031ae26a99b616a2a21f22ba2de0197c43dfb05acbe7ee" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7011d97b484a5ebdc4b1fdb3b12d5e4bbbea56e9d22b688f2e79e04b65a7d8a6" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4794a5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +members = ["crates/cli"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.91" +license = "MIT" + +[workspace.dependencies] +core = { path = "vendor/aster/crates/core" } +compiler = { path = "vendor/aster/crates/compiler" } +frontend = { path = "vendor/aster/crates/frontend" } + +[workspace.lints.rust] +unsafe_code = "forbid" diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..fccdc33 --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,11 @@ +FROM rust:1.91.1-bookworm +WORKDIR /build +RUN apt-get update && apt-get install -y --no-install-recommends python3 \ + && rm -rf /var/lib/apt/lists/* \ + && rustup component add rustfmt clippy +ENV CARGO_BUILD_JOBS=1 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY vendor ./vendor +COPY scripts ./scripts +CMD ["sh", "scripts/check.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a8dfea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aster VM contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8cc63c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Lyra CLI + +Lyra 是 Endor 的独立命令行宿主,由 Aster 语言引擎提供解析、编译和执行。 +源码迁自 Aster 提交 51cd64e6884c1036564c22e5de582c083d6db142;引擎通过 vendor/aster 子模块固定版本。 + +| 入口 | 职责 | +| --- | --- | +| [crates](crates/README.md) | CLI 源码与进程集成测试 | +| [vendor](vendor/README.md) | 固定版本的 Aster 引擎 | +| [scripts](scripts/README.md) | 远端检查、五个 Endor 样例的实际输出报告 | +| [.gitea](.gitea/README.md) | ces-control CI 与 artifact | +| Dockerfile.ci | 远端检查和 Linux 可执行文件构建镜像 | +| Cargo.toml / Cargo.lock | Rust 1.91+ 工作区与依赖锁 | +| LICENSE / .gitignore / .dockerignore | 许可和产物排除 | + +初始化子模块后,远端构建产出 `lyra`。用法: + +```sh +lyra run vendor/aster/crates/compiler/examples/hello.endor +lyra eval vendor/aster/crates/compiler/examples/literal.endor +lyra parse --json vendor/aster/crates/frontend/examples/basic.endor +lyra bootstrap +``` + +run 默认调用无参数 main,可用 --entry 指定入口;run/eval/parse 支持 - 读取 UTF-8 stdin。语言能力与所有权见固定 Aster 版本文档。Lyra 不提供宿主 metadata 管理,相关命令已归 Hestia 的兼容管理程序 cli。 + +本地不构建、运行测试/冒烟或下载 Cargo 依赖。远端 CI 在 ces-control 检查格式、进程测试、Clippy,并执行固定 Aster 的五个样例;Markdown/JSON 报告保存真实 stdout/stderr、退出码、源码和预期值,失败也保留。成功后上传 Linux lyra 二进制。CI 凭据要求见 [.gitea](.gitea/README.md),不自动查询运行状态。 diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..335eed6 --- /dev/null +++ b/crates/README.md @@ -0,0 +1,3 @@ +# Lyra 源码 + +[cli](cli/README.md) 是本工作区唯一包,Cargo 包名 cli,可执行文件 lyra。通过根工作区依赖固定 Aster 子模块。返回 [项目入口](../README.md)。 diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..8731ed0 --- /dev/null +++ b/crates/cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cli" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "lyra" +path = "src/main.rs" + +[dependencies] +compiler.workspace = true +core.workspace = true +frontend.workspace = true + +[lints] +workspace = true diff --git a/crates/cli/README.md b/crates/cli/README.md new file mode 100644 index 0000000..c043435 --- /dev/null +++ b/crates/cli/README.md @@ -0,0 +1,11 @@ +# CLI 包 + +Cargo 包名 cli,二进制 lyra。提供 bootstrap、run、eval、parse 和 help;JSON 文件信息管理由 Hestia 负责。 + +| 入口 | 内容 | +| --- | --- | +| Cargo.toml | 库依赖来自根工作区的固定 Aster 子模块 | +| [src](src/README.md) | 命令分发与源码执行 | +| [tests](tests/README.md) | 进程输出、来源诊断与失败退出检查 | + +只在 ces-control 验证,不在本地运行。返回 [crates](../README.md)。 diff --git a/crates/cli/src/README.md b/crates/cli/src/README.md new file mode 100644 index 0000000..4bebe75 --- /dev/null +++ b/crates/cli/src/README.md @@ -0,0 +1,3 @@ +# CLI 源码 + +main.rs 处理命令、对象引导和 parse 输出;execute.rs 处理 run/eval、入口查找、结果格式、traceback 和候选命名空间清理。二进制名 lyra。返回 [包入口](../README.md)。 diff --git a/crates/cli/src/execute.rs b/crates/cli/src/execute.rs new file mode 100644 index 0000000..8e92486 --- /dev/null +++ b/crates/cli/src/execute.rs @@ -0,0 +1,105 @@ +use std::error::Error; + +use compiler::{CompileOptions, compile_source}; +use endor_core::{Value, Vm}; +use frontend::{EndorReader, ParseMode, SourceUnit}; + +pub(super) fn run_command(arguments: &[&str]) -> Result<(), Box> { + let mut entry = "main"; + let mut path = None; + let mut arguments = arguments.iter().copied(); + while let Some(argument) = arguments.next() { + match argument { + "--entry" => { + entry = arguments + .next() + .filter(|name| !name.is_empty() && !name.starts_with("--")) + .ok_or(super::USAGE)?; + } + value if !value.starts_with("--") && path.is_none() => path = Some(value), + _ => return Err(super::USAGE.into()), + } + } + let source = read(path.ok_or(super::USAGE)?)?; + let output = execute(&source, ParseMode::Module, Some(entry))?; + println!("{output}"); + Ok(()) +} + +pub(super) fn eval_command(path: &str) -> Result<(), Box> { + let source = read(path)?; + let output = execute(&source, ParseMode::Expression, None)?; + println!("{output}"); + Ok(()) +} + +fn read(path: &str) -> Result> { + Ok(if path == "-" { + EndorReader::from_stream("", std::io::stdin().lock())? + } else { + EndorReader::from_file(path)? + }) +} + +fn execute( + source: &SourceUnit, + mode: ParseMode, + entry: Option<&str>, +) -> Result> { + let compiled = compile_source(source, mode, &CompileOptions::default())?; + let mut vm = Vm::new()?; + let code = compiled.load(&mut vm)?; + let globals = match vm.new_dict() { + Ok(globals) => globals, + Err(error) => { + vm.release(code)?; + return Err(error.into()); + } + }; + let result = (|| -> Result> { + let mut value = vm.execute(code, globals)?; + if let Some(entry) = entry { + vm.release_value(value)?; + let Some(Value::Object(function)) = vm.dict_get(globals, &Value::Text(entry.into()))? + else { + return Err(format!("entry function not found: {entry}").into()); + }; + value = vm.call(function, &[])?; + } + let output = format_value(&vm, &value); + vm.release_value(value)?; + output + })(); + // Copy traceback metadata before cleanup. This is the entry location of + // each code object plus bytecode offset, not a per-instruction source map. + let result = result.map_err(|error| { + let mut message = format!("{}: {error}", source.origin()); + for location in vm.last_traceback() { + message.push_str(&format!( + "\n at {} ({}:{}, code unit {})", + location.function, + location.filename, + location.first_line, + location.instruction_offset + )); + } + Box::::from(message) + }); + vm.release(code)?; + // CLI owns this fresh namespace. Clearing it breaks function↔globals + // cycles on both success and failure; embedders choose their own lifetime. + vm.dict_clear(globals)?; + vm.release(globals)?; + vm.collect_cycles(); + result +} + +fn format_value(vm: &Vm, value: &Value) -> Result> { + Ok(match value { + Value::Nil => "nil".into(), + Value::Bool(value) => value.to_string(), + Value::Text(value) => value.clone(), + Value::Number(value) => value.to_string(), + Value::Object(reference) => vm.str(*reference)?, + }) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs new file mode 100644 index 0000000..6d37a9a --- /dev/null +++ b/crates/cli/src/main.rs @@ -0,0 +1,92 @@ +use std::error::Error; +use std::process::ExitCode; + +use endor_core::{Value, Vm}; +use frontend::ast::AstDocument; +use frontend::{EndorReader, ParseMode, parse_source_ast}; + +mod execute; + +const USAGE: &str = "Usage: lyra [bootstrap | run [--entry name] | eval | parse [--mode module|expression|interactive] [--json] | help]"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("lyra: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { + let args: Vec = std::env::args().skip(1).collect(); + match args + .iter() + .map(String::as_str) + .collect::>() + .as_slice() + { + [] | ["bootstrap"] => { + let mut vm = Vm::new()?; + let first = vm.new_object()?; + let second = vm.new_object()?; + vm.set_attr( + first, + "message", + Value::Text("Hello from EndorObject".into()), + )?; + println!("runtime=EndorObject type_system=disabled"); + println!( + "objects_distinct={} heap_objects={}", + first != second, + vm.object_count() + ); + vm.release(first)?; + vm.release(second)?; + println!("released heap_objects={}", vm.object_count()); + println!("bootstrap uses no database"); + } + ["help"] | ["--help"] | ["-h"] => println!("{USAGE}"), + ["parse", arguments @ ..] => parse_command(arguments)?, + ["run", arguments @ ..] => execute::run_command(arguments)?, + ["eval", path] => execute::eval_command(path)?, + _ => return Err(USAGE.into()), + } + Ok(()) +} + +fn parse_command(arguments: &[&str]) -> Result<(), Box> { + let mut mode = ParseMode::Module; + let mut json = false; + let mut path = None; + let mut arguments = arguments.iter().copied(); + while let Some(argument) = arguments.next() { + match argument { + "--json" => json = true, + "--mode" => { + mode = match arguments.next() { + Some("module") => ParseMode::Module, + Some("expression") => ParseMode::Expression, + Some("interactive") => ParseMode::Interactive, + _ => return Err(USAGE.into()), + } + } + value if !value.starts_with("--") && path.is_none() => path = Some(value), + _ => return Err(USAGE.into()), + } + } + let path = path.ok_or(USAGE)?; + let source = if path == "-" { + EndorReader::from_stream("", std::io::stdin().lock())? + } else { + EndorReader::from_file(path)? + }; + let root = parse_source_ast(&source, mode)?; + if json { + println!("{}", AstDocument::new(root)?.to_json()?); + } else { + println!("{root:#?}"); + } + Ok(()) +} diff --git a/crates/cli/tests/README.md b/crates/cli/tests/README.md new file mode 100644 index 0000000..e04dbf8 --- /dev/null +++ b/crates/cli/tests/README.md @@ -0,0 +1,3 @@ +# CLI 进程检查 + +execute.rs 检查固定 Aster 子模块中的五个真实 Endor 文件、stdin、入口选择、错误阶段和退出码;parse.rs 检查来源、UTF-8、AST/JSON 和非法输入。仅远端执行,不在本地运行。返回 [包入口](../README.md)。 diff --git a/crates/cli/tests/execute.rs b/crates/cli/tests/execute.rs new file mode 100644 index 0000000..c7f4e42 --- /dev/null +++ b/crates/cli/tests/execute.rs @@ -0,0 +1,136 @@ +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +fn stdin_command(arguments: &[&str], source: &[u8]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_lyra")) + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(source).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn run_executes_real_endor_files_and_prints_entry_results() { + for (file, expected) in [ + ("hello.endor", "你好,Endor 🌱\n"), + ("calls.endor", "函数调用已接通\n"), + ("shadowing.endor", "局部名称优先\n"), + ("number.endor", "-123456789012345678901234567890.00100\n"), + ] { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../vendor/aster/crates/compiler/examples") + .join(file); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .arg("run") + .arg(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); + assert!(output.stderr.is_empty()); + } +} + +#[test] +fn eval_and_run_read_stdin_and_support_explicit_entry_names() { + let output = stdin_command(&["eval", "-"], "\"表达式🌱\"".as_bytes()); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "表达式🌱\n"); + let output = stdin_command( + &["run", "--entry", "greet", "-"], + b"function greet(): String { return \"ok\" }", + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "ok\n"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../vendor/aster/crates/compiler/examples/literal.endor"); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .arg("eval") + .arg(path) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "表达式 → 字节码 → VM\n" + ); +} + +#[test] +fn execution_errors_fail_without_success_output_and_report_their_stage() { + for (arguments, source, expected) in [ + ( + vec!["run", "-"], + "function main(): T {\n return missing\n}", + ":2:10: undefined name: missing", + ), + ( + vec!["run", "-"], + "function main(): T { return f() } function f(x: T): T { return x }", + "positional arguments", + ), + ( + vec!["run", "-"], + "function other(): T { return 1 }", + "entry function not found: main", + ), + ( + vec!["eval", "-"], + "1 + 2", + "arithmetic bytecode is not implemented", + ), + (vec!["eval", "-"], "1()", "does not support call"), + ( + vec!["run", "-"], + "function main(): T { return ) }", + "expected", + ), + ] { + let output = stdin_command(&arguments, source.as_bytes()); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains(expected), "{error}"); + if expected == "positional arguments" { + assert!(error.contains("at main (:1, code unit")); + } + } + let output = stdin_command(&["eval", "-"], &[0xff]); + assert!(!output.status.success()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("invalid UTF-8") + ); + for arguments in [ + vec!["run"], + vec!["run", "--entry"], + vec!["run", "--unknown", "-"], + vec!["eval"], + vec!["eval", "-", "extra"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .args(arguments) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr).unwrap().contains("Usage:")); + } +} diff --git a/crates/cli/tests/parse.rs b/crates/cli/tests/parse.rs new file mode 100644 index 0000000..ac7e1b6 --- /dev/null +++ b/crates/cli/tests/parse.rs @@ -0,0 +1,139 @@ +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static NEXT_FILE: AtomicUsize = AtomicUsize::new(0); + +struct SourceFile(PathBuf); + +impl SourceFile { + fn new(content: &[u8]) -> Self { + let path = std::env::temp_dir().join(format!( + "aster-parse-{}-{}.endor", + std::process::id(), + NEXT_FILE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::write(&path, content).unwrap(); + Self(path) + } +} + +impl Drop for SourceFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[test] +fn parse_command_prints_ast_without_executing_source() { + let source = SourceFile::new("function 表示(): String { return 未定義() }".as_bytes()); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .arg("parse") + .arg(&source.0) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("表示")); + assert!(stdout.contains("未定義")); + assert!(stdout.contains("Call")); +} + +#[test] +fn parse_command_reports_source_locations_and_rejects_invalid_utf8() { + let source = SourceFile::new(b"function f(): Number {\n return )\n}"); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .arg("parse") + .arg(&source.0) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains(":2:10:"), "{stderr}"); + assert!(stderr.contains("expected")); + let source = SourceFile::new(&[0xff]); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .arg("parse") + .arg(&source.0) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); +} + +fn parse_stdin(source: &[u8]) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_lyra")) + .args(["parse", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(source).unwrap(); + drop(stdin); + child.wait_with_output().unwrap() +} + +#[test] +fn parse_command_reads_stdin_to_eof_and_reports_its_origin() { + let output = parse_stdin("function 表示(): String { return \"日\" }".as_bytes()); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8(output.stdout).unwrap().contains("表示")); + let output = parse_stdin(b"function f(): Number {\n return )\n}"); + assert!(!output.status.success()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains(":2:10:") + ); + let output = parse_stdin(&[0xff]); + assert!(!output.status.success()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains(": invalid UTF-8 at byte 0") + ); +} + +#[test] +fn parse_command_exports_versioned_json_for_expression_and_interactive_inputs() { + for (mode, source) in [("expression", "f(\"日\")"), ("interactive", "let x = 1")] { + let source = SourceFile::new(source.as_bytes()); + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .args(["parse", "--json", "--mode", mode]) + .arg(&source.0) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let document = + frontend::ast::AstDocument::from_json(std::str::from_utf8(&output.stdout).unwrap()) + .unwrap(); + assert_eq!(document.schema, frontend::ast::AST_SCHEMA_ID); + match (mode, document.root) { + ("expression", frontend::ast::AstRoot::Expression(_)) + | ("interactive", frontend::ast::AstRoot::Interactive(_)) => {} + _ => panic!("incorrect parse mode"), + } + } + let output = Command::new(env!("CARGO_BIN_EXE_lyra")) + .args(["parse", "--mode", "unknown", "-"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..51647cf --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,11 @@ +# Lyra 验证脚本 + +| 文件 | 职责 | +| --- | --- | +| check.sh | 远端格式、CLI 进程测试、Clippy 和报告脚本回归 | +| endor_examples.py | 构建真实 lyra,执行固定 Aster 的 cases.json,保存实际输出 | +| test_endor_examples.py | 远端报告失败/超时/输出不匹配检查 | +| ci-ces-control.sh | 专用检查容器,复制报告和 Linux 二进制,清理容器 | +| README.md | 本目录入口 | + +报告不冒充整体 CI 结果,失败时保留已有诊断。无数据库或生产部署。返回 [项目入口](../README.md)。 diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100644 index 0000000..60f7ef0 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Run only in the designated remote validation environment. +set -eu +cargo fmt --all --check +cargo test --workspace --locked +cargo clippy --workspace --all-targets --locked -- -D warnings +python3 scripts/test_endor_examples.py diff --git a/scripts/ci-ces-control.sh b/scripts/ci-ces-control.sh new file mode 100644 index 0000000..62e77a0 --- /dev/null +++ b/scripts/ci-ces-control.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Remote CI only. No production deployment or resources. +set -eu +cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +release_sha=${LYRA_RELEASE_SHA:?LYRA_RELEASE_SHA is required} +case "$release_sha" in *[!0-9a-f]*|'') exit 1;; esac +test "${#release_sha}" -eq 40 +image="endorphin-lyra-cli-ci:$release_sha" +checks="endorphin-lyra-cli-checks-${GITHUB_RUN_ID:-manual}-$$" +mkdir -p ci-output +printf '# Lyra Endor examples\n\nStatus: NOT_RUN; inspect CI logs.\n' > ci-output/endor-examples.md +printf '{"status":"NOT_RUN","cases":[]}\n' > ci-output/endor-examples.json +cleanup() { docker rm -f "$checks" >/dev/null 2>&1 || true; } +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +docker build -f Dockerfile.ci -t "$image" . +status=0 +docker run --name "$checks" --init --env LYRA_RELEASE_SHA="$release_sha" "$image" sh -eu -c ' + checks=0 + sh scripts/check.sh || checks=$? + examples=0 + python3 scripts/endor_examples.py --output-dir /build/ci-output || examples=$? + test "$checks" -eq 0 + test "$examples" -eq 0 + cargo build --release --locked -p cli + cp target/release/lyra ci-output/lyra +' || status=$? +copy_status=0 +docker cp "$checks:/build/ci-output/endor-examples.md" ci-output/endor-examples.md || copy_status=$? +docker cp "$checks:/build/ci-output/endor-examples.json" ci-output/endor-examples.json || copy_status=$? +if [ "$status" -eq 0 ]; then + docker cp "$checks:/build/ci-output/lyra" ci-output/lyra || copy_status=$? +fi +if [ "$status" -ne 0 ]; then exit "$status"; fi +exit "$copy_status" diff --git a/scripts/endor_examples.py b/scripts/endor_examples.py new file mode 100644 index 0000000..8c42bc7 --- /dev/null +++ b/scripts/endor_examples.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Run Endor example files and retain actual output on the designated CI host. + +Never run locally: this builds the CLI and executes language code. +""" + +import argparse +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import shlex +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "vendor/aster/crates/compiler/examples" + + +def decoded(value): + return value.decode("utf-8", "replace") if isinstance(value, bytes) else value or "" + + +def invoke(command, timeout): + """Keep stdout, stderr and failure state even for launch errors/timeouts.""" + record = {"command": command, "exit_code": None, "stdout": "", "stderr": "", "error": None} + try: + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout) + record.update(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr) + except subprocess.TimeoutExpired as error: + record.update(stdout=decoded(error.stdout), stderr=decoded(error.stderr), + error=f"timed out after {timeout} seconds") + except OSError as error: + record["error"] = str(error) + return record + + +def load_cases(): + manifest = json.loads((EXAMPLES / "cases.json").read_text(encoding="utf-8")) + if manifest.get("format_version") != 1: + raise ValueError("unsupported example manifest version") + cases = manifest["cases"] + names, sources = set(), set() + for case in cases: + if not re.fullmatch(r"[a-z][a-z0-9_-]*", case["name"]) or case["name"] in names: + raise ValueError("invalid or duplicate example name") + source = EXAMPLES / case["source"] + if source.resolve().parent != EXAMPLES.resolve() or source.suffix != ".endor": + raise ValueError("example source must be a direct .endor file") + if case["mode"] not in ("run", "eval") or not isinstance(case["expected_stdout"], str): + raise ValueError("invalid example mode or expected output") + if case["source"] in sources: + raise ValueError("duplicate example source") + case["source_text"] = source.read_text(encoding="utf-8") + names.add(case["name"]) + sources.add(case["source"]) + if not cases or sources != {path.name for path in EXAMPLES.glob("*.endor")}: + raise ValueError("manifest must cover every .endor example exactly once") + return cases + + +def fence(text, language="text"): + # Keep source/output literal even if future examples print Markdown fences. + longest = max((len(match[0]) for match in re.finditer(r"`+", text)), default=0) + delimiter = "`" * max(3, longest + 1) + return f"{delimiter}{language}\n{text}" + ("" if text.endswith("\n") else "\n") + delimiter + + +def markdown(report): + lines = ["# Endor 示例实际运行结果", "", f"提交:`{report['commit']}`", + f"状态:**{report['status']}**", f"生成时间(UTC):{report['generated_at']}", + "", "本报告仅覆盖 Endor 示例;整个 CI/CD 的状态以工作流结果为准。"] + if report.get("error"): + lines += ["", "报告阶段错误:", fence(report["error"])] + for stage in report["preparation"]: + lines += ["", "## 准备:" + shlex.join(stage["command"]), + f"退出码:{stage['exit_code']};错误:{stage['error'] or '无'}"] + if stage["exit_code"] != 0 or stage["error"]: + lines += ["", "stdout:", fence(stage["stdout"]), "", "stderr:", fence(stage["stderr"])] + for case in report["cases"]: + lines += ["", f"## {case['name']} — {case['status']}", "", + "命令:", fence(shlex.join(case["command"]), "sh"), "", "源码:", + fence(case["source_text"], "endor"), "", "预期 stdout:", + fence(case["expected_stdout"]), "", "实际 stdout:", fence(case["stdout"]), + "", "实际 stderr:", fence(case["stderr"]), "", + f"预期退出码:0;实际退出码:{case['exit_code']}", + f"错误:{case['error'] or '无'}"] + return "\n".join(lines) + "\n" + + +def collect(report): + for case in load_cases(): + report["cases"].append({**case, "status": "NOT_RUN", "exit_code": None, + "stdout": "", "stderr": "", "error": None, + "expected_stderr": "", "expected_exit_code": 0, + "command": ["lyra", case["mode"], "vendor/aster/crates/compiler/examples/" + case["source"]]}) + build = invoke(["cargo", "build", "-p", "cli", "--locked"], timeout=600) + report["preparation"].append(build) + if build["exit_code"] != 0 or build["error"]: + report.update(status="NOT_RUN", error="CLI build failed; examples were not executed.") + return + metadata = invoke(["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], timeout=60) + report["preparation"].append(metadata) + if metadata["exit_code"] != 0 or metadata["error"]: + report.update(status="NOT_RUN", error="Cannot locate the compiled CLI.") + return + binary = Path(json.loads(metadata["stdout"])["target_directory"]) / "debug" / "lyra" + for case in report["cases"]: + command = [str(binary), *case["command"][1:]] + actual = invoke(command, timeout=30) + case.update({key: actual[key] for key in ["exit_code", "stdout", "stderr", "error"]}) + case["binary"] = str(binary) + case["status"] = "PASS" if ( + case["exit_code"] == case["expected_exit_code"] and not case["error"] + and case["stdout"] == case["expected_stdout"] + and case["stderr"] == case["expected_stderr"] + ) else "FAIL" + report["status"] = "PASS" if all(case["status"] == "PASS" for case in report["cases"]) else "FAIL" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + report = {"format_version": 1, "commit": os.environ.get("LYRA_RELEASE_SHA", "unknown"), + "generated_at": datetime.now(timezone.utc).isoformat(), "status": "NOT_RUN", + "error": None, "preparation": [], "cases": []} + try: + collect(report) + except Exception as error: + report.update(status="FAIL", error=str(error)) + # Always persist diagnostics before returning a failing CI exit code. + args.output_dir.joinpath("endor-examples.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + rendered = markdown(report) + args.output_dir.joinpath("endor-examples.md").write_text(rendered, encoding="utf-8") + print(rendered) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_endor_examples.py b/scripts/test_endor_examples.py new file mode 100644 index 0000000..1ba97f5 --- /dev/null +++ b/scripts/test_endor_examples.py @@ -0,0 +1,80 @@ +"""Remote-only regression checks for example output capture and failure reports.""" + +import contextlib +import io +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import endor_examples as examples + + +def result(stdout="", code=0): + return {"command": ["mock"], "exit_code": code, "stdout": stdout, "stderr": "", "error": None} + + +def report(): + return {"format_version": 1, "commit": "test", "generated_at": "test", "status": "NOT_RUN", + "preparation": [], "cases": [], "error": None} + + +class ExampleReportTests(unittest.TestCase): + def test_manifest_runs_every_source_and_captures_exact_output(self): + cases = examples.load_cases() + responses = [result(), result(json.dumps({"target_directory": "/tmp/ci-target"}))] + responses += [result(case["expected_stdout"]) for case in cases] + collected = report() + with patch.object(examples, "invoke", side_effect=responses) as invoke: + examples.collect(collected) + self.assertEqual(collected["status"], "PASS") + self.assertEqual(invoke.call_count, len(cases) + 2) + for actual, expected in zip(collected["cases"], cases): + self.assertEqual(actual["stdout"], expected["expected_stdout"]) + self.assertEqual(actual["status"], "PASS") + self.assertEqual(actual["binary"], "/tmp/ci-target/debug/lyra") + + def test_output_mismatch_fails_and_build_failure_never_claims_examples_ran(self): + cases = examples.load_cases() + responses = [result(), result(json.dumps({"target_directory": "/tmp/target"}))] + responses += [result("wrong\n")] + [result(case["expected_stdout"]) for case in cases[1:]] + collected = report() + with patch.object(examples, "invoke", side_effect=responses): + examples.collect(collected) + self.assertEqual(collected["status"], "FAIL") + self.assertEqual(collected["cases"][0]["stdout"], "wrong\n") + self.assertEqual(collected["cases"][0]["status"], "FAIL") + self.assertIn("wrong", examples.markdown(collected)) + collected = report() + with patch.object(examples, "invoke", return_value=result(code=1)) as invoke: + examples.collect(collected) + self.assertEqual(invoke.call_count, 1) + self.assertEqual(collected["status"], "NOT_RUN") + self.assertTrue(all(case["status"] == "NOT_RUN" for case in collected["cases"])) + + def test_timeout_keeps_partial_utf8_stdout_and_stderr(self): + failure = subprocess.TimeoutExpired(["cli"], 1, output="日".encode(), stderr=b"partial error") + with patch.object(examples.subprocess, "run", side_effect=failure): + captured = examples.invoke(["cli"], 1) + self.assertIsNone(captured["exit_code"]) + self.assertEqual(captured["stdout"], "日") + self.assertEqual(captured["stderr"], "partial error") + self.assertIn("timed out", captured["error"]) + + def test_unexpected_failure_still_writes_both_artifacts(self): + with tempfile.TemporaryDirectory() as directory: + with patch("sys.argv", ["endor_examples.py", "--output-dir", directory]), \ + patch.object(examples, "collect", side_effect=ValueError("broken manifest")), \ + contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(examples.main(), 1) + payload = json.loads(Path(directory, "endor-examples.json").read_text(encoding="utf-8")) + self.assertEqual(payload["status"], "FAIL") + self.assertEqual(payload["error"], "broken manifest") + self.assertIn("broken manifest", Path(directory, "endor-examples.md").read_text(encoding="utf-8")) + self.assertTrue(examples.fence("```\n日").startswith("````text\n")) + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..27a5052 --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,11 @@ +# Aster 引擎依赖 + +[aster](aster/README.md) 是独立 Git 子模块;父仓库 gitlink 固定具体提交,.gitmodules 保存 SSH URL。使用专用 codex-bot 的隔离 SSH 命令初始化: + +```sh +git submodule update --init --recursive +``` + +根 Cargo.toml 通过子模块路径链接库;子模块保留自己的 Cargo workspace。父仓库 Cargo.lock 负责宿主依赖图。构建宿主会自动编译所需引擎库,无需启动 Aster 服务。更新时先取得明确的已发布 Aster commit,再 checkout --detach 并提交父仓库 gitlink;不要提交指向未发布引擎提交的宿主版本。 + +CI 获取私有子模块的 bot SSH secrets 要求见 [.gitea](../.gitea/README.md)。Aster 源码和本目录 README 是本目录直接子项。返回 [项目入口](../README.md)。 diff --git a/vendor/aster b/vendor/aster new file mode 160000 index 0000000..55475a9 --- /dev/null +++ b/vendor/aster @@ -0,0 +1 @@ +Subproject commit 55475a9d4b3948a569bf76c84041ad9cea0ec6ea