feat: establish Lyra CLI with pinned Aster engine and remote CI
Verify Lyra CLI / verify (push) Failing after 1s
Verify Lyra CLI / verify (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
target
|
||||
data
|
||||
work
|
||||
outputs
|
||||
ci-output
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
**/.git
|
||||
**/target
|
||||
**/AGENTS.md
|
||||
**/work
|
||||
**/ci-output
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
/target/
|
||||
/data/
|
||||
/work/
|
||||
/outputs/
|
||||
/ci-output/
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
__pycache__/
|
||||
AGENTS.md
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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),不自动查询运行状态。
|
||||
@@ -0,0 +1,3 @@
|
||||
# Lyra 源码
|
||||
|
||||
[cli](cli/README.md) 是本工作区唯一包,Cargo 包名 cli,可执行文件 lyra。通过根工作区依赖固定 Aster 子模块。返回 [项目入口](../README.md)。
|
||||
@@ -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
|
||||
@@ -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)。
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLI 源码
|
||||
|
||||
main.rs 处理命令、对象引导和 parse 输出;execute.rs 处理 run/eval、入口查找、结果格式、traceback 和候选命名空间清理。二进制名 lyra。返回 [包入口](../README.md)。
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLI 进程检查
|
||||
|
||||
execute.rs 检查固定 Aster 子模块中的五个真实 Endor 文件、stdin、入口选择、错误阶段和退出码;parse.rs 检查来源、UTF-8、AST/JSON 和非法输入。仅远端执行,不在本地运行。返回 [包入口](../README.md)。
|
||||
@@ -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:"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Vendored
+11
@@ -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)。
|
||||
+1
Submodule vendor/aster added at 55475a9d4b
Reference in New Issue
Block a user