From af5c5f7a25c236898591e5088cfe5a75179b9e36 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 20 Oct 2026 01:10:02 +0800 Subject: [PATCH] feat: establish Lyra CLI with pinned Aster engine and remote CI --- .dockerignore | 15 +++ .gitea/README.md | 22 +++++ .gitea/workflows/README.md | 5 + .gitea/workflows/ci.yml | 68 ++++++++++++++ .gitignore | 11 +++ .gitmodules | 3 + Cargo.lock | 163 +++++++++++++++++++++++++++++++++ Cargo.toml | 17 ++++ Dockerfile.ci | 11 +++ LICENSE | 21 +++++ README.md | 27 ++++++ crates/README.md | 3 + crates/cli/Cargo.toml | 18 ++++ crates/cli/README.md | 11 +++ crates/cli/src/README.md | 3 + crates/cli/src/execute.rs | 105 +++++++++++++++++++++ crates/cli/src/main.rs | 92 +++++++++++++++++++ crates/cli/tests/README.md | 3 + crates/cli/tests/execute.rs | 136 +++++++++++++++++++++++++++ crates/cli/tests/parse.rs | 139 ++++++++++++++++++++++++++++ scripts/README.md | 11 +++ scripts/check.sh | 7 ++ scripts/ci-ces-control.sh | 36 ++++++++ scripts/endor_examples.py | 145 +++++++++++++++++++++++++++++ scripts/test_endor_examples.py | 80 ++++++++++++++++ vendor/README.md | 11 +++ vendor/aster | 1 + 27 files changed, 1164 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/README.md create mode 100644 .gitea/workflows/README.md create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile.ci create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crates/README.md create mode 100644 crates/cli/Cargo.toml create mode 100644 crates/cli/README.md create mode 100644 crates/cli/src/README.md create mode 100644 crates/cli/src/execute.rs create mode 100644 crates/cli/src/main.rs create mode 100644 crates/cli/tests/README.md create mode 100644 crates/cli/tests/execute.rs create mode 100644 crates/cli/tests/parse.rs create mode 100644 scripts/README.md create mode 100644 scripts/check.sh create mode 100644 scripts/ci-ces-control.sh create mode 100644 scripts/endor_examples.py create mode 100644 scripts/test_endor_examples.py create mode 100644 vendor/README.md create mode 160000 vendor/aster 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