feat: establish Lyra CLI with pinned Aster engine and remote CI
Verify Lyra CLI / verify (push) Failing after 1s

This commit is contained in:
2026-10-20 01:10:02 +08:00
commit af5c5f7a25
27 changed files with 1164 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
target
data
work
outputs
ci-output
.env
*.db
*.db-shm
*.db-wal
**/.git
**/target
**/AGENTS.md
**/work
**/ci-output
+22
View File
@@ -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.
+5
View File
@@ -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.
+68
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
/target/
/data/
/work/
/outputs/
/ci-output/
.env
*.db
*.db-shm
*.db-wal
__pycache__/
AGENTS.md
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/aster"]
path = vendor/aster
url = ssh://git@gitea.entroia.com:22222/Zayden-Jung/endorphin-aster-vm.git
Generated
+163
View File
@@ -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"
+17
View File
@@ -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"
+11
View File
@@ -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"]
+21
View File
@@ -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.
+27
View File
@@ -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),不自动查询运行状态。
+3
View File
@@ -0,0 +1,3 @@
# Lyra 源码
[cli](cli/README.md) 是本工作区唯一包,Cargo 包名 cli,可执行文件 lyra。通过根工作区依赖固定 Aster 子模块。返回 [项目入口](../README.md)。
+18
View File
@@ -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
+11
View File
@@ -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)。
+3
View File
@@ -0,0 +1,3 @@
# CLI 源码
main.rs 处理命令、对象引导和 parse 输出;execute.rs 处理 run/eval、入口查找、结果格式、traceback 和候选命名空间清理。二进制名 lyra。返回 [包入口](../README.md)。
+105
View File
@@ -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<dyn Error>> {
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<dyn Error>> {
let source = read(path)?;
let output = execute(&source, ParseMode::Expression, None)?;
println!("{output}");
Ok(())
}
fn read(path: &str) -> Result<SourceUnit, Box<dyn Error>> {
Ok(if path == "-" {
EndorReader::from_stream("<stdin>", std::io::stdin().lock())?
} else {
EndorReader::from_file(path)?
})
}
fn execute(
source: &SourceUnit,
mode: ParseMode,
entry: Option<&str>,
) -> Result<String, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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::<dyn Error>::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<String, Box<dyn Error>> {
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)?,
})
}
+92
View File
@@ -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] <file|-> | eval <file|-> | parse [--mode module|expression|interactive] [--json] <file|-> | help]";
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("lyra: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().skip(1).collect();
match args
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.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<dyn Error>> {
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("<stdin>", 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(())
}
+3
View File
@@ -0,0 +1,3 @@
# CLI 进程检查
execute.rs 检查固定 Aster 子模块中的五个真实 Endor 文件、stdin、入口选择、错误阶段和退出码;parse.rs 检查来源、UTF-8、AST/JSON 和非法输入。仅远端执行,不在本地运行。返回 [包入口](../README.md)。
+136
View File
@@ -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}",
"<stdin>: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 (<stdin>: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:"));
}
}
+139
View File
@@ -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("<stdin>:2:10:")
);
let output = parse_stdin(&[0xff]);
assert!(!output.status.success());
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("<stdin>: 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());
}
+11
View File
@@ -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)。
+7
View File
@@ -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
+36
View File
@@ -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"
+145
View File
@@ -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())
+80
View File
@@ -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()
+11
View File
@@ -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)。
Vendored Submodule
+1
Submodule vendor/aster added at 55475a9d4b