diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..66aa68b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Summary + +Describe the change in 2-5 bullets. + +## Validation + +List the commands, checks, or CI evidence that validate this PR. + +## Notes + +Add any context that helps reviewers focus on the important details. diff --git a/.github/workflows/ci-pr-checks.yml b/.github/workflows/ci-pr-checks.yml new file mode 100644 index 0000000..a422a17 --- /dev/null +++ b/.github/workflows/ci-pr-checks.yml @@ -0,0 +1,72 @@ +name: "CI - PR Checks" + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + title: + name: PR title + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check PR title + uses: zrr1999/zendev-actions/validate-title@9013ad848b32d287d0258fdbc9f51e350b8eca33 + with: + text: ${{ github.event.pull_request.title }} + + body: + name: PR body + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check PR body headings + env: + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: | + python - <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + def extract_h2_headings(text: str) -> list[str]: + in_fence = False + headings = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + if re.match(r"^##\s+\S", stripped): + headings.append(re.sub(r"^##\s+", "", stripped)) + return headings + + payload = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + body = (payload.get("pull_request") or {}).get("body") or "" + template = Path(".github/pull_request_template.md").read_text(encoding="utf-8") + + expected = extract_h2_headings(template) + actual = extract_h2_headings(body) + + if actual != expected: + print("PR body headings do not match the repository template.", file=sys.stderr) + print(f"Expected headings: {expected}", file=sys.stderr) + print(f"Actual headings: {actual}", file=sys.stderr) + raise SystemExit(1) + + print("PR body headings match the repository template.") + PY diff --git a/.github/workflows/ci-static-checks.yml b/.github/workflows/ci-static-checks.yml new file mode 100644 index 0000000..299a51f --- /dev/null +++ b/.github/workflows/ci-static-checks.yml @@ -0,0 +1,27 @@ +name: "CI - Static Checks" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-static-${{ github.ref }} + cancel-in-progress: true + +jobs: + prek: + name: prek + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + components: clippy, rustfmt + + - uses: j178/prek-action@v2 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..5a379eb --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,93 @@ +name: "CI - Tests" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + host: + name: host + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: host + + - name: Build host crate + run: cargo build --manifest-path host/Cargo.toml + + - name: Run host tests + run: cargo test --manifest-path host/Cargo.toml + + spore: + name: spore + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/checkout@v6 + with: + repository: spore-lang/spore + ref: ef627c785e355a70551af5e4f4d85230996ce8d9 + path: _spore + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: _spore + + - name: Build spore compiler + run: cargo build --release --manifest-path _spore/Cargo.toml --bin spore + + - name: Format Spore files + run: | + paths=(examples platform) + if [ -d tests ]; then + paths+=(tests) + fi + find "${paths[@]}" -name '*.sp' -type f | sort | xargs _spore/target/release/spore format + git diff --exit-code -- "${paths[@]}" + + - name: Check Spore files + run: | + paths=(examples platform) + if [ -d tests ]; then + paths+=(tests) + fi + find "${paths[@]}" -name '*.sp' -type f | sort | xargs -n 1 _spore/target/release/spore check + + - name: Build Spore files + run: | + paths=(examples platform) + if [ -d tests ]; then + paths+=(tests) + fi + find "${paths[@]}" -name '*.sp' -type f | sort | xargs -n 1 _spore/target/release/spore build + + - name: Run hello example + run: _spore/target/release/spore run examples/hello.sp + + - name: Run Spore spec tests + run: | + if [ ! -d tests ]; then + echo "No Spore spec tests yet; skipping spore test." + exit 0 + fi + find tests -name '*.sp' -type f | sort | xargs -r -n 1 _spore/target/release/spore test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c2f226e..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - host-check: - name: Host (Rust) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - name: Format - run: cargo fmt --manifest-path host/Cargo.toml -- --check - - name: Clippy - run: cargo clippy --manifest-path host/Cargo.toml --all-targets -- -D warnings - - name: Test - run: cargo test --manifest-path host/Cargo.toml diff --git a/README.md b/README.md index af6380b..bfd16e7 100644 --- a/README.md +++ b/README.md @@ -23,58 +23,73 @@ Operating System | `Stdout` | `uses [Console]` | Standard output | | `Stdin` | `uses [Console]` | Standard input | | `File` | `uses [FileRead]` or `uses [FileWrite]` | File system operations | -| `Dir` | `uses [FileRead]` | Directory listing | +| `Dir` | `uses [FileRead]` or `uses [FileWrite]` | Directory listing and creation | | `Env` | `uses [Env]` | Environment variables | | `Cmd` | `uses [Spawn]` | Process execution | ## Quick Start ```spore -// hello.sp -uses [Console] - -fn main() -> Unit uses [Console] { - println("Hello from basic-cli!") +/// A simple "Hello World" using the basic-cli platform. +fn main() -> () uses [Console] { + println("Hello from Spore basic-cli!") } ``` ```bash -spore run hello.sp --platform basic-cli +spore check examples/hello.sp +spore build examples/hello.sp +spore run examples/hello.sp ``` +This repository currently keeps `examples/` limited to files that PR CI validates today. +It also intentionally keeps the current loose `.sp` file layout for now; the manifest/package-layout migration is deferred to a follow-up change. + ## Project Structure ``` basic-cli/ -├── platform/ # Spore API modules (.sp files) +├── platform/ # Spore API modules (.sp files), checked and built in CI │ ├── Stdout.sp # Standard output operations │ ├── Stdin.sp # Standard input operations │ ├── File.sp # File read/write operations │ ├── Dir.sp # Directory operations │ ├── Env.sp # Environment variable access │ ├── Cmd.sp # Process execution -│ └── main.sp # Platform entry point ├── host/ # Rust host implementation │ ├── Cargo.toml │ └── src/ │ └── lib.rs # Foreign function implementations -├── examples/ # Example Spore programs using this platform -└── tests/ # Integration tests +└── examples/ # Canonical examples that format/check/build in CI ``` +## Tutorial Contract + +- `examples/` is for truthful, CI-validated examples only. +- `platform/` is the API surface for the platform modules themselves. +- Only add `tests/` when the repo has real Spore-side regression coverage worth running with `spore test`. + +If you want to add a new tutorial/example, treat this as the bar: + +1. keep it self-contained; +2. make sure it passes `spore format --check`, `spore check`, and `spore build`; +3. only then promote it into `examples/` and mention it in this README. + ## Design Philosophy -Following Spore's [SEP-0005 (Effect System)](https://github.com/spore-lang/spore-evolution): +Following Spore's [SEP-0003 (Effect Capability System)](https://github.com/spore-lang/spore-evolution/blob/main/seps/SEP-0003-effect-capability-system.md): - **Capability-gated**: Every I/O function declares its required capabilities via `uses [Cap]` -- **Cost-annotated**: Platform functions carry `cost ≤ N` budgets where meaningful -- **Error-typed**: Functions declare error sets via `! [ErrorType]` +- **Cost-annotated**: Platform functions can carry `cost [c, a, i, p]` budgets where meaningful +- **Error-typed**: Functions declare error sets via `! ErrorType` - **Pure by default**: The platform boundary is the only place side effects occur ## Status 🚧 **Early development** — API is unstable and subject to change. +At the moment, the validated example is `examples/hello.sp`. More ambitious host-backed demos such as environment/file workflows should stay out of `examples/` until the current platform import/runtime architecture supports them honestly. + ## License MIT — see [LICENSE](LICENSE). diff --git a/examples/env-reader.sp b/examples/env-reader.sp deleted file mode 100644 index 63b1884..0000000 --- a/examples/env-reader.sp +++ /dev/null @@ -1,11 +0,0 @@ -/// Environment variable reader. -/// -/// Demonstrates Env and Console capabilities. -uses [Env, Console] - -fn main() -> Unit uses [Env, Console] { - match env_get("HOME") { - Some(home) => println("Home directory: " + home), - None => println("HOME not set"), - } -} diff --git a/examples/file-copy.sp b/examples/file-copy.sp deleted file mode 100644 index 366ce3d..0000000 --- a/examples/file-copy.sp +++ /dev/null @@ -1,10 +0,0 @@ -/// File copy example — reads a file and writes it to another path. -/// -/// Demonstrates FileRead and FileWrite capabilities. -uses [FileRead, FileWrite, Console] - -fn main() -> Unit uses [FileRead, FileWrite, Console] ! [IoError] { - let content = file_read("input.txt") - file_write("output.txt", content) - println("File copied successfully!") -} diff --git a/examples/hello.sp b/examples/hello.sp index e8a4d41..9cdabdc 100644 --- a/examples/hello.sp +++ b/examples/hello.sp @@ -1,8 +1,4 @@ /// A simple "Hello World" using the basic-cli platform. /// -/// Run with: spore run examples/hello.sp --platform basic-cli -uses [Console] - -fn main() -> Unit uses [Console] { - println("Hello from Spore basic-cli!") -} +/// Run with: spore run examples/hello.sp +fn main() -> () uses [Console] { println("Hello from Spore basic-cli!") } diff --git a/platform/Cmd.sp b/platform/Cmd.sp index cd1e351..1e9770f 100644 --- a/platform/Cmd.sp +++ b/platform/Cmd.sp @@ -4,11 +4,7 @@ /// Run a command and capture its stdout as a string. /// Returns the full stdout output on success. -foreign fn process_run(cmd: String, args: List[String]) -> String - uses [Spawn] - ! [ExecError] +foreign fn process_run(cmd: String, args: List[String]) -> String ! ExecError uses [Spawn] /// Run a command and return its exit code. -foreign fn process_run_status(cmd: String, args: List[String]) -> Int - uses [Spawn] - ! [ExecError] +foreign fn process_run_status(cmd: String, args: List[String]) -> Int ! ExecError uses [Spawn] diff --git a/platform/Dir.sp b/platform/Dir.sp index cce3b33..e279873 100644 --- a/platform/Dir.sp +++ b/platform/Dir.sp @@ -1,11 +1,7 @@ /// basic-cli platform — Directory operations /// List entries in a directory, returning their names. -foreign fn dir_list(path: String) -> List[String] - uses [FileRead] - ! [IoError] +foreign fn dir_list(path: String) -> List[String] ! IoError uses [FileRead] /// Create a directory (and any missing parents). -foreign fn dir_mkdir(path: String) -> Unit - uses [FileWrite] - ! [IoError] +foreign fn dir_mkdir(path: String) -> Unit ! IoError uses [FileWrite] diff --git a/platform/Env.sp b/platform/Env.sp index d964382..22a6bb2 100644 --- a/platform/Env.sp +++ b/platform/Env.sp @@ -1,9 +1,7 @@ /// basic-cli platform — Environment variable access /// Get the value of an environment variable, or None if not set. -foreign fn env_get(key: String) -> Option[String] - uses [Env] +foreign fn env_get(key: String) -> Option[String] uses [Env] /// Set an environment variable. -foreign fn env_set(key: String, value: String) -> Unit - uses [Env] +foreign fn env_set(key: String, value: String) -> Unit uses [Env] diff --git a/platform/File.sp b/platform/File.sp index ac027d9..0d40000 100644 --- a/platform/File.sp +++ b/platform/File.sp @@ -3,20 +3,13 @@ /// Provides capability-gated file read and write operations. /// Read the entire contents of a file as a string. -foreign fn file_read(path: String) -> String - uses [FileRead] - ! [IoError] +foreign fn file_read(path: String) -> String ! IoError uses [FileRead] /// Write content to a file, creating or overwriting it. -foreign fn file_write(path: String, content: String) -> Unit - uses [FileWrite] - ! [IoError] +foreign fn file_write(path: String, content: String) -> Unit ! IoError uses [FileWrite] /// Check whether a file or directory exists at the given path. -foreign fn file_exists(path: String) -> Bool - uses [FileRead] +foreign fn file_exists(path: String) -> Bool uses [FileRead] /// Get file metadata (size, modified time, etc.) as a string representation. -foreign fn file_stat(path: String) -> String - uses [FileRead] - ! [IoError] +foreign fn file_stat(path: String) -> String ! IoError uses [FileRead] diff --git a/platform/Stdin.sp b/platform/Stdin.sp index fd4aa7d..7e86f64 100644 --- a/platform/Stdin.sp +++ b/platform/Stdin.sp @@ -1,5 +1,4 @@ /// basic-cli platform — Standard input operations /// Read a line from stdin (blocks until newline). -foreign fn read_line() -> String - uses [Console] +foreign fn read_line() -> String uses [Console] diff --git a/platform/Stdout.sp b/platform/Stdout.sp index 96ffe7d..3c1b05b 100644 --- a/platform/Stdout.sp +++ b/platform/Stdout.sp @@ -3,17 +3,13 @@ /// Provides capability-gated access to stdout/stderr. /// Print a string to stdout without trailing newline. -foreign fn print(s: String) -> Unit - uses [Console] +foreign fn print(s: String) -> Unit uses [Console] /// Print a string to stdout with trailing newline. -foreign fn println(s: String) -> Unit - uses [Console] +foreign fn println(s: String) -> Unit uses [Console] /// Print a formatted string to stderr. -foreign fn eprint(s: String) -> Unit - uses [Console] +foreign fn eprint(s: String) -> Unit uses [Console] /// Print a formatted string to stderr with trailing newline. -foreign fn eprintln(s: String) -> Unit - uses [Console] +foreign fn eprintln(s: String) -> Unit uses [Console] diff --git a/platform/main.sp b/platform/main.sp deleted file mode 100644 index b74c5a7..0000000 --- a/platform/main.sp +++ /dev/null @@ -1,12 +0,0 @@ -/// basic-cli platform entry point -/// -/// Re-exports all platform modules for convenient access. -/// Usage: `uses basic-cli` in your Spore application. - -// Platform modules -pub use Stdout.{print, println, eprint, eprintln} -pub use Stdin.{read_line} -pub use File.{file_read, file_write, file_exists, file_stat} -pub use Dir.{dir_list, dir_mkdir} -pub use Env.{env_get, env_set} -pub use Cmd.{process_run, process_run_status} diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..19c6d5f --- /dev/null +++ b/prek.toml @@ -0,0 +1,58 @@ +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v6.0.0" +hooks = [ + { id = "check-added-large-files" }, + { id = "check-case-conflict" }, + { id = "check-executables-have-shebangs" }, + { id = "check-yaml" }, + { id = "check-json" }, + { id = "check-merge-conflict" }, + { id = "destroyed-symlinks" }, + { id = "end-of-file-fixer" }, + { id = "mixed-line-ending" }, + { id = "trailing-whitespace" }, +] + +[[repos]] +repo = "https://github.com/tombi-toml/tombi-pre-commit" +rev = "v0.7.27" +hooks = [ + { id = "tombi-format", exclude = "^Cargo\\.lock$" }, + { id = "tombi-lint" }, +] + +[[repos]] +repo = "https://github.com/crate-ci/typos" +rev = "v1.33.1" +hooks = [ + { id = "typos", args = ["--force-exclude"] }, +] + +[[repos]] +repo = "https://github.com/zrr1999/zendev" +rev = "e2a5243899467d0df1cb1ab0ddcb18f3ef33d97b" +hooks = [ + { id = "zendev-commit-msg" }, +] + +[[repos]] +repo = "local" +hooks = [ + { + id = "cargo-fmt", + name = "cargo-fmt", + entry = "cargo fmt --manifest-path host/Cargo.toml --", + language = "system", + types = ["rust"], + pass_filenames = false, + }, + { + id = "cargo-clippy", + name = "cargo-clippy", + entry = "cargo clippy --manifest-path host/Cargo.toml --all-targets -- -D warnings", + language = "system", + types = ["rust"], + pass_filenames = false, + }, +]