diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6c92265..1981098 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -19,13 +19,17 @@ The following files in `.machine_readable/6a2/` contain structured project metad ## Language Policy (Hyperpolymath Standard) -> **Policy refresh 2026-05-28**: This repo has two products. (1) The **Oikos DSL** at the repo root is **Rust** (compiler crates `oikos-syntax`, `oikos-parser`, `oikos-desugar`, `oikos-check`); (2) **OikosBot** at `bot-integration-affine/` is **AffineScript** as of the 2026-05-28 legacy-shutoff (oikos#41) — the previous ReScript implementation at `bot-integration/` was removed in the same PR. Write no new ReScript or TypeScript here. MPL-1.0 / MPL-1.0-or-later are banned; rewrite to MPL-2.0 wherever encountered (DR-010 supersedes DR-002). +> **Disambiguation (READ FIRST — see [`DISAMBIGUATION.adoc`](../DISAMBIGUATION.adoc)):** this repo is **OikosBot** and **only** OikosBot. Do not conflate it with two separate, similarly-named projects: +> - **oikos** — the stock-flow-consistent economics *DSL* (crates `oikos-syntax`/`oikos-parser`/`oikos-desugar`/`oikos-check`). It lives in its **own** repo `hyperpolymath/oikos-economics-accounting-dsl`, **not here**. Do **not** add `oikos-*` DSL crates to this repo. +> - **sustainabot** — a *reserved member slot* of `hyperpolymath/gitbot-fleet` (`bots/sustainabot/`). **Not here.** A misfiled copy of OikosBot once lived there; it was moved into this repo and renamed `sustainabot-*` → `oikosbot-*`. Do **not** reintroduce the `sustainabot` name here (the sole exception is the legacy `crates/oikosbot-fleet` bridge, which still targets the fleet's `BotId::Sustainabot`). +> +> **Policy refresh 2026-06-19**: This repo has two surfaces, both OikosBot. (1) The **Rust analysis workspace** at the repo root — crates `oikosbot-*` (`oikosbot-cli`/`-analysis`/`-metrics`/`-sarif`/`-eclexia`, plus the optional, workspace-excluded `oikosbot-fleet` bridge). The default `cargo` workspace builds **standalone** with no dependency on gitbot-fleet. (2) **OikosBot's webhook bot** at `bot-integration-affine/` is **AffineScript** as of the 2026-05-28 legacy-shutoff (oikos#41) — the previous ReScript implementation at `bot-integration/` was removed in the same PR. Write no new ReScript or TypeScript here. MPL-1.0 / MPL-1.0-or-later are banned; rewrite to MPL-2.0 wherever encountered (DR-010 supersedes DR-002). ### ALLOWED Languages & Tools | Language/Tool | Use Case | Notes | |---------------|----------|-------| -| **Rust** | Oikos DSL compiler crates | Repo root; performance-critical | +| **Rust** | OikosBot analysis workspace (`crates/oikosbot-*`) | Repo root; performance-critical. NOT the `oikos-*` DSL (separate repo). | | **AffineScript** (`.affine`) | OikosBot (`bot-integration-affine/`) | Affine types, dependent types, row polymorphism, extensible effects | | **Haskell** | OikosBot analyser backend (`analyzers/`) | Existing surface; pure analysis | | **Deno** | Runtime & package management | Replaces Node/npm/bun | diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d15c975..30193c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: actions: patterns: - "*" + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + groups: + cargo: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d924f..8ee5866 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,35 @@ jobs: - name: Test Haskell analyzer working-directory: analyzers/code-haskell run: cabal test all + rust: + name: Rust Workspace + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Cache cargo + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + # Uses the runner's preinstalled stable toolchain (no third-party action). + # NB: the default workspace deliberately EXCLUDES crates/oikosbot-fleet so + # OikosBot builds with no dependency on hyperpolymath/gitbot-fleet. + - name: Format check + run: cargo fmt --check + - name: Build (standalone workspace) + run: cargo build --workspace --locked + - name: Test (standalone workspace) + run: cargo test --workspace --locked + - name: Clippy (informational) + run: cargo clippy --workspace + continue-on-error: true affine: name: AffineScript Scaffold runs-on: ubuntu-latest diff --git a/.machine_readable/6a2/ECOSYSTEM.a2ml b/.machine_readable/6a2/ECOSYSTEM.a2ml index 7788283..b8ec8da 100644 --- a/.machine_readable/6a2/ECOSYSTEM.a2ml +++ b/.machine_readable/6a2/ECOSYSTEM.a2ml @@ -1,20 +1,28 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) # -# ECOSYSTEM.a2ml — Oikos ecosystem position +# ECOSYSTEM.a2ml — OikosBot ecosystem position [metadata] -version = "1.0" -last-updated = "2026-04-11" +version = "1.1" +last-updated = "2026-06-19" [project] -name = "Oikos" -purpose = "" -role = "" +name = "OikosBot" +purpose = "An ecological & economic code-analysis GitHub/GitLab App that adds an eco/econ review lens to pull requests, complementing (not replacing) Dependabot/CodeQL/linters." +role = "Standalone analysis product. Optionally plugs into the gitbot fleet via the excluded crates/oikosbot-fleet bridge." [position-in-ecosystem] -category = "" +category = "code-analysis-app" + +# Disambiguation — three similarly-named, SEPARATE projects. See DISAMBIGUATION.adoc. +[disambiguation] +this-project = "OikosBot (this repo, hyperpolymath/oikosbot) — crates oikosbot-*" +not-the-dsl = "oikos — the stock-flow-consistent economics DSL — lives in hyperpolymath/oikos-economics-accounting-dsl (crates oikos-*). NOT here." +not-the-fleet-slot = "sustainabot — a reserved member slot of hyperpolymath/gitbot-fleet (bots/sustainabot/), to be prototyped later. NOT here. A misfiled copy of OikosBot once lived there and was moved into this repo." [related-projects] projects = [ - # No related projects recorded + { name = "oikos-economics-accounting-dsl", repo = "hyperpolymath/oikos-economics-accounting-dsl", relation = "name-sibling", note = "Separate DSL; shares the οἶκος root only." }, + { name = "gitbot-fleet", repo = "hyperpolymath/gitbot-fleet", relation = "optional-consumer", note = "OikosBot can run as a fleet member via the excluded crates/oikosbot-fleet bridge; the bots/sustainabot/ slot is reserved and separate." }, + { name = "affinescript", repo = "hyperpolymath/affinescript", relation = "upstream-toolchain", note = "Provides the AffineScript compiler/stdlib for bot-integration-affine/." } ] diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 59453eb..df9c183 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -3,16 +3,16 @@ # # STATE.a2ml — Oikos project state [metadata] -project = "oikos" +project = "oikosbot" version = "0.1.0-dev" -last-updated = "2026-05-28" +last-updated = "2026-06-19" status = "active" -session = "2026-05-28 Phase 5 scaffold + legacy shutoff; upstream RSR rewires landed in affinescript" +session = "2026-06-19 extract Rust analysis workspace from gitbot-fleet/bots/sustainabot → crates/oikosbot-* (renamed, decoupled, standalone, 35 tests green); add DISAMBIGUATION.adoc" [project-context] -name = "Oikos" -purpose = "Two products: (1) the Oikos DSL — typed compiler for stock-flow-consistent macroeconomic models; (2) OikosBot — code-analysis GitHub App. See docs/disambiguation/oikos-dsl-vs-oikosbot.adoc." -completion-percentage = 30 +name = "OikosBot" +purpose = "OikosBot — an ecological & economic code-analysis GitHub/GitLab App. NB: the similarly-named 'oikos' DSL (separate repo hyperpolymath/oikos-economics-accounting-dsl) and the 'sustainabot' gitbot-fleet slot are SEPARATE projects — see DISAMBIGUATION.adoc." +completion-percentage = 40 [position] phase = "implementation" # design | implementation | testing | maintenance | archived @@ -22,6 +22,7 @@ maturity = "alpha" # experimental | alpha | beta | production | lts milestones = [ { id = "phase-5-scaffold", status = "landed", pr = 35, summary = "OikosBot AffineScript scaffold at bot-integration-affine/" }, { id = "legacy-shutoff", status = "landed", pr = 41, summary = "Clean shutoff of ReScript bot-integration/ (-33,061 lines)" }, + { id = "crates-extraction", status = "landed", summary = "Extract Rust analysis workspace from gitbot-fleet/bots/sustainabot; rename sustainabot-* → oikosbot-*; decouple from fleet (oikosbot-fleet bridge excluded from default workspace); 35 tests green" }, { id = "stdlib-json-v03", status = "landed-upstream", repo = "affinescript", pr = 421, summary = "stdlib/json.affine RSR rewire to hpm-json-rsr Zig FFI — unblocks OikosBot webhook payload extraction" }, { id = "stdlib-http-server", status = "landed-upstream", repo = "affinescript", pr = 425, summary = "stdlib/Http.affine RSR rewire — surfaces hpm-http-rsr Zig FFI server externs — unblocks OikosBot accept loop" }, { id = "bot-affine-implementation", status = "pending", summary = "Implement webhook handler + HMAC verification + dispatch in bot-integration-affine/ now that both stdlib gates are closed" }, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a32d215..8430cbd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,14 +9,19 @@ Copyright (c) Jonathan D.A. Jewell > **Note — which product is this?** > This document describes **OikosBot**, the code-analysis GitHub/GitLab App. -> Its AffineScript port lives in -> [`bot-integration-affine/`](bot-integration-affine/) (in-flight; the +> Its Rust analysis workspace is `crates/oikosbot-*`; its AffineScript port lives +> in [`bot-integration-affine/`](bot-integration-affine/) (in-flight; the > previous ReScript codebase at `bot-integration/` was retired on > 2026-05-28 — see git history for the prior implementation). -> The DSL of the same root name (a typed compiler for stock-flow consistent -> macroeconomic models) now lives in -> [`hyperpolymath/oikos-economics-accounting-dsl`](https://github.com/hyperpolymath/oikos-economics-accounting-dsl). -> They share the οἶκος root but are otherwise unrelated. +> Two similarly-named-but-**separate** projects are easy to confuse with this one +> (full breakdown in [`DISAMBIGUATION.adoc`](DISAMBIGUATION.adoc)): +> the **oikos** DSL (a typed compiler for stock-flow consistent macroeconomic +> models) now lives in +> [`hyperpolymath/oikos-economics-accounting-dsl`](https://github.com/hyperpolymath/oikos-economics-accounting-dsl); +> and **sustainabot** is a reserved member slot of +> [`hyperpolymath/gitbot-fleet`](https://github.com/hyperpolymath/gitbot-fleet) +> (`bots/sustainabot/`), not OikosBot. They share the οἶκος root but are +> otherwise unrelated. ## Vision diff --git a/CHANGELOG.md b/CHANGELOG.md index 221d75b..54c6b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- feat(crates): extract the Rust analysis workspace from `gitbot-fleet/bots/sustainabot/` into this repo, renamed `sustainabot-*` → `oikosbot-*` (`oikosbot-cli`/`-analysis`/`-metrics`/`-sarif`/`-eclexia`, plus the optional `oikosbot-fleet` bridge). Adds `policies/`, `fuzz/`, `examples/`, `containers/`, and `QUICKSTART.md`. Builds and tests green (35 tests). +- docs: `DISAMBIGUATION.adoc` — canonical breakdown of **oikos** (the DSL) vs **OikosBot** (this App) vs **sustainabot** (a reserved gitbot-fleet slot), with guardrails to prevent the misfiling recurring. +- ci(rust): add a `rust` job (fmt-check + build + test, informational clippy) to `ci.yml` and a `cargo` dependabot ecosystem. New `just rust-build` / `rust-test` targets. +- feat(bot): missing AffineScript modules `GitHubAPI`, `GitHubApp`, and the TEA runtime (`tea/Cmd`, `tea/Runtime`, `tea/Sub`) added to `bot-integration-affine/src/` from the former sustainabot tree. - feat(bot): `bot-integration-affine/` Phase 5 AffineScript scaffold (#35) — initial AS port of OikosBot; cross-module type-check, JSON payload extraction, and HTTP-server accept loop are gated on upstream `affinescript` stdlib work (Json v0.3 RSR rewire `affinescript#421` + Http server FFI `affinescript#425`). - feat: Oikos Bot v0.1.0-beta - TEA architecture with typed HTTP routing - feat: add GitHub App manifest for developer programme registration @@ -39,6 +43,7 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- chore(decouple): sever OikosBot's dependency on `gitbot-fleet`. The default `cargo` workspace **excludes** `crates/oikosbot-fleet` (the only fleet-aware crate), and the optional `panic-attacker` / `eclexia` path dependencies that escaped the repo were neutralised to no-op feature seams — OikosBot now builds from a clean checkout with no sibling repos present. - chore(repo): split OikosBot into its own repository separate from the Oikos economics accounting DSL. - chore(bot-integration): clean shutoff of the legacy ReScript `bot-integration/` (#41) — 208 files / -33,061 lines: removes `bot-integration/`, `containers/`, `.github/workflows/oikos.yml`, the `rescript:` job from `ci.yml`, the npm/bot-integration dependabot entry, and `.gitmodules`. README / ARCHITECTURE / ROADMAP / DEPLOY / disambiguation docs repointed at `bot-integration-affine/`. No production blast radius (`.github/app.yml` URLs were `*.example.com` placeholders). - chore(license): align stale SPDX headers + `Cargo.toml` manifest with `MPL-2.0` (#36) — completes the 2026-05-22 EUPL → MPL migration that had left 45 file headers + the manifest at `EUPL-1.2`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5d853c1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,763 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e34525d5bbbd55da2bb745d34b36121baac88d07619a9a09cfcf4a6c0832785" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a20016a20a3da95bef50ec7238dbd09baeef4311dcdd38ec15aba69812fb61" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "oikosbot-analysis" +version = "0.1.0" +dependencies = [ + "anyhow", + "oikosbot-metrics", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "tracing", + "tree-sitter", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "oikosbot-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "oikosbot-analysis", + "oikosbot-eclexia", + "oikosbot-metrics", + "oikosbot-sarif", + "serde_json", + "tracing", + "tracing-subscriber", + "walkdir", +] + +[[package]] +name = "oikosbot-eclexia" +version = "0.1.0" +dependencies = [ + "anyhow", + "oikosbot-metrics", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "oikosbot-metrics" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "oikosbot-sarif" +version = "0.1.0" +dependencies = [ + "oikosbot-metrics", + "serde", + "serde_json", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae62f7eae5eb549c71b76658648b72cc6111f2d87d24a1e31fa907f4943e3ce" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9bd9893 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[workspace] +resolver = "2" +members = [ + "crates/oikosbot-cli", + "crates/oikosbot-analysis", + "crates/oikosbot-metrics", + "crates/oikosbot-sarif", + "crates/oikosbot-eclexia", +] +# `oikosbot-fleet` is the OPTIONAL gitbot-fleet bridge. It is intentionally +# excluded from the default workspace so OikosBot builds standalone with no +# dependency on `hyperpolymath/gitbot-fleet`. Build it explicitly (with the +# fleet checked out as a sibling) only when wiring OikosBot into the fleet. +# See crates/oikosbot-fleet/README.md and DISAMBIGUATION.adoc. +exclude = [ + "crates/oikosbot-fleet", + "fuzz", +] + +[workspace.package] +version = "0.1.0" +authors = ["Jonathan D.A. Jewell "] +edition = "2021" +license = "MPL-2.0" +repository = "https://github.com/hyperpolymath/oikosbot" + +[workspace.dependencies] +# AST parsing +tree-sitter = "0.23.2" +tree-sitter-rust = "0.23.3" +tree-sitter-javascript = "0.23.1" +tree-sitter-python = "0.23.6" + +# CLI +clap = { version = "4.5.55", features = ["derive", "cargo"] } +anyhow = "1.0.100" +thiserror = "2.0.18" + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +toml = "0.8.23" + +# Logging +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } + +# Process execution (for Eclexia interpreter) +tokio = { version = "1.49.0", features = ["full"] } + +# Carbon APIs +reqwest = { version = "0.12.28", features = ["json"] } + + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" diff --git a/DISAMBIGUATION.adoc b/DISAMBIGUATION.adoc new file mode 100644 index 0000000..f205614 --- /dev/null +++ b/DISAMBIGUATION.adoc @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += Disambiguation: oikos vs OikosBot vs sustainabot +Jonathan D.A. Jewell +:toc: macro +:icons: font + +Three similarly-named things in the hyperpolymath estate are routinely confused. +They are **three separate projects** living in **three separate places**. This +page is the canonical reference; link here whenever the distinction matters. + +toc::[] + +== TL;DR + +[cols="1,2,3,2",options="header"] +|=== +| Name | What it is | Where it lives | Language + +| **oikos** +| A DSL — a typed compiler for stock-flow consistent (SFC) macroeconomic / + accounting models. +| Its own top-level repo: `hyperpolymath/oikos-economics-accounting-dsl`. +| Rust (compiler crates `oikos-syntax`, `oikos-parser`, `oikos-desugar`, + `oikos-check`). + +| **OikosBot** (this repo) +| A code-analysis GitHub/GitLab App that adds an *ecological & economic* review + lens to pull requests. +| Its own top-level repo: `hyperpolymath/oikosbot`. +| Rust workspace (`crates/oikosbot-*`) + Haskell analyzer + AffineScript bot. + +| **sustainabot** +| A *reserved* member slot of the gitbot fleet — a future, lightweight quality + bot, kept for later prototyping. **Currently empty.** +| A directory inside another repo: `hyperpolymath/gitbot-fleet` → + `bots/sustainabot/`. +| TBD (not yet built). +|=== + +They share the Greek root _οἶκος_ ("household / habitat") but are otherwise +unrelated. + +== The three projects in detail + +=== oikos — the DSL (NOT this repo) + +`oikos` is a programming language / compiler for stock-flow consistent economic +models. It is **not a bot** and has **nothing to do** with code analysis. It +lives in its own repository, `hyperpolymath/oikos-economics-accounting-dsl`, and +its crates are namespaced `oikos-*` (e.g. `oikos-parser`). + +=== OikosBot — this repository + +`OikosBot` is the eco/econ code-analysis App. Its Rust crates are namespaced +`oikosbot-*` (`oikosbot-cli`, `oikosbot-analysis`, `oikosbot-metrics`, +`oikosbot-sarif`, `oikosbot-eclexia`, and the optional `oikosbot-fleet` bridge). + +[IMPORTANT] +==== +OikosBot crates use the `oikosbot-` prefix, **never** `oikos-` (that namespace +belongs to the DSL) and **never** `sustainabot-` (that belongs to the fleet +slot). Do not reintroduce either prefix here. +==== + +=== sustainabot — a gitbot-fleet member (NOT this repo) + +`sustainabot` is one of the bots coordinated by `hyperpolymath/gitbot-fleet`. It +is a **reserved slot**, intended to be prototyped later as a lightweight quality +helper. It is a *peer* of the other fleet bots (rhodibot, echidnabot, …), not a +home for full standalone products. + +== What went wrong (and how this was fixed) + +A full incarnation of **OikosBot** was once committed *inside* gitbot-fleet at +`bots/sustainabot/` — a Rust workspace named `sustainabot-*`, a Haskell +analyzer, an AffineScript bot, containers, policies, and fuzz targets. That was +a misfiling: a whole standalone product had been vendored into one fleet bot's +slot, and it had even grown a hard `path` dependency back into +`gitbot-fleet/shared-context`. + +The fix: + +. The implementation was **moved here** (`hyperpolymath/oikosbot`) and renamed + `sustainabot-*` → `oikosbot-*`. +. The fleet coupling was severed: the default OikosBot workspace builds with + **no** dependency on gitbot-fleet. The only bridge, + link:crates/oikosbot-fleet/README.md[`oikosbot-fleet`], is *excluded* from the + default workspace and built only on demand. +. `gitbot-fleet/bots/sustainabot/` was emptied back to a **placeholder** that + reserves the slot and points here. + +== Guardrails — how to avoid this happening again + +* **Naming:** keep the prefixes distinct — `oikos-*` (DSL), + `oikosbot-*` (this App), `sustainabot-*` (the fleet slot, if/when built). A CI + language-policy/antipattern check can assert no `sustainabot` strings appear + in this repo outside the legacy `oikosbot-fleet` bridge. +* **No vendoring of standalone products into `gitbot-fleet/bots//`.** A + fleet bot directory is a *thin adapter*, not a home for an entire project. See + `gitbot-fleet/bots/sustainabot/README.md`. +* **No path dependencies escaping the repository.** OikosBot must build from a + clean checkout with no sibling repos present (the `oikosbot-fleet` bridge is + the sole, explicitly-excluded exception). +* When in doubt about which "oikos" thing is meant, **link to this file**. diff --git a/Justfile b/Justfile index 599b579..e022fb6 100644 --- a/Justfile +++ b/Justfile @@ -7,7 +7,18 @@ haskell_dir := "analyzers/code-haskell" all: check -check: haskell-build haskell-test +check: rust-build rust-test haskell-build haskell-test + +# Rust analysis workspace. The default workspace excludes crates/oikosbot-fleet +# (the optional gitbot-fleet bridge) so OikosBot builds standalone. +rust-build: + cargo build --workspace + +rust-test: + cargo test --workspace + +rust-fmt-check: + cargo fmt --check haskell-build: cd {{ haskell_dir }} && cabal build all @@ -22,4 +33,5 @@ shell-check: bash -n bot-integration-affine/check.sh clean: + cargo clean cd {{ haskell_dir }} && cabal clean diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..c0cc605 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,75 @@ +# OikosBot Quickstart + +## Build + +```bash +cargo build --release +``` + +## Usage + +### Analyze a file + +```bash +./target/release/oikosbot analyze path/to/file.rs +``` + +Supports: Rust (.rs), JavaScript (.js, .mjs), TypeScript (.ts) + +### Analyze oikosbot itself (dogfooding!) + +```bash +./target/release/oikosbot self-analyze +``` + +This proves we practice what we preach - the analyzer itself is efficient. + +### JSON output + +```bash +./target/release/oikosbot analyze file.rs --format json +``` + +## What It Does + +OikosBot analyzes code for: + +- **Energy consumption** (Joules) +- **Carbon footprint** (gCO2e) +- **Execution time** (milliseconds) +- **Memory usage** (bytes) + +And provides: +- **Eco score** (0-100) - environmental impact +- **Econ score** (0-100) - algorithmic efficiency +- **Quality score** (0-100) - code quality +- **Overall health** (weighted combination) + +## Example Output + +``` +📍 Function: nested_loops_function + Resources: + Energy: 7.10 J + Carbon: 0.0009 gCO2e + Time: 35.50 ms + + Health Index: + Eco: 80.4/100 + Overall: 75.1/100 + + Recommendations: + • Consider algorithm optimization to reduce nested iterations +``` + +## The Eclexia Connection + +- **Phase 1** (DONE): Analyzer built with Eclexia principles (explicit resource types) +- **Phase 2** (Next): Policy engine runs Eclexia code from `policies/*.ecl` +- **Phase 3+**: Migrate core to Eclexia as language matures + +## Philosophy + +> "The best ecological code analyzer is one that's ecological itself." + +OikosBot demonstrates that efficient, resource-aware code is not just theory - it works in practice. diff --git a/README.adoc b/README.adoc index 0a6abab..8db74a7 100644 --- a/README.adoc +++ b/README.adoc @@ -15,17 +15,30 @@ image:https://img.shields.io/badge/License-MPL_2.0-blue.svg[License: MPL-2.0,lin [NOTE] ==== -This repository is only OikosBot. The Oikos stock-flow consistent economics DSL -now lives in -https://github.com/hyperpolymath/oikos-economics-accounting-dsl[`hyperpolymath/oikos-economics-accounting-dsl`]. -Canonical home for OikosBot is this repo: `https://github.com/hyperpolymath/oikosbot`. +This repository is only **OikosBot**, and it is easy to confuse with two +similarly-named-but-separate things: + +* **oikos** — the stock-flow consistent economics *DSL*, now in + https://github.com/hyperpolymath/oikos-economics-accounting-dsl[`hyperpolymath/oikos-economics-accounting-dsl`]. +* **sustainabot** — a *reserved member slot* of + https://github.com/hyperpolymath/gitbot-fleet[`hyperpolymath/gitbot-fleet`] + (`bots/sustainabot/`), to be prototyped later. OikosBot is **not** sustainabot; + the implementation that briefly lived there was a misfiled copy of OikosBot and + has been moved into this repo. + +See link:DISAMBIGUATION.adoc[`DISAMBIGUATION.adoc`] for the full breakdown. ==== toc::[] == Status -`0.1.0-dev` - scaffold only. +`0.1.0-dev`. + +The Rust analysis workspace (`crates/oikosbot-*`: CLI, analysis engine, +metrics, SARIF output, and Eclexia policy evaluation) builds and tests cleanly +and is the most complete surface — it was extracted from the former +`gitbot-fleet/bots/sustainabot/` and renamed. The AffineScript webhook receiver is in flight. Cross-module type-checking, JSON payload extraction, and the HTTP server accept loop are still gated on @@ -39,15 +52,28 @@ OikosBot is not currently deployed in production. [source,text] ---- oikosbot/ +|-- crates/ # Rust workspace (oikosbot-* analysis engine + CLI) +| |-- oikosbot-cli/ # `oikosbot` binary (analyze / check / report) +| |-- oikosbot-analysis/ # tree-sitter analysis + eco/econ heuristics +| |-- oikosbot-metrics/ # shared metric/result types +| |-- oikosbot-sarif/ # SARIF report generation +| |-- oikosbot-eclexia/ # Eclexia (.ecl) policy evaluation +| `-- oikosbot-fleet/ # OPTIONAL gitbot-fleet bridge (excluded by default) |-- bot-integration-affine/ # AffineScript webhook receiver |-- analyzers/code-haskell/ # Haskell eco/econ/quality analysis engine |-- policy-engine/ # Datalog and DeepProbLog policy rules +|-- policies/ # Eclexia (.ecl) policy definitions |-- config/ # Bot configuration |-- prompts/ # Generated assistant prompt templates +|-- containers/ # Container build + compose for deployment +|-- examples/ # Example CI integration +|-- fuzz/ # cargo-fuzz targets (excluded from the workspace) |-- database/ # Knowledge-graph schema and query assets |-- databases/ # Legacy database assets retained for migration |-- docs/GITHUB_APP_SETUP.md # GitHub App registration notes |-- ARCHITECTURE.md # Platform architecture +|-- DISAMBIGUATION.adoc # oikos vs OikosBot vs sustainabot +|-- QUICKSTART.md # CLI quickstart `-- DEPLOY.md # Deployment status and runbook notes ---- @@ -55,11 +81,17 @@ oikosbot/ [source,shell] ---- +cargo build --workspace # or: just rust-build +cargo test --workspace # or: just rust-test just haskell-build just haskell-test just affine-check ---- +The default `cargo` workspace deliberately excludes `crates/oikosbot-fleet` +(the optional gitbot-fleet bridge) so OikosBot builds standalone. See +link:DISAMBIGUATION.adoc[`DISAMBIGUATION.adoc`]. + `just affine-check` expects an AffineScript compiler. Override the compiler path with `AS_BIN=/path/to/main.exe` when needed. diff --git a/bot-integration-affine/src/GitHubAPI.affine b/bot-integration-affine/src/GitHubAPI.affine new file mode 100644 index 0000000..abc5767 --- /dev/null +++ b/bot-integration-affine/src/GitHubAPI.affine @@ -0,0 +1,153 @@ +// face: affinescript +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2024-2026 hyperpolymath +// +// MIGRATED FROM GitHubAPI.res by hand-port per issue #148 (policy override 2026-05-24). +// Uses Http::fetch (affinescript#160) and canonical json::*. + +module GitHubAPI; + +use prelude::{Option, Some, None, Result, Ok, Err}; +use json::{Json}; + +const user_agent: String = "oikos-bot/0.1.0-beta"; +const api_version: String = "2022-11-28"; + +pub fn api_request( + token: ref String, + method: ref String, + endpoint: ref String, + body: Option, +) -{IO}-> Result { + let url = "https://api.github.com" ++ endpoint; + + let headers = [ + ("Authorization", "Bearer " ++ token), + ("Accept", "application/vnd.github+json"), + ("X-GitHub-Api-Version", api_version), + ("User-Agent", user_agent), + ("Content-Type", "application/json"), + ]; + + try { + let body_str = match body { + Some(b) => Some(json::stringify(b)), + None => None + }; + let response = Http::http_request(url, method, headers, body_str); + + if response.status >= 200 && response.status < 300 { + // TODO(mechanical-migrator): parse response.body to Json once + // json::parse (#161 follow-up) is bound. + Err("response decoder not yet bound (awaits json::parse)") + } else { + Err("GitHub API error " ++ Int::to_string(response.status) ++ ": " ++ response.body) + } + } catch { + err => Err("API request failed: " ++ Exn::message(err)) + } +} + +pub fn post_pr_comment( + token: ref String, + owner: ref String, + repo: ref String, + pr_number: Int, + body: ref String, +) -{IO}-> Result { + let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/issues/" ++ Int::to_string(pr_number) ++ "/comments"; + let payload = json::encode_object([("body", json::encode_string(body))]); + + let result = api_request(token, "POST", endpoint, Some(payload)); + + match result { + Ok(j) => match json::decode_object(j) { + Some(obj) => match dict::get(obj, "id") { + Some(id) => match json::decode_int(id) { + Some(n) => Ok(n), + None => Err("Invalid comment ID in response") + }, + None => Err("No comment ID in response") + }, + None => Err("Invalid JSON response") + }, + Err(e) => Err(e) + } +} + +pub fn update_comment( + token: ref String, + owner: ref String, + repo: ref String, + comment_id: Int, + body: ref String, +) -{IO}-> Result<(), String> { + let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/issues/comments/" ++ Int::to_string(comment_id); + let payload = json::encode_object([("body", json::encode_string(body))]); + + let result = api_request(token, "PATCH", endpoint, Some(payload)); + + match result { + Ok(_) => Ok(()), + Err(e) => Err(e) + } +} + +// conclusion: "success" | "failure" | "neutral" | "cancelled" | "skipped" | "timed_out" | "action_required" +pub fn create_check_run( + token: ref String, + owner: ref String, + repo: ref String, + head_sha: ref String, + name: ref String, + conclusion: ref String, + title: ref String, + summary: ref String, +) -{IO}-> Result { + let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/check-runs"; + let payload = json::encode_object([ + ("name", json::encode_string(name)), + ("head_sha", json::encode_string(head_sha)), + ("status", json::encode_string("completed")), + ("conclusion", json::encode_string(conclusion)), + ("output", json::encode_object([ + ("title", json::encode_string(title)), + ("summary", json::encode_string(summary)), + ])), + ]); + + let result = api_request(token, "POST", endpoint, Some(payload)); + + match result { + Ok(j) => match json::decode_object(j) { + Some(obj) => match dict::get(obj, "id") { + Some(id) => match json::decode_int(id) { + Some(n) => Ok(n), + None => Err("Invalid check run ID in response") + }, + None => Err("No check run ID in response") + }, + None => Err("Invalid JSON response") + }, + Err(e) => Err(e) + } +} + +pub fn get_pull_request( + token: ref String, + owner: ref String, + repo: ref String, + pr_number: Int, +) -{IO}-> Result { + let endpoint = "/repos/" ++ owner ++ "/" ++ repo ++ "/pulls/" ++ Int::to_string(pr_number); + api_request(token, "GET", endpoint, None) +} + +pub fn get_repository( + token: ref String, + owner: ref String, + repo: ref String, +) -{IO}-> Result { + let endpoint = "/repos/" ++ owner ++ "/" ++ repo; + api_request(token, "GET", endpoint, None) +} diff --git a/bot-integration-affine/src/GitHubApp.affine b/bot-integration-affine/src/GitHubApp.affine new file mode 100644 index 0000000..c16aa3e --- /dev/null +++ b/bot-integration-affine/src/GitHubApp.affine @@ -0,0 +1,191 @@ +// face: affinescript +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2024-2026 hyperpolymath +// +// MIGRATED FROM GitHubApp.res by hand-port per issue #148 (policy override 2026-05-24). +// RS256 JWT signing + installation-token caching. +// Crypto / Base64 / Bytes paths assume the Web Crypto-derived stdlib +// surface; will need re-validation against the canonical AffineScript +// stdlib once #103 (Async-extern ABI) is fully wired through handlers. +// See MISSING-EXTERNS.md for the unbound surfaces called below. + +module GitHubApp; + +use prelude::{Option, Some, None, Result, Ok, Err}; +use json::{Json}; +use option::{unwrap_or}; +use Types::{Config, InstallationToken}; + +// Module-level token cache. The original ReScript used a mutable +// process-local Dict; AffineScript does not permit `mut` on a `const`, +// so the cache has been moved behind get/set helpers that the +// mechanical migrator should re-back with a proper handler-managed +// state cell (Cmd-style, or a `state` effect once #59 lands). + +fn cache_get(_key: ref String) -{IO}-> Option { + // TODO(mechanical-migrator): wire to actual state-effect cache. + None +} + +fn cache_set(_key: ref String, _value: ref InstallationToken) -{IO}-> () { + // TODO(mechanical-migrator): wire to actual state-effect cache. + () +} + +fn pem_to_array_buffer(pem: ref String) -{IO}-> [Int] { + let stripped = String::replace_regex(pem, "-----BEGIN (?:RSA )?PRIVATE KEY-----", ""); + let stripped2 = String::replace_regex(stripped, "-----END (?:RSA )?PRIVATE KEY-----", ""); + let stripped3 = String::replace_regex(stripped2, "\\s", ""); + + let binary = Base64::decode(stripped3); + Bytes::from_string(binary) +} + +fn base64_url_encode(data: ref [Int]) -> String { + let raw = Base64::encode(data); + let r1 = String::replace_all(raw, "+", "-"); + let r2 = String::replace_all(r1, "/", "_"); + String::replace_regex(r2, "=+$", "") +} + +fn base64_url_encode_string(s: ref String) -> String { + let raw = Base64::encode(Bytes::from_utf8(s)); + let r1 = String::replace_all(raw, "+", "-"); + let r2 = String::replace_all(r1, "/", "_"); + String::replace_regex(r2, "=+$", "") +} + +// JWT valid for 10 minutes (GitHub requirement). `iat` is set 60 s in the +// past to account for clock drift. +pub fn generate_jwt( + app_id: ref String, + private_key_pem: ref String, +) -{IO}-> Result { + let now_seconds = Time::now_millis() / 1000.0; + + let header = json::encode_object([ + ("alg", json::encode_string("RS256")), + ("typ", json::encode_string("JWT")), + ]); + let header_b64 = base64_url_encode_string(json::stringify(header)); + + let payload = json::encode_object([ + ("iss", json::encode_string(app_id)), + ("iat", json::encode_float(now_seconds - 60.0)), + ("exp", json::encode_float(now_seconds + 600.0)), + ]); + let payload_b64 = base64_url_encode_string(json::stringify(payload)); + + let message = header_b64 ++ "." ++ payload_b64; + + try { + let key_buffer = pem_to_array_buffer(private_key_pem); + + let key = Crypto::import_key_pkcs8( + key_buffer, + #{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + + let message_bytes = Bytes::from_utf8(message); + let signature_buffer = Crypto::sign_with_algorithm( + #{ name: "RSASSA-PKCS1-v1_5" }, + key, + message_bytes, + ); + + let signature_b64 = base64_url_encode(signature_buffer); + Ok(message ++ "." ++ signature_b64) + } catch { + err => Err("Failed to generate JWT: " ++ Exn::message(err)) + } +} + +// GitHub installation tokens are valid for 1 hour. +pub fn get_installation_token( + jwt: ref String, + installation_id: Int, +) -{IO}-> Result { + let cache_key = Int::to_string(installation_id); + + match cache_get(cache_key) { + Some(cached) => { + if cached.expiresAt > Time::now_millis() + 60000.0 { + Ok(cached) + } else { + fetch_installation_token(jwt, cache_key) + } + }, + None => fetch_installation_token(jwt, cache_key) + } +} + +fn fetch_installation_token( + jwt: ref String, + cache_key: ref String, +) -{IO}-> Result { + try { + let response = Http::http_request( + "https://api.github.com/app/installations/" ++ cache_key ++ "/access_tokens", + "POST", + [ + ("Authorization", "Bearer " ++ jwt), + ("Accept", "application/vnd.github+json"), + ("X-GitHub-Api-Version", "2022-11-28"), + ("User-Agent", "oikos-bot") + ], + None, + ); + + if response.status < 200 || response.status >= 300 { + Err("GitHub API error " ++ Int::to_string(response.status) ++ ": " ++ response.body) + } else { + // TODO(mechanical-migrator): parse response.body once json::parse lands. + Err("installation-token decoder not yet bound (awaits json::parse)") + } + } catch { + err => Err("Failed to get installation token: " ++ Exn::message(err)) + } +} + +pub fn extract_installation_id(payload: ref Json) -> Option { + match json::decode_object(payload) { + Some(obj) => match dict::get(obj, "installation") { + Some(inst) => match json::decode_object(inst) { + Some(inst_obj) => match dict::get(inst_obj, "id") { + Some(id) => json::decode_int(id), + None => None + }, + None => None + }, + None => None + }, + None => None + } +} + +pub fn get_auth_token( + config: ref Config, + payload: ref Json, +) -{IO}-> Result { + match (config.githubAppId, config.githubPrivateKey) { + (Some(app_id), Some(private_key)) => match extract_installation_id(payload) { + Some(installation_id) => { + let jwt_result = generate_jwt(app_id, private_key); + match jwt_result { + Ok(jwt) => { + let token_result = get_installation_token(jwt, installation_id); + match token_result { + Ok(install_token) => Ok(install_token.token), + Err(e) => Err(e) + } + }, + Err(e) => Err(e) + } + }, + None => Err("No installation ID in payload") + }, + _ => Err("GitHub App credentials not configured") + } +} diff --git a/bot-integration-affine/src/tea/Cmd.affine b/bot-integration-affine/src/tea/Cmd.affine new file mode 100644 index 0000000..966f10f --- /dev/null +++ b/bot-integration-affine/src/tea/Cmd.affine @@ -0,0 +1,52 @@ +// face: affinescript +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell +// +// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24). +// TEA Cmd module — describes IO actions that produce messages. +// +// Split out of the original single-file ServerTea hand-port: the parser +// has no inline `module Name { ... }` form, so each TEA submodule is its +// own file. + +module Cmd; + +use prelude::{Option, Some, None, Result, Ok, Err}; + +pub type T = + None + | Batch([T]) + | Perform(fn() -{IO}-> M) + | PerformWithDispatch(fn(fn(M) -> ()) -{IO}-> ()) + +pub fn none() -> T { + None +} + +pub fn batch(cmds: [T]) -> T { + Batch(cmds) +} + +pub fn perform(task: fn() -{IO}-> A, to_msg: fn(A) -> M) -> T { + Perform(fn() -{IO}-> M { to_msg(task()) }) +} + +// `attempt` lifts a fallible task into a Result-carrying message. +// The E parameter is unconstrained here; the mechanical migrator should +// re-narrow against the actual Exn[E] effect surface (#59). +pub fn attempt( + task: fn() -{IO}-> A, + to_msg: fn(Result) -> M, +) -> T { + Perform(fn() -{IO}-> M { + try { + to_msg(Ok(task())) + } catch { + e => to_msg(Err(e)) + } + }) +} + +pub fn with_dispatch(f: fn(fn(M) -> ()) -{IO}-> ()) -> T { + PerformWithDispatch(f) +} diff --git a/bot-integration-affine/src/tea/Runtime.affine b/bot-integration-affine/src/tea/Runtime.affine new file mode 100644 index 0000000..80e72fd --- /dev/null +++ b/bot-integration-affine/src/tea/Runtime.affine @@ -0,0 +1,92 @@ +// face: affinescript +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell +// +// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24). +// TEA Runtime module — the dispatch loop. +// +// Structural caveat (must be re-resolved by the mechanical migrator): +// The original ReScript used `let rec dispatch = ... and update_subs = +// ... and start_subscription = ...` mutual recursion over shared `mut` +// cells. The AffineScript parser has no `let rec ... and ...` form, so +// the closures here are hoisted to top-level `fn`s that take the +// state record explicitly. The borrow checker will likely require +// the State record to be `own`-passed through these functions, or +// the whole loop to be re-expressed as a single handler. See +// MIGRATION-NOTES.md "Runtime structural rework". + +module Runtime; + +use prelude::{Option, Some, None}; +use Cmd; +use Sub; + +pub type State = { + model: mut Model, + subscriptions: mut [Sub::T], + running: mut Bool, + // `http_server` / `intervals` carry host handles whose canonical types + // (Http::Server, Time::IntervalId) are not yet in stdlib; modelled as + // opaque Int handles until the externs land. + http_server: mut Option, + intervals: mut [Int], +} + +pub type Handle = { + dispatch: fn(Msg) -> (), + get_model: fn() -> Model, + stop: fn() -{IO}-> (), +} + +// dispatch / update_subscriptions / start_subscription were +// mutually-recursive closures in the original; mechanical re-port should +// re-express them under whatever final closure-form the borrow checker +// admits. Stubs preserved here for traceability. + +pub fn execute_cmd( + cmd: ref Cmd::T, + dispatch: fn(Msg) -> (), +) -{IO}-> () { + match cmd { + Cmd::None => (), + Cmd::Batch(cmds) => { + // TODO(mechanical-migrator): iterate `cmds` once Array/iter externs + // are bound; for now this is a no-op stub so the file parses. + () + }, + Cmd::Perform(task) => { + let msg = task(); + dispatch(msg) + }, + Cmd::PerformWithDispatch(f) => f(dispatch), + } +} + +// `make` is the public entry point. The original wired up dispatch, +// update_subscriptions and start_subscription as a `let rec ... and` +// cluster sharing the `state` record. That form is not in the parser; +// the implementation body has been replaced with a TODO stub so the +// file parses cleanly. The mechanical migrator must rebuild the loop +// against the final effect-row + borrow shape (#59 / #103). +pub fn make( + init: fn(Flags) -> (Model, Cmd::T), + update: fn(Msg, Model) -> (Model, Cmd::T), + subscriptions: fn(Model) -> Sub::T, + flags: Flags, +) -{IO}-> Handle { + // TODO(mechanical-migrator): rebuild the dispatch loop. The shape was + // 1. let (initial_model, initial_cmd) = init(flags); + // 2. create State {model, subscriptions=[], running=true, ...} + // 3. dispatch = msg => if running { (m, c) = update(msg, state.model); + // state.model := m; execute_cmd(c, dispatch); ... } + // 4. start_subscription on initial subs + // 5. return Handle { dispatch, get_model, stop } + // The hand-port has been stubbed so the file parses; do NOT trust the + // returned Handle to actually drive a TEA loop. + let (initial_model, _initial_cmd) = init(flags); + Handle #{ + dispatch: fn(_msg) -> () { () }, + get_model: fn() -> Model { initial_model }, + stop: fn() -{IO}-> () { () }, + } +} diff --git a/bot-integration-affine/src/tea/Sub.affine b/bot-integration-affine/src/tea/Sub.affine new file mode 100644 index 0000000..2405429 --- /dev/null +++ b/bot-integration-affine/src/tea/Sub.affine @@ -0,0 +1,33 @@ +// face: affinescript +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell +// +// MIGRATED FROM tea/ServerTea.res by hand-port per issue #148 (policy override 2026-05-24). +// TEA Sub module — long-lived subscription descriptors. + +module Sub; + +use prelude::{Option, Some, None}; +use json::{Json}; + +pub type T = + None + | Batch([T]) + | HttpServer(Int, fn(Json) -> Option) + | Interval(Int, fn() -> M) + +pub fn none() -> T { + None +} + +pub fn batch(subs: [T]) -> T { + Batch(subs) +} + +pub fn http_server(port: Int, handler: fn(Json) -> Option) -> T { + HttpServer(port, handler) +} + +pub fn every(ms: Int, to_msg: fn() -> M) -> T { + Interval(ms, to_msg) +} diff --git a/containers/Containerfile b/containers/Containerfile new file mode 100644 index 0000000..2945629 --- /dev/null +++ b/containers/Containerfile @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: MPL-2.0 +# Oikos Bot Container Image +# +# Build: vordr build -t oikos:latest -f containers/Containerfile . +# Run: vordr run -p 3000:3000 oikos:latest + +# ============================================================================= +# Stage 1: Placeholder for Haskell analyzer (skipped for MVP) +# ============================================================================= +# The Haskell analyzer will be added when fully implemented. +# For now, the bot uses mock analysis or calls external analyzer endpoint. +FROM docker.io/debian:bookworm-slim AS haskell-builder +RUN echo "Haskell analyzer skipped for MVP" > /build-status.txt + +# ============================================================================= +# Stage 2: Placeholder for OCaml analyzer (skipped for MVP) +# ============================================================================= +# The OCaml doc analyzer will be added when implemented. +FROM docker.io/debian:bookworm-slim AS ocaml-builder +RUN echo "OCaml analyzer skipped for MVP" > /build-status.txt + +# ============================================================================= +# Stage 3: ReScript bot integration (pre-compiled, Deno runtime) +# ============================================================================= +# NOTE: ReScript must be pre-compiled locally before building container. +# Run `deno task build:rescript` in bot-integration/ to generate .res.js files. +# We do NOT use npm in the container - Deno only. +FROM docker.io/denoland/deno:2.1.4 AS rescript-stage + +WORKDIR /build/bot + +# Just copy pre-compiled ReScript output and Deno config +COPY bot-integration/src/*.res.js ./src/ +COPY bot-integration/deno.json . +COPY bot-integration/bindings/ ./bindings/ +COPY bot-integration/rescript-runtime/ ./rescript-runtime/ + +# Cache Deno dependencies +RUN deno cache src/Oikos.res.js || true + +# ============================================================================= +# Stage 4: Final runtime image +# ============================================================================= +FROM docker.io/denoland/deno:2.1.4 AS runtime + +LABEL maintainer="hyperpolymath" +LABEL org.opencontainers.image.title="Oikos Bot" +LABEL org.opencontainers.image.description="Ecological & Economic Code Analysis Platform" +LABEL org.opencontainers.image.version="0.1.0-beta" +LABEL org.opencontainers.image.source="https://github.com/hyperpolymath/oikos-bot" +LABEL org.opencontainers.image.licenses="MPL-2.0" + +WORKDIR /app + +USER root + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + git \ + jq \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Copy built artifacts from previous stages +# NOTE: Haskell and OCaml analyzers are optional - if they didn't build, we skip them +# The bot will fall back to mock analysis if the analyzer is unavailable + +# ReScript bot (pre-compiled, Deno runtime) +COPY --from=rescript-stage /build/bot/src/*.res.js /app/bot-integration/src/ +COPY --from=rescript-stage /build/bot/deno.json /app/bot-integration/ +COPY --from=rescript-stage /build/bot/rescript-runtime/ /app/bot-integration/rescript-runtime/ + +# Copy policy engine +COPY policy-engine/ /app/policy-engine/ + +# Copy configuration +COPY config/ /app/config/ + +# Environment +ENV PORT=3000 +ENV BOT_MODE=advisor +ENV ANALYSIS_ENDPOINT=http://localhost:8080/analyze +ENV DENO_DIR=/app/.deno + +# Create deno cache directory +RUN mkdir -p /app/.deno && chown -R deno:deno /app + +# Switch to non-root user +USER deno + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD deno eval "const r = await fetch('http://localhost:3000/health'); if (!r.ok) Deno.exit(1);" || exit 1 + +EXPOSE 3000 + +# Default command +CMD ["run", "--allow-net", "--allow-env", "--allow-read", "/app/bot-integration/src/Oikos.res.js"] diff --git a/containers/Containerfile.policy b/containers/Containerfile.policy new file mode 100644 index 0000000..db95b32 --- /dev/null +++ b/containers/Containerfile.policy @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2024-2025 hyperpolymath +# +# Oikos Bot Policy Engine Container +# Datalog + DeepProbLog based policy engine +# +# Build: podman build -t oikos-policy:latest -f containers/Containerfile.policy . + +FROM /cerro-torre AS builder + +WORKDIR /build + +# Install build dependencies +RUN guix install \ + python \ + python-pip \ + souffle \ + swi-prolog \ + git + +# Copy policy engine source +COPY policy-engine/ . + +# Create virtual environment and install dependencies +RUN python -m venv /opt/venv && \ + /opt/venv/bin/pip install --upgrade pip && \ + /opt/venv/bin/pip install \ + numpy \ + torch \ + networkx \ + pyyaml \ + aiohttp \ + pyarango \ + SPARQLWrapper + +# Install DeepProbLog +RUN /opt/venv/bin/pip install deepproblog + +# ============================================================================= +# Runtime +# ============================================================================= +FROM /cerro-torre AS runtime + +WORKDIR /app + +# Install runtime dependencies +RUN guix install \ + python \ + souffle \ + swi-prolog \ + jq + +# Copy virtual environment +COPY --from=builder /opt/venv /opt/venv + +# Copy policy engine +COPY --from=builder /build /app/policy-engine + +# Copy Datalog rules +COPY policy-engine/datalog/ /app/datalog/ + +# Copy DeepProbLog rules +COPY policy-engine/deepproblog/ /app/deepproblog/ + +# Environment +ENV PATH="/opt/venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 +ENV PORT=8081 + +# Create non-root user +RUN useradd -m -s /bin/bash policyengine +USER policyengine + +EXPOSE 8081 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" + +CMD ["python", "-m", "policy_engine.server", "--port", "8081"] diff --git a/containers/compose.yaml b/containers/compose.yaml new file mode 100644 index 0000000..6bc64c9 --- /dev/null +++ b/containers/compose.yaml @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: MPL-2.0 +# Oikos Bot Compose Configuration +# +# Usage with Vörðr/Podman: +# podman-compose up -d +# podman-compose logs -f oikos +# podman-compose down + +name: oikos + +services: + # ========================================================================== + # Main Oikos Bot Service + # ========================================================================== + oikos: + build: + context: .. + dockerfile: containers/Containerfile + image: oikos:latest + container_name: oikos + restart: unless-stopped + ports: + - "3000:3000" + environment: + - PORT=3000 + - BOT_MODE=advisor + - ANALYSIS_ENDPOINT=https://analyzer:8080/analyze + - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET:-} + - GITLAB_WEBHOOK_SECRET=${GITLAB_WEBHOOK_SECRET:-} + - ARANGO_URL=https://arangodb:8529 + - VIRTUOSO_URL=https://virtuoso:8890/sparql + depends_on: + - arangodb + - virtuoso + - analyzer + networks: + - oikos-network + healthcheck: + test: ["CMD", "deno", "eval", "const r = await fetch('http://localhost:3000/health'); if (!r.ok) Deno.exit(1);"] + interval: 30s + timeout: 10s + retries: 3 + + # ========================================================================== + # Analysis Service (Haskell + OCaml analyzers) + # ========================================================================== + analyzer: + build: + context: .. + dockerfile: containers/Containerfile + target: runtime + image: oikos-analyzer:latest + container_name: oikos-analyzer + restart: unless-stopped + ports: + - "8080:8080" + environment: + - PORT=8080 + networks: + - oikos-network + command: ["oikos-analyzer", "serve", "--port", "8080"] + + # ========================================================================== + # Policy Engine (Datalog + DeepProbLog) + # ========================================================================== + policy-engine: + build: + context: .. + dockerfile: containers/Containerfile.policy + image: oikos-policy:latest + container_name: oikos-policy + restart: unless-stopped + ports: + - "8081:8081" + environment: + - PORT=8081 + - ARANGO_URL=https://arangodb:8529 + - VIRTUOSO_URL=https://virtuoso:8890/sparql + depends_on: + - arangodb + - virtuoso + networks: + - oikos-network + volumes: + - policy-data:/app/data + + # ========================================================================== + # ArangoDB (Graph + Document Database) + # ========================================================================== + arangodb: + image: arangodb:3.11 + container_name: oikos-arangodb + restart: unless-stopped + ports: + - "8529:8529" + environment: + - ARANGO_ROOT_PASSWORD=${ARANGO_ROOT_PASSWORD:-oikos} + - ARANGO_NO_AUTH=${ARANGO_NO_AUTH:-0} + volumes: + - arangodb-data:/var/lib/arangodb3 + - ../databases/arangodb/schema.js:/docker-entrypoint-initdb.d/schema.js:ro + networks: + - oikos-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8529/_api/version"] + interval: 30s + timeout: 10s + retries: 5 + + # ========================================================================== + # Virtuoso (RDF Triple Store + SPARQL) + # ========================================================================== + virtuoso: + image: openlink/virtuoso-opensource-7:latest + container_name: oikos-virtuoso + restart: unless-stopped + ports: + - "8890:8890" + - "1111:1111" + environment: + - DBA_PASSWORD=${VIRTUOSO_DBA_PASSWORD:-oikos} + - SPARQL_UPDATE=true + - DEFAULT_GRAPH=https://oikos.dev/knowledge + volumes: + - virtuoso-data:/database + - ../databases/virtuoso/ontology.ttl:/opt/virtuoso-opensource/share/virtuoso/vad/ontology.ttl:ro + networks: + - oikos-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8890/sparql?query=ASK%20%7B%7D"] + interval: 30s + timeout: 10s + retries: 5 + + # ========================================================================== + # Prometheus (Metrics) + # ========================================================================== + prometheus: + image: prom/prometheus:latest + container_name: oikos-prometheus + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus-data:/prometheus + networks: + - oikos-network + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + + # ========================================================================== + # Grafana (Dashboard) + # ========================================================================== + grafana: + image: grafana/grafana:latest + container_name: oikos-grafana + restart: unless-stopped + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-oikos} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + depends_on: + - prometheus + networks: + - oikos-network + +# ============================================================================= +# Networks +# ============================================================================= +networks: + oikos-network: + driver: bridge + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + arangodb-data: + virtuoso-data: + policy-data: + prometheus-data: + grafana-data: diff --git a/containers/prometheus.yml b/containers/prometheus.yml new file mode 100644 index 0000000..595b9d6 --- /dev/null +++ b/containers/prometheus.yml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MPL-2.0 +# Prometheus configuration for Oikos Bot metrics + +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: [] + +rule_files: [] + +scrape_configs: + # Prometheus self-monitoring + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Oikos main service + - job_name: 'oikos' + static_configs: + - targets: ['oikos:3000'] + metrics_path: '/metrics' + + # Analysis service + - job_name: 'oikos-analyzer' + static_configs: + - targets: ['analyzer:8080'] + metrics_path: '/metrics' + + # Policy engine + - job_name: 'oikos-policy' + static_configs: + - targets: ['policy-engine:8081'] + metrics_path: '/metrics' + + # ArangoDB + - job_name: 'arangodb' + static_configs: + - targets: ['arangodb:8529'] + metrics_path: '/_admin/metrics/v2' + basic_auth: + username: 'root' + password_file: '/etc/prometheus/arango_password' diff --git a/containers/vordr-build.sh b/containers/vordr-build.sh new file mode 100755 index 0000000..10347bc --- /dev/null +++ b/containers/vordr-build.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +# +# Oikos Container Build Script +# +# Builds all container images using Vörðr (Svalinn OCI runtime) +# https://github.com/hyperpolymath/svalinn + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${CYAN}[STEP]${NC} $1" +} + +# Check for container runtime (prefer vordr, fallback to podman) +detect_runtime() { + if command -v vordr &> /dev/null; then + echo "vordr" + elif command -v podman &> /dev/null; then + echo "podman" + else + log_error "No container runtime found." + log_info "Install Vörðr: https://github.com/hyperpolymath/svalinn" + log_info "Or fallback: sudo dnf install podman" + exit 1 + fi +} + +RUNTIME=$(detect_runtime) +log_info "Using container runtime: ${RUNTIME}" + +# Display Vörðr version if available +if [[ "$RUNTIME" == "vordr" ]]; then + log_info "Vörðr version: $(vordr --version 2>/dev/null || echo 'unknown')" +fi + +cd "$PROJECT_ROOT" + +# Ensure AffineScript is pre-compiled +if [[ ! -f "bot-integration/src/Oikos.affine.js" ]]; then + log_warn "AffineScript not compiled. Running build..." + cd bot-integration + if command -v deno &> /dev/null; then + deno task build:affinescript + else + log_error "Deno not found. Please install Deno and run: deno task build:affinescript" + exit 1 + fi + cd "$PROJECT_ROOT" +fi + +# Build main Oikos image +log_step "Building oikos:latest..." +$RUNTIME build \ + --tag oikos:latest \ + --file containers/Containerfile \ + --progress=plain \ + . + +# Build policy engine image +log_step "Building oikos-policy:latest..." +$RUNTIME build \ + --tag oikos-policy:latest \ + --file containers/Containerfile.policy \ + --progress=plain \ + . + +# Tag images with version +VERSION=$(git describe --tags --always 2>/dev/null || echo "0.1.0-beta") +log_info "Tagging images with version: $VERSION" + +$RUNTIME tag oikos:latest "oikos:$VERSION" +$RUNTIME tag oikos-policy:latest "oikos-policy:$VERSION" + +# List built images +log_info "Built images:" +$RUNTIME images | grep oikos || true + +log_info "Build complete!" +echo "" +echo "To run the stack:" +if [[ "$RUNTIME" == "vordr" ]]; then + echo " vordr run -d -p 3000:3000 --name oikos oikos:latest" + echo "" + echo "To run with compose (requires podman-compose or docker-compose):" +fi +echo " cd containers && podman-compose up -d" +echo "" +echo "To push to registry:" +echo " $RUNTIME push oikos:$VERSION" diff --git a/crates/oikosbot-analysis/Cargo.toml b/crates/oikosbot-analysis/Cargo.toml new file mode 100644 index 0000000..cf9b7c3 --- /dev/null +++ b/crates/oikosbot-analysis/Cargo.toml @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[package] +name = "oikosbot-analysis" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[features] +default = [] +# Security-sustainability correlation. The optional `panic-attacker` integration +# (a separate hyperpolymath repo) was decoupled when OikosBot was extracted from +# gitbot-fleet so this crate builds standalone; the feature is retained as a +# no-op seam to be re-wired (path or git dep) when that integration is needed. +panic-attack = [] + +[dependencies] +oikosbot-metrics = { path = "../oikosbot-metrics" } +tree-sitter.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-javascript.workspace = true +tree-sitter-python.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/oikosbot-analysis/src/analyzer.rs b/crates/oikosbot-analysis/src/analyzer.rs new file mode 100644 index 0000000..27742da --- /dev/null +++ b/crates/oikosbot-analysis/src/analyzer.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Core analysis engine using tree-sitter AST + +use crate::carbon::estimate_carbon; +use crate::language::Language; +use crate::patterns::detect_patterns; +use anyhow::{Context, Result}; +use oikosbot_metrics::*; +use std::fs; +use std::path::Path; +use tree_sitter::{Parser, Tree}; + +pub struct Analyzer { + _language: Language, + parser: Parser, + file_path: Option, +} + +impl Analyzer { + pub fn new(language: Language) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&language.parser()) + .context("Failed to set parser language")?; + + Ok(Analyzer { + _language: language, + parser, + file_path: None, + }) + } + + pub fn analyze_file(&mut self, path: &Path) -> Result> { + let source = fs::read_to_string(path) + .with_context(|| format!("Failed to read file: {}", path.display()))?; + + self.file_path = Some(path.display().to_string()); + let results = self.analyze_source(&source); + self.file_path = None; + results + } + + pub fn analyze_source(&mut self, source: &str) -> Result> { + let tree = self + .parser + .parse(source, None) + .context("Failed to parse source")?; + + self.analyze_tree(source, &tree) + } + + fn analyze_tree(&self, source: &str, tree: &Tree) -> Result> { + let mut results = Vec::new(); + + // Walk the AST and analyze each function + let root = tree.root_node(); + let mut cursor = root.walk(); + + self.visit_node(source, &root, &mut cursor, &mut results); + + Ok(results) + } + + fn visit_node( + &self, + source: &str, + node: &tree_sitter::Node, + cursor: &mut tree_sitter::TreeCursor, + results: &mut Vec, + ) { + // Analyze functions + if self.is_function_node(node) { + if let Some(result) = self.analyze_function(source, node) { + results.push(result); + } + } + + // Recurse to children + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + self.visit_node(source, &child, cursor, results); + + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + } + + fn is_function_node(&self, node: &tree_sitter::Node) -> bool { + matches!( + node.kind(), + "function_item" // Rust + | "function_declaration" // JS + | "arrow_function" // JS + | "method_declaration" // JS + | "function_definition" // Python + ) + } + + fn analyze_function(&self, source: &str, node: &tree_sitter::Node) -> Option { + let location = self.node_location(source, node)?; + + // Estimate resources based on code patterns + let complexity = self.estimate_complexity(node); + let resources = self.estimate_resources(complexity); + + // Detect problematic patterns + let pattern_matches = detect_patterns(source, node); + let patterns: Vec = pattern_matches.iter().map(|p| p.name.clone()).collect(); + let recommendations = self.generate_recommendations(&patterns); + + // Derive rule_id and suggestion from most significant pattern + let (rule_id, suggestion) = if let Some(pm) = pattern_matches.first() { + (format!("oikosbot/{}", pm.name), pm.suggestion.clone()) + } else { + ("oikosbot/general".to_string(), None) + }; + + // Calculate scores + let eco_score = self.calculate_eco_score(&resources); + let econ_score = self.calculate_econ_score(complexity); + let quality_score = self.calculate_quality_score(complexity); + + let health = HealthIndex::compute(eco_score, econ_score, quality_score); + + let end = node.end_position(); + + Some(AnalysisResult { + location, + resources, + health, + recommendations, + rule_id, + suggestion, + end_location: Some((end.row + 1, end.column + 1)), + confidence: oikosbot_metrics::Confidence::Estimated, + }) + } + + fn node_location(&self, source: &str, node: &tree_sitter::Node) -> Option { + let start = node.start_position(); + let end = node.end_position(); + let name = self.extract_function_name(source, node); + + Some(CodeLocation { + file: self + .file_path + .clone() + .unwrap_or_else(|| String::from("")), + line: start.row + 1, + column: start.column + 1, + end_line: Some(end.row + 1), + end_column: Some(end.column + 1), + name, + }) + } + + fn extract_function_name(&self, source: &str, node: &tree_sitter::Node) -> Option { + // Try to find name node (language-specific) + let mut cursor = node.walk(); + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + if child.kind() == "identifier" { + return Some(child.utf8_text(source.as_bytes()).ok()?.to_string()); + } + if !cursor.goto_next_sibling() { + break; + } + } + } + None + } + + fn estimate_complexity(&self, node: &tree_sitter::Node) -> usize { + // Simple complexity: count nodes + let mut count = 1; + let mut cursor = node.walk(); + + if cursor.goto_first_child() { + loop { + count += self.estimate_complexity(&cursor.node()); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + + count + } + + fn estimate_resources(&self, complexity: usize) -> ResourceProfile { + // Baseline estimates (will be improved with profiling data) + let energy = Energy::joules(complexity as f64 * 0.1); + let duration = Duration::milliseconds(complexity as f64 * 0.5); + let carbon = estimate_carbon(energy); + let memory = Memory::kilobytes(complexity * 2); + + ResourceProfile { + energy, + duration, + carbon, + memory, + } + } + + fn calculate_eco_score(&self, resources: &ResourceProfile) -> EcoScore { + // Lower resource usage = higher score + // Baseline: 100J = 50 score, scale logarithmically + let energy_score = (100.0 - (resources.energy.0.ln() * 10.0)).max(0.0); + EcoScore::new(energy_score) + } + + fn calculate_econ_score(&self, complexity: usize) -> EconScore { + // Lower complexity = higher efficiency + let score = (100.0 - (complexity as f64 * 0.5)).max(0.0); + EconScore::new(score) + } + + fn calculate_quality_score(&self, complexity: usize) -> f64 { + // Simple quality metric based on complexity + (100.0 - (complexity as f64 * 0.3)).max(0.0) + } + + fn generate_recommendations(&self, patterns: &[String]) -> Vec { + let mut recs = Vec::new(); + + for pattern in patterns { + match pattern.as_str() { + "busy-wait" => recs + .push("Replace busy-wait loop with async/await or blocking sleep".to_string()), + "nested-loops" => recs.push( + "Consider algorithm optimization to reduce nested iterations".to_string(), + ), + "large-allocation" => recs + .push("Review memory allocation - consider streaming or chunking".to_string()), + "string-concat-in-loop" => recs.push( + "Use String::with_capacity + push_str or collect with iterators".to_string(), + ), + "clone-in-loop" => { + recs.push("Consider borrowing instead of cloning inside loop body".to_string()) + } + "unbuffered-io" => recs.push( + "Wrap File with BufReader/BufWriter to reduce syscall overhead".to_string(), + ), + "redundant-allocation" => { + recs.push("Accept &str instead of String where borrow suffices".to_string()) + } + _ => {} + } + } + + if recs.is_empty() { + recs.push("Code looks efficient - keep up the good work!".to_string()); + } + + recs + } +} diff --git a/crates/oikosbot-analysis/src/calibration.rs b/crates/oikosbot-analysis/src/calibration.rs new file mode 100644 index 0000000..70699dd --- /dev/null +++ b/crates/oikosbot-analysis/src/calibration.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Calibration framework for resource estimation. +//! +//! Replaces naive `complexity * 0.1 J` with pattern-based resource profiles +//! producing ranges (min, typical, max) instead of single numbers. + +use crate::carbon::estimate_carbon; +use oikosbot_metrics::{Confidence, Duration, Energy, Memory, ResourceProfile}; + +/// Resource estimate with min/typical/max range +#[derive(Debug, Clone)] +pub struct ResourceRange { + pub min: ResourceProfile, + pub typical: ResourceProfile, + pub max: ResourceProfile, + pub confidence: Confidence, +} + +impl ResourceRange { + /// Return the typical estimate as a single profile + pub fn as_typical(&self) -> &ResourceProfile { + &self.typical + } +} + +/// Operation categories for calibrated estimates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationKind { + /// HashMap/BTreeMap lookup + HashLookup, + /// Sorting (comparison-based) + Sort, + /// File I/O (read or write) + FileIO, + /// Network call (HTTP request) + NetworkCall, + /// Heap allocation + Allocation, + /// String operations (format, concat) + StringOp, + /// Mathematical computation + MathCompute, + /// Generic/unknown operation + Generic, +} + +/// Calibrated resource profiles for known operation patterns. +/// +/// These are expert estimates that will be refined with profiling data. +/// All values assume a modern x86_64 system at ~50W TDP. +pub fn estimate_operation(kind: OperationKind, n: usize) -> ResourceRange { + match kind { + OperationKind::HashLookup => { + // O(1) amortized, ~50ns per lookup + let count = n.max(1) as f64; + ResourceRange { + min: profile(0.000001 * count, 0.00005 * count, 64 * n.max(1)), + typical: profile(0.000005 * count, 0.0001 * count, 128 * n.max(1)), + max: profile(0.00005 * count, 0.001 * count, 256 * n.max(1)), + confidence: Confidence::Calibrated, + } + } + OperationKind::Sort => { + // O(n log n), ~100ns per comparison + let nf = n.max(1) as f64; + let nlogn = nf * nf.log2().max(1.0); + ResourceRange { + min: profile(0.00001 * nlogn, 0.0001 * nlogn, 8 * n), + typical: profile(0.00005 * nlogn, 0.0005 * nlogn, 16 * n), + max: profile(0.0005 * nlogn, 0.005 * nlogn, 32 * n), + confidence: Confidence::Calibrated, + } + } + OperationKind::FileIO => { + // ~1ms per syscall, ~10μJ per 4KB page + let pages = (n / 4096).max(1) as f64; + ResourceRange { + min: profile(0.01 * pages, 0.5 * pages, n), + typical: profile(0.05 * pages, 2.0 * pages, n + 4096), + max: profile(0.5 * pages, 20.0 * pages, n + 65536), + confidence: Confidence::Estimated, + } + } + OperationKind::NetworkCall => { + // ~50ms per request, ~0.5J for a typical HTTPS request + ResourceRange { + min: profile(0.05, 10.0, 4096), + typical: profile(0.5, 50.0, 65536), + max: profile(5.0, 500.0, 1_048_576), + confidence: Confidence::Estimated, + } + } + OperationKind::Allocation => { + // ~10ns per allocation, ~1nJ per byte + let bytes = n.max(1) as f64; + ResourceRange { + min: profile(0.000001 * bytes / 1000.0, 0.00001, n), + typical: profile(0.00001 * bytes / 1000.0, 0.0001, n + 64), + max: profile(0.0001 * bytes / 1000.0, 0.001, n + 4096), + confidence: Confidence::Calibrated, + } + } + OperationKind::StringOp => { + // ~100ns per string operation, proportional to length + let len = n.max(1) as f64; + ResourceRange { + min: profile(0.000005 * len, 0.0001 * len, n + 64), + typical: profile(0.00005 * len, 0.001 * len, 2 * n + 128), + max: profile(0.0005 * len, 0.01 * len, 4 * n + 256), + confidence: Confidence::Estimated, + } + } + OperationKind::MathCompute => { + // ~5ns per FP operation + let ops = n.max(1) as f64; + ResourceRange { + min: profile(0.0000005 * ops, 0.000005 * ops, 0), + typical: profile(0.000005 * ops, 0.00005 * ops, 0), + max: profile(0.00005 * ops, 0.0005 * ops, 0), + confidence: Confidence::Calibrated, + } + } + OperationKind::Generic => { + // Fallback: linear in complexity + let c = n.max(1) as f64; + ResourceRange { + min: profile(0.01 * c, 0.05 * c, 512 * n.max(1)), + typical: profile(0.1 * c, 0.5 * c, 2048 * n.max(1)), + max: profile(1.0 * c, 5.0 * c, 8192 * n.max(1)), + confidence: Confidence::Unknown, + } + } + } +} + +/// Estimate resources for a function based on its complexity and detected patterns. +pub fn calibrated_estimate(complexity: usize, patterns: &[String]) -> ResourceProfile { + // Start with generic estimate based on complexity + let base = estimate_operation(OperationKind::Generic, complexity); + let mut result = base.typical.clone(); + + // Apply pattern-based adjustments + for pattern in patterns { + let multiplier = match pattern.as_str() { + "nested-loops" => 3.0, + "busy-wait" => 5.0, + "string-concat-in-loop" => 2.0, + "clone-in-loop" => 1.5, + "unbuffered-io" => 3.0, + "large-allocation" => 2.0, + "redundant-allocation" => 1.2, + _ => 1.0, + }; + + result.energy = Energy::joules(result.energy.0 * multiplier); + result.duration = Duration::milliseconds(result.duration.0 * multiplier); + result.carbon = estimate_carbon(result.energy); + } + + result +} + +fn profile(energy_j: f64, duration_ms: f64, memory_bytes: usize) -> ResourceProfile { + let energy = Energy::joules(energy_j); + ResourceProfile { + energy, + duration: Duration::milliseconds(duration_ms), + carbon: estimate_carbon(energy), + memory: Memory::bytes(memory_bytes), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_lookup_range() { + let range = estimate_operation(OperationKind::HashLookup, 1000); + assert!(range.min.energy.0 < range.typical.energy.0); + assert!(range.typical.energy.0 < range.max.energy.0); + assert_eq!(range.confidence, Confidence::Calibrated); + } + + #[test] + fn test_calibrated_estimate_with_patterns() { + let base = calibrated_estimate(10, &[]); + let with_pattern = calibrated_estimate(10, &["nested-loops".to_string()]); + // Pattern should increase energy + assert!(with_pattern.energy.0 > base.energy.0); + } + + #[test] + fn test_network_call_energy() { + let range = estimate_operation(OperationKind::NetworkCall, 1); + // Network calls should be significant energy users + assert!(range.typical.energy.0 >= 0.1); + } +} diff --git a/crates/oikosbot-analysis/src/carbon.rs b/crates/oikosbot-analysis/src/carbon.rs new file mode 100644 index 0000000..53689a9 --- /dev/null +++ b/crates/oikosbot-analysis/src/carbon.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Carbon emission estimation + +use oikosbot_metrics::{Carbon, Energy}; + +/// Estimate carbon emissions from energy consumption +/// +/// Uses average grid carbon intensity. In future, this will integrate +/// with real-time APIs (ElectricityMaps, WattTime, etc.) +pub fn estimate_carbon(energy: Energy) -> Carbon { + // Average grid intensity: ~475 gCO2e/kWh globally + // 1 kWh = 3,600,000 J + // So: gCO2e = J * (475 / 3,600,000) + const CARBON_INTENSITY: f64 = 475.0 / 3_600_000.0; + + Carbon::grams_co2e(energy.0 * CARBON_INTENSITY) +} + +/// Get real-time carbon intensity for a location (stub for now) +pub async fn get_carbon_intensity(_location: &str) -> f64 { + // TODO: Integrate with ElectricityMaps or WattTime API + // For now, return global average + 475.0 // gCO2e/kWh +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_carbon_estimation() { + // 100 J should produce ~0.0132 gCO2e with 475 intensity + let energy = Energy::joules(100.0); + let carbon = estimate_carbon(energy); + + assert!(carbon.0 > 0.01 && carbon.0 < 0.02); + } +} diff --git a/crates/oikosbot-analysis/src/dependencies.rs b/crates/oikosbot-analysis/src/dependencies.rs new file mode 100644 index 0000000..bc10f02 --- /dev/null +++ b/crates/oikosbot-analysis/src/dependencies.rs @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Dependency analysis for sustainability. +//! +//! Parses Cargo.toml and package.json to flag heavy dependencies +//! and suggest minimal feature sets. + +use anyhow::{Context, Result}; +use oikosbot_metrics::*; +use std::path::Path; + +/// Known heavy Rust dependencies and their lighter alternatives/fixes +const HEAVY_RUST_DEPS: &[(&str, &str)] = &[ + ( + "reqwest", + "Consider using ureq for simple HTTP (no async runtime needed)", + ), + ( + "tokio", + "If only using basic features, specify minimal feature set instead of 'full'", + ), + ( + "serde_yaml", + "Consider using serde_yml or minimal YAML parser if only reading configs", + ), + ( + "openssl", + "Consider using rustls for TLS (pure Rust, smaller footprint)", + ), + ( + "diesel", + "For simple queries, consider sqlx with compile-time checked queries", + ), + ( + "chrono", + "Consider using time crate (smaller, no C dependency)", + ), + ( + "num-bigint", + "If only using basic math, std library may suffice", + ), + ( + "regex", + "For simple patterns, consider using glob or manual matching", + ), + ("hyper", "For simple HTTP servers, consider using tiny_http"), + ( + "image", + "Specify only needed format features (e.g., features = [\"png\"])", + ), +]; + +/// Known heavy npm dependencies +const HEAVY_NPM_DEPS: &[(&str, &str)] = &[ + ( + "moment", + "Replace with dayjs or date-fns (tree-shakeable, much smaller)", + ), + ( + "lodash", + "Use native JS methods or lodash-es for tree-shaking", + ), + ( + "axios", + "Use native fetch API (available in modern browsers and Deno)", + ), + ("express", "Consider Hono or Fastify for better performance"), + ("webpack", "Consider Vite or esbuild for faster builds"), + ("jquery", "Use native DOM APIs"), + ("underscore", "Use native JS methods"), + ("request", "Deprecated — use native fetch or undici"), + ("bluebird", "Use native Promise"), + ("chalk", "Use picocolors (much smaller)"), +]; + +/// Dependency finding +#[derive(Debug, Clone)] +pub struct DepFinding { + pub dep_name: String, + pub manifest: String, + pub suggestion: String, + pub uses_all_features: bool, +} + +/// Analyze dependencies in a project directory. +pub fn analyze_dependencies(repo_path: &Path) -> Vec { + let mut results = Vec::new(); + + // Check Cargo.toml + let cargo_toml = repo_path.join("Cargo.toml"); + if cargo_toml.exists() { + if let Ok(findings) = analyze_cargo_toml(&cargo_toml) { + for finding in findings { + results.push(dep_finding_to_result(finding)); + } + } + } + + // Check package.json + let package_json = repo_path.join("package.json"); + if package_json.exists() { + if let Ok(findings) = analyze_package_json(&package_json) { + for finding in findings { + results.push(dep_finding_to_result(finding)); + } + } + } + + results +} + +fn analyze_cargo_toml(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + + let table: toml::Value = + toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?; + + let mut findings = Vec::new(); + let manifest = path.display().to_string(); + + // Check [dependencies] + if let Some(deps) = table.get("dependencies").and_then(|d| d.as_table()) { + for (name, value) in deps { + check_rust_dep(name, value, &manifest, &mut findings); + } + } + + // Check [dev-dependencies] + if let Some(deps) = table.get("dev-dependencies").and_then(|d| d.as_table()) { + for (name, value) in deps { + check_rust_dep(name, value, &manifest, &mut findings); + } + } + + Ok(findings) +} + +fn check_rust_dep(name: &str, value: &toml::Value, manifest: &str, findings: &mut Vec) { + // Check if it's a known heavy dep + if let Some((_, suggestion)) = HEAVY_RUST_DEPS.iter().find(|(dep, _)| *dep == name) { + let uses_all = check_uses_all_features(value); + findings.push(DepFinding { + dep_name: name.to_string(), + manifest: manifest.to_string(), + suggestion: suggestion.to_string(), + uses_all_features: uses_all, + }); + } + + // Flag deps using features = ["full"] or default-features = true with many features + if check_uses_all_features(value) { + let already_flagged = findings.iter().any(|f| f.dep_name == name); + if !already_flagged { + findings.push(DepFinding { + dep_name: name.to_string(), + manifest: manifest.to_string(), + suggestion: format!( + "Dependency '{}' uses all features. Consider specifying only needed features.", + name + ), + uses_all_features: true, + }); + } + } +} + +fn check_uses_all_features(value: &toml::Value) -> bool { + if let Some(table) = value.as_table() { + if let Some(features) = table.get("features").and_then(|f| f.as_array()) { + return features.iter().any(|f| f.as_str() == Some("full")); + } + } + false +} + +fn analyze_package_json(path: &Path) -> Result> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + + let json: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse {}", path.display()))?; + + let mut findings = Vec::new(); + let manifest = path.display().to_string(); + + // Check dependencies + if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) { + for name in deps.keys() { + if let Some((_, suggestion)) = + HEAVY_NPM_DEPS.iter().find(|(dep, _)| *dep == name.as_str()) + { + findings.push(DepFinding { + dep_name: name.clone(), + manifest: manifest.clone(), + suggestion: suggestion.to_string(), + uses_all_features: false, + }); + } + } + } + + // Check devDependencies too + if let Some(deps) = json.get("devDependencies").and_then(|d| d.as_object()) { + for name in deps.keys() { + if let Some((_, suggestion)) = + HEAVY_NPM_DEPS.iter().find(|(dep, _)| *dep == name.as_str()) + { + findings.push(DepFinding { + dep_name: name.clone(), + manifest: manifest.clone(), + suggestion: suggestion.to_string(), + uses_all_features: false, + }); + } + } + } + + Ok(findings) +} + +fn dep_finding_to_result(finding: DepFinding) -> AnalysisResult { + let severity_boost = if finding.uses_all_features { 2.0 } else { 1.0 }; + + AnalysisResult { + location: CodeLocation { + file: finding.manifest, + line: 0, + column: 0, + end_line: None, + end_column: None, + name: Some(format!("dep:{}", finding.dep_name)), + }, + resources: ResourceProfile { + energy: Energy::joules(10.0 * severity_boost), + duration: Duration::milliseconds(50.0 * severity_boost), + carbon: crate::carbon::estimate_carbon(Energy::joules(10.0 * severity_boost)), + memory: Memory::kilobytes(500), + }, + health: HealthIndex::compute( + EcoScore::new(if finding.uses_all_features { + 40.0 + } else { + 60.0 + }), + EconScore::new(50.0), + 50.0, + ), + recommendations: vec![finding.suggestion.clone()], + rule_id: "oikosbot/heavy-dependency".to_string(), + suggestion: Some(finding.suggestion), + end_location: None, + confidence: Confidence::Estimated, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_heavy_dep_detection() { + let value: toml::Value = toml::from_str(r#"version = "1.0""#).unwrap(); + let mut findings = Vec::new(); + check_rust_dep("reqwest", &value, "Cargo.toml", &mut findings); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].dep_name, "reqwest"); + } + + #[test] + fn test_all_features_detection() { + // Build the TOML value as a table directly + let mut table = toml::map::Map::new(); + table.insert( + "version".to_string(), + toml::Value::String("1.0".to_string()), + ); + table.insert( + "features".to_string(), + toml::Value::Array(vec![toml::Value::String("full".to_string())]), + ); + let value = toml::Value::Table(table); + assert!(check_uses_all_features(&value)); + } + + #[test] + fn test_no_all_features() { + let mut table = toml::map::Map::new(); + table.insert( + "version".to_string(), + toml::Value::String("1.0".to_string()), + ); + table.insert( + "features".to_string(), + toml::Value::Array(vec![toml::Value::String("json".to_string())]), + ); + let value = toml::Value::Table(table); + assert!(!check_uses_all_features(&value)); + } +} diff --git a/crates/oikosbot-analysis/src/directives.rs b/crates/oikosbot-analysis/src/directives.rs new file mode 100644 index 0000000..b39716f --- /dev/null +++ b/crates/oikosbot-analysis/src/directives.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Parser for `.machine_readable/bot_directives/*.a2ml` files. +//! +//! These files control what bots are allowed to do in a given repository. +//! The format is TOML-shaped A2ML; the SCM form was retired 2026-04-17. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::Path; + +/// A parsed bot directive +#[derive(Debug, Clone)] +pub struct BotDirective { + /// Bot name this directive applies to + pub bot: String, + /// Whether this bot is allowed to run + pub allow: bool, + /// Scopes the bot is allowed to operate in + pub scopes: Vec, + /// Scopes explicitly denied + pub deny: Vec, + /// Freeform notes + pub notes: Option, + /// Custom threshold overrides + pub thresholds: Vec<(String, f64)>, +} + +/// Raw A2ML shape (TOML deserialization target). Fields here mirror the +/// migration-script output at `.machine_readable/bot_directives/.a2ml`. +#[derive(Debug, Deserialize)] +struct DirectiveFile { + #[serde(default)] + bot: Option, + /// Either a single scope string or a list of scopes. + #[serde(default)] + scope: Option, + #[serde(default)] + scopes: Option>, + #[serde(default)] + allow: Option, + #[serde(default)] + deny: Option>, + #[serde(default)] + notes: Option, + #[serde(default)] + thresholds: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ScopeField { + One(String), + Many(Vec), +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum AllowField { + Bool(bool), + Scopes(Vec), +} + +/// Check if a specific bot has a directive in the given repo. +/// +/// Looks for `.machine_readable/bot_directives/{bot_name}.a2ml`. Returns +/// `None` if the file does not exist or fails to parse. +pub fn check_directive(repo_path: &Path, bot_name: &str) -> Option { + let path = repo_path + .join(".machine_readable") + .join("bot_directives") + .join(format!("{}.a2ml", bot_name)); + + if !path.exists() { + return None; + } + + match parse_directive(&path, bot_name) { + Ok(d) => Some(d), + Err(e) => { + tracing::warn!("Failed to parse directive {}: {}", path.display(), e); + None + } + } +} + +/// Parse a bot directive A2ML file. +fn parse_directive(path: &Path, bot_name: &str) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read directive: {}", path.display()))?; + + let file: DirectiveFile = toml::from_str(&content) + .with_context(|| format!("Failed to parse A2ML: {}", path.display()))?; + + let mut scopes: Vec = match file.scope { + Some(ScopeField::One(s)) => vec![s], + Some(ScopeField::Many(v)) => v, + None => Vec::new(), + }; + if let Some(mut extra) = file.scopes { + scopes.append(&mut extra); + } + + let allow = match file.allow { + // Plain boolean: honour as-is. + Some(AllowField::Bool(b)) => b, + // List-of-scopes: treat as allow = true + union the list into scopes. + Some(AllowField::Scopes(list)) => { + scopes.extend(list); + true + } + // No allow field → default to allowed (conservative parse). + None => true, + }; + + let thresholds = file + .thresholds + .unwrap_or_default() + .into_iter() + .filter_map(|(k, v)| v.as_float().map(|f| (k, f))) + .collect(); + + Ok(BotDirective { + bot: file.bot.unwrap_or_else(|| bot_name.to_string()), + allow, + scopes, + deny: file.deny.unwrap_or_default(), + notes: file.notes, + thresholds, + }) +} + +/// Check if the directive allows a specific scope +pub fn is_scope_allowed(directive: &BotDirective, scope: &str) -> bool { + if !directive.allow { + return false; + } + + // If deny list contains this scope, it's denied + if directive.deny.iter().any(|d| d == scope) { + return false; + } + + // If scopes list is empty, all scopes are allowed + if directive.scopes.is_empty() { + return true; + } + + // Otherwise, scope must be in the allow list + directive.scopes.iter().any(|s| s == scope) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_directive(dir: &Path, bot: &str, body: &str) { + let d = dir.join(".machine_readable").join("bot_directives"); + fs::create_dir_all(&d).unwrap(); + fs::write(d.join(format!("{}.a2ml", bot)), body).unwrap(); + } + + #[test] + fn test_default_directive() { + let d = BotDirective { + bot: "test".to_string(), + allow: true, + scopes: vec![], + deny: vec![], + notes: None, + thresholds: vec![], + }; + assert!(is_scope_allowed(&d, "anything")); + } + + #[test] + fn test_denied_scope() { + let d = BotDirective { + bot: "test".to_string(), + allow: true, + scopes: vec![], + deny: vec!["security".to_string()], + notes: None, + thresholds: vec![], + }; + assert!(!is_scope_allowed(&d, "security")); + assert!(is_scope_allowed(&d, "eco")); + } + + #[test] + fn test_fully_denied() { + let d = BotDirective { + bot: "test".to_string(), + allow: false, + scopes: vec![], + deny: vec![], + notes: None, + thresholds: vec![], + }; + assert!(!is_scope_allowed(&d, "anything")); + } + + #[test] + fn test_parse_typical_bot_directive() { + let dir = TempDir::new().unwrap(); + write_directive( + dir.path(), + "echidnabot", + r#" +schema_version = "1.0" +directive_type = "bot-directive" +bot = "echidnabot" +scope = "formal verification and fuzzing" +allow = ["analysis", "fuzzing", "proof checks"] +deny = ["write to core modules", "write to bindings"] +notes = "May open findings; code changes require explicit approval" +"#, + ); + + let directive = check_directive(dir.path(), "echidnabot").expect("should parse"); + assert_eq!(directive.bot, "echidnabot"); + assert!(directive.allow); + assert!(directive.scopes.contains(&"fuzzing".to_string())); + assert!(directive + .scopes + .contains(&"formal verification and fuzzing".to_string())); + assert!(directive + .deny + .contains(&"write to core modules".to_string())); + assert!(directive.notes.is_some()); + } + + #[test] + fn test_parse_allow_false() { + let dir = TempDir::new().unwrap(); + write_directive( + dir.path(), + "rhodibot", + r#" +schema_version = "1.0" +bot = "rhodibot" +allow = false +"#, + ); + + let directive = check_directive(dir.path(), "rhodibot").expect("should parse"); + assert!(!directive.allow); + assert!(!is_scope_allowed(&directive, "anything")); + } + + #[test] + fn test_missing_file_returns_none() { + let dir = TempDir::new().unwrap(); + assert!(check_directive(dir.path(), "nonexistent").is_none()); + } +} diff --git a/crates/oikosbot-analysis/src/language.rs b/crates/oikosbot-analysis/src/language.rs new file mode 100644 index 0000000..3fb8cd5 --- /dev/null +++ b/crates/oikosbot-analysis/src/language.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Language detection and support + +use anyhow::{bail, Result}; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Language { + Rust, + JavaScript, + TypeScript, + Python, +} + +impl Language { + /// Detect language from file extension + pub fn detect(path: &Path) -> Result { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + + match ext { + "rs" => Ok(Language::Rust), + "js" | "mjs" | "cjs" => Ok(Language::JavaScript), + "ts" | "mts" | "cts" => Ok(Language::TypeScript), + "py" | "pyw" => Ok(Language::Python), + _ => bail!("Unsupported file extension: {}", ext), + } + } + + /// Get tree-sitter parser for this language + pub fn parser(&self) -> tree_sitter::Language { + match self { + Language::Rust => tree_sitter_rust::LANGUAGE.into(), + Language::JavaScript | Language::TypeScript => tree_sitter_javascript::LANGUAGE.into(), + Language::Python => tree_sitter_python::LANGUAGE.into(), + } + } + + /// Human-readable name + pub fn name(&self) -> &'static str { + match self { + Language::Rust => "Rust", + Language::JavaScript => "JavaScript", + Language::TypeScript => "TypeScript", + Language::Python => "Python", + } + } +} diff --git a/crates/oikosbot-analysis/src/lib.rs b/crates/oikosbot-analysis/src/lib.rs new file mode 100644 index 0000000..3048d4f --- /dev/null +++ b/crates/oikosbot-analysis/src/lib.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! # OikosBot Analysis Engine +//! +//! AST-based code analysis for ecological and economic metrics. +//! Built with Eclexia principles: explicit resource tracking. + +pub mod analyzer; +pub mod calibration; +pub mod carbon; +pub mod dependencies; +pub mod directives; +pub mod language; +pub mod migration; +pub mod patterns; +pub mod security; + +use anyhow::Result; +use oikosbot_metrics::AnalysisResult; +use std::path::Path; + +pub use analyzer::Analyzer; +pub use language::Language; + +/// Main entry point for analyzing a file +pub fn analyze_file(path: &Path) -> Result> { + let language = Language::detect(path)?; + let mut analyzer = Analyzer::new(language)?; + analyzer.analyze_file(path) +} + +/// Analyze source code directly +pub fn analyze_source(source: &str, language: Language) -> Result> { + let mut analyzer = Analyzer::new(language)?; + analyzer.analyze_source(source) +} diff --git a/crates/oikosbot-analysis/src/migration.rs b/crates/oikosbot-analysis/src/migration.rs new file mode 100644 index 0000000..0730bab --- /dev/null +++ b/crates/oikosbot-analysis/src/migration.rs @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Migration Health Tracking for OikosBot +//! +//! Extends oikosbot's analysis with ReScript migration health monitoring. +//! Tracks migration health scores over time and alerts on regressions. +//! +//! Integration points: +//! - Reads panic-attack migration-snapshot JSON outputs +//! - Produces SARIF-compatible findings for migration regressions +//! - Feeds health deltas into the fleet dispatch pipeline + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Migration health snapshot (parsed from panic-attack output) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationHealthSnapshot { + /// Repository or target path + pub target: PathBuf, + /// Snapshot label + pub label: String, + /// ISO 8601 timestamp + pub timestamp: String, + /// Overall health score (0.0 - 1.0) + pub health_score: f64, + /// Count of deprecated API usages + pub deprecated_count: usize, + /// Count of modern API usages + pub modern_count: usize, + /// Config format: bsconfig, rescript_json, both, none + pub config_format: String, + /// Detected version bracket + pub version_bracket: String, +} + +/// A migration health regression finding +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MigrationRegression { + /// Repository affected + pub target: PathBuf, + /// Previous health score + pub previous_score: f64, + /// Current health score + pub current_score: f64, + /// Health score delta (negative = regression) + pub delta: f64, + /// What regressed + pub reason: RegressionReason, + /// SARIF-compatible rule ID + pub rule_id: String, + /// Human-readable description + pub description: String, +} + +/// Reason for a migration regression +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegressionReason { + /// Health score dropped + HealthScoreDrop, + /// Deprecated API count increased + DeprecatedCountIncrease, + /// Config format reverted (e.g., rescript.json → bsconfig.json) + ConfigFormatRevert, + /// Version bracket downgrade + VersionBracketDowngrade, +} + +impl std::fmt::Display for RegressionReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::HealthScoreDrop => write!(f, "Migration health score dropped"), + Self::DeprecatedCountIncrease => write!(f, "Deprecated API usage increased"), + Self::ConfigFormatRevert => write!(f, "Config format reverted"), + Self::VersionBracketDowngrade => write!(f, "Version bracket downgraded"), + } + } +} + +/// Migration health tracker +/// +/// Compares successive snapshots to detect regressions and track +/// migration progress over time. +pub struct MigrationHealthTracker; + +impl MigrationHealthTracker { + /// Compare two snapshots and detect regressions. + /// + /// Returns a list of regression findings. An empty list means + /// migration health is stable or improving. + pub fn detect_regressions( + previous: &MigrationHealthSnapshot, + current: &MigrationHealthSnapshot, + ) -> Vec { + let mut regressions = Vec::new(); + + // Health score regression (threshold: -0.05 = 5% drop) + let health_delta = current.health_score - previous.health_score; + if health_delta < -0.05 { + regressions.push(MigrationRegression { + target: current.target.clone(), + previous_score: previous.health_score, + current_score: current.health_score, + delta: health_delta, + reason: RegressionReason::HealthScoreDrop, + rule_id: "oikosbot/migration-health-regression".to_string(), + description: format!( + "Migration health score dropped from {:.2} to {:.2} ({:+.2})", + previous.health_score, current.health_score, health_delta + ), + }); + } + + // Deprecated count regression + if current.deprecated_count > previous.deprecated_count { + let increase = current.deprecated_count - previous.deprecated_count; + regressions.push(MigrationRegression { + target: current.target.clone(), + previous_score: previous.health_score, + current_score: current.health_score, + delta: -(increase as f64), + reason: RegressionReason::DeprecatedCountIncrease, + rule_id: "oikosbot/migration-deprecated-increase".to_string(), + description: format!( + "Deprecated API usage increased by {} (from {} to {})", + increase, previous.deprecated_count, current.deprecated_count + ), + }); + } + + // Config format regression (rescript_json → bsconfig is a downgrade) + if previous.config_format == "rescript_json" && current.config_format == "bsconfig" { + regressions.push(MigrationRegression { + target: current.target.clone(), + previous_score: previous.health_score, + current_score: current.health_score, + delta: -0.2, // Config revert is a significant regression + reason: RegressionReason::ConfigFormatRevert, + rule_id: "oikosbot/migration-config-revert".to_string(), + description: "Config format reverted from rescript.json to bsconfig.json" + .to_string(), + }); + } + + // Version bracket downgrade + let prev_rank = version_bracket_rank(&previous.version_bracket); + let curr_rank = version_bracket_rank(¤t.version_bracket); + if curr_rank < prev_rank { + regressions.push(MigrationRegression { + target: current.target.clone(), + previous_score: previous.health_score, + current_score: current.health_score, + delta: (curr_rank as f64 - prev_rank as f64) * 0.1, + reason: RegressionReason::VersionBracketDowngrade, + rule_id: "oikosbot/migration-version-downgrade".to_string(), + description: format!( + "Version bracket downgraded from {} to {}", + previous.version_bracket, current.version_bracket + ), + }); + } + + regressions + } + + /// Compute a migration velocity metric. + /// + /// Given a series of snapshots ordered by time, computes the average + /// health improvement per snapshot interval. + pub fn compute_velocity(snapshots: &[MigrationHealthSnapshot]) -> f64 { + if snapshots.len() < 2 { + return 0.0; + } + + let total_delta = snapshots + .last() + .expect("snapshots non-empty: len >= 2 checked above") + .health_score + - snapshots + .first() + .expect("snapshots non-empty: len >= 2 checked above") + .health_score; + total_delta / (snapshots.len() - 1) as f64 + } + + /// Load a migration health snapshot from a JSON file. + /// + /// Reads the panic-attack migration-snapshot output format. + pub fn load_snapshot(path: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("reading migration snapshot from {}", path.display()))?; + let raw: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("parsing migration snapshot JSON from {}", path.display()))?; + + // Correctness-critical fields: a missing/malformed value here must NOT + // be silently defaulted. Defaulting `health_score` to 0.0 or + // `deprecated_api_count` to 0 would fabricate or mask regressions that + // feed the fleet dispatch pipeline, so propagate a hard error instead. + let health_score = raw["health_score"].as_f64().with_context(|| { + format!( + "migration snapshot {} is missing a numeric `health_score`", + path.display() + ) + })?; + let deprecated_count = raw["deprecated_api_count"].as_u64().with_context(|| { + format!( + "migration snapshot {} is missing an integer `deprecated_api_count`", + path.display() + ) + })? as usize; + let modern_count = raw["modern_api_count"].as_u64().with_context(|| { + format!( + "migration snapshot {} is missing an integer `modern_api_count`", + path.display() + ) + })? as usize; + let target = raw["target_path"].as_str().with_context(|| { + format!( + "migration snapshot {} is missing a string `target_path`", + path.display() + ) + })?; + let config_format = raw["config_format"].as_str().with_context(|| { + format!( + "migration snapshot {} is missing a string `config_format`", + path.display() + ) + })?; + let version_bracket = raw["version_bracket"].as_str().with_context(|| { + format!( + "migration snapshot {} is missing a string `version_bracket`", + path.display() + ) + })?; + + Ok(MigrationHealthSnapshot { + target: PathBuf::from(target), + // `label`/`timestamp` are descriptive only and do not affect + // regression detection, so a missing value falls back benignly. + label: raw["label"].as_str().unwrap_or("unknown").to_string(), + timestamp: raw["timestamp"].as_str().unwrap_or("unknown").to_string(), + health_score, + deprecated_count, + modern_count, + config_format: config_format.to_string(), + version_bracket: version_bracket.to_string(), + }) + } +} + +/// Rank version brackets for comparison (higher = more modern) +fn version_bracket_rank(bracket: &str) -> u8 { + match bracket { + "BuckleScript" => 1, + "V11" => 2, + "V12Alpha" => 3, + "V12Stable" => 4, + "V12Current" => 5, + "V13PreRelease" => 6, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_snapshot( + health: f64, + deprecated: usize, + config: &str, + version: &str, + ) -> MigrationHealthSnapshot { + MigrationHealthSnapshot { + target: PathBuf::from("/tmp/test-repo"), + label: "test".to_string(), + timestamp: "2026-03-01T10:00:00Z".to_string(), + health_score: health, + deprecated_count: deprecated, + modern_count: 50, + config_format: config.to_string(), + version_bracket: version.to_string(), + } + } + + #[test] + fn test_no_regression_on_improvement() { + let prev = make_snapshot(0.5, 40, "bsconfig", "V11"); + let curr = make_snapshot(0.7, 20, "rescript_json", "V12Stable"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(regressions.is_empty()); + } + + #[test] + fn test_health_score_regression() { + let prev = make_snapshot(0.8, 10, "rescript_json", "V12Current"); + let curr = make_snapshot(0.6, 10, "rescript_json", "V12Current"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(regressions + .iter() + .any(|r| r.reason == RegressionReason::HealthScoreDrop)); + } + + #[test] + fn test_deprecated_count_regression() { + let prev = make_snapshot(0.7, 10, "rescript_json", "V12Stable"); + let curr = make_snapshot(0.7, 25, "rescript_json", "V12Stable"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(regressions + .iter() + .any(|r| r.reason == RegressionReason::DeprecatedCountIncrease)); + } + + #[test] + fn test_config_format_regression() { + let prev = make_snapshot(0.8, 5, "rescript_json", "V12Current"); + let curr = make_snapshot(0.8, 5, "bsconfig", "V12Current"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(regressions + .iter() + .any(|r| r.reason == RegressionReason::ConfigFormatRevert)); + } + + #[test] + fn test_version_bracket_regression() { + let prev = make_snapshot(0.8, 5, "rescript_json", "V12Stable"); + let curr = make_snapshot(0.8, 5, "rescript_json", "V11"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(regressions + .iter() + .any(|r| r.reason == RegressionReason::VersionBracketDowngrade)); + } + + #[test] + fn test_small_health_drop_not_flagged() { + // 3% drop should not trigger (threshold is 5%) + let prev = make_snapshot(0.8, 10, "rescript_json", "V12Current"); + let curr = make_snapshot(0.77, 10, "rescript_json", "V12Current"); + let regressions = MigrationHealthTracker::detect_regressions(&prev, &curr); + assert!(!regressions + .iter() + .any(|r| r.reason == RegressionReason::HealthScoreDrop)); + } + + #[test] + fn test_migration_velocity() { + let snapshots = vec![ + make_snapshot(0.3, 50, "bsconfig", "V11"), + make_snapshot(0.5, 30, "bsconfig", "V11"), + make_snapshot(0.7, 15, "rescript_json", "V12Stable"), + make_snapshot(0.85, 5, "rescript_json", "V12Current"), + ]; + let velocity = MigrationHealthTracker::compute_velocity(&snapshots); + // (0.85 - 0.3) / 3 = 0.183... + assert!(velocity > 0.15); + assert!(velocity < 0.25); + } + + #[test] + fn test_migration_velocity_single_snapshot() { + let snapshots = vec![make_snapshot(0.5, 20, "bsconfig", "V11")]; + let velocity = MigrationHealthTracker::compute_velocity(&snapshots); + assert_eq!(velocity, 0.0); + } + + #[test] + fn test_version_bracket_rank() { + assert!(version_bracket_rank("V12Current") > version_bracket_rank("V11")); + assert!(version_bracket_rank("V13PreRelease") > version_bracket_rank("V12Current")); + assert!(version_bracket_rank("BuckleScript") < version_bracket_rank("V11")); + } +} diff --git a/crates/oikosbot-analysis/src/patterns.rs b/crates/oikosbot-analysis/src/patterns.rs new file mode 100644 index 0000000..d00bc5f --- /dev/null +++ b/crates/oikosbot-analysis/src/patterns.rs @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Pattern detection for inefficient code + +/// A detected inefficiency pattern with metadata +#[derive(Debug, Clone)] +pub struct PatternMatch { + /// Machine-readable pattern name (e.g. "nested-loops") + pub name: String, + /// Human-readable description + pub description: String, + /// Concrete fix suggestion for SARIF output + pub suggestion: Option, + /// Estimated energy impact multiplier (1.0 = normal, >1 = worse) + pub impact_multiplier: f64, +} + +/// Detect problematic patterns in code +pub fn detect_patterns(_source: &str, node: &tree_sitter::Node) -> Vec { + let mut patterns = Vec::new(); + + // Check nested loops + let loop_depth = count_loop_depth(node); + if loop_depth >= 3 { + patterns.push(PatternMatch { + name: "nested-loops".to_string(), + description: format!( + "Deeply nested loops (depth {}): O(n^{}) complexity", + loop_depth, loop_depth + ), + suggestion: Some( + "Consider algorithm optimization to reduce nested iterations. \ + Use hash maps for lookups, sort + binary search, or restructure as flat iteration." + .to_string(), + ), + impact_multiplier: loop_depth as f64, + }); + } + + // Check for busy-wait loops (loop/while with no sleep/await/yield) + if has_busy_wait(_source, node) { + patterns.push(PatternMatch { + name: "busy-wait".to_string(), + description: "Loop without sleep, await, or yield burns CPU continuously".to_string(), + suggestion: Some( + "Replace busy-wait with async/await, tokio::time::sleep, \ + std::thread::sleep, or a channel recv." + .to_string(), + ), + impact_multiplier: 5.0, + }); + } + + // Check for string concatenation in loops + if has_string_concat_in_loop(_source, node) { + patterns.push(PatternMatch { + name: "string-concat-in-loop".to_string(), + description: "String concatenation inside loop causes repeated allocation".to_string(), + suggestion: Some( + "Use String::with_capacity() and push_str(), or collect with \ + iterators instead of concatenating in a loop." + .to_string(), + ), + impact_multiplier: 2.0, + }); + } + + // Check for clone in loops + if has_clone_in_loop(_source, node) { + patterns.push(PatternMatch { + name: "clone-in-loop".to_string(), + description: ".clone() inside loop body causes repeated deep copies".to_string(), + suggestion: Some( + "Consider borrowing instead of cloning, or move the clone \ + outside the loop if the value doesn't change per iteration." + .to_string(), + ), + impact_multiplier: 1.5, + }); + } + + // Check for unbuffered I/O + if has_unbuffered_io(_source, node) { + patterns.push(PatternMatch { + name: "unbuffered-io".to_string(), + description: "Read/write without BufReader/BufWriter causes excessive syscalls" + .to_string(), + suggestion: Some( + "Wrap the reader/writer with BufReader/BufWriter for \ + buffered I/O to reduce system call overhead." + .to_string(), + ), + impact_multiplier: 3.0, + }); + } + + // Check for large allocations + if has_large_allocation(_source, node) { + patterns.push(PatternMatch { + name: "large-allocation".to_string(), + description: "Large heap allocation (>1MB) detected".to_string(), + suggestion: Some( + "Review memory allocation size. Consider streaming, \ + chunked processing, or memory-mapped files for large data." + .to_string(), + ), + impact_multiplier: 2.0, + }); + } + + // Check for redundant allocations (.to_string()/.to_owned() where borrow suffices) + if has_redundant_to_string(_source, node) { + patterns.push(PatternMatch { + name: "redundant-allocation".to_string(), + description: "Unnecessary .to_string()/.to_owned() where a borrow would suffice" + .to_string(), + suggestion: Some( + "Accept &str instead of String where possible to avoid \ + unnecessary heap allocation." + .to_string(), + ), + impact_multiplier: 1.2, + }); + } + + patterns +} + +fn count_loop_depth(node: &tree_sitter::Node) -> usize { + let is_loop = matches!( + node.kind(), + "for_expression" + | "while_expression" + | "loop_expression" + | "for_statement" + | "while_statement" + ); + + let mut max_child_depth = 0; + let mut cursor = node.walk(); + + if cursor.goto_first_child() { + loop { + let child_depth = count_loop_depth(&cursor.node()); + max_child_depth = max_child_depth.max(child_depth); + + if !cursor.goto_next_sibling() { + break; + } + } + } + + if is_loop { + 1 + max_child_depth + } else { + max_child_depth + } +} + +/// Check if a loop body contains no sleep/await/yield/recv — indicating a busy wait +fn has_busy_wait(source: &str, node: &tree_sitter::Node) -> bool { + let is_loop = matches!(node.kind(), "loop_expression" | "while_expression"); + + if !is_loop { + // Recurse into children to find loops + let mut cursor = node.walk(); + if cursor.goto_first_child() { + loop { + if has_busy_wait(source, &cursor.node()) { + return true; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + return false; + } + + // Found a loop — check if its body text mentions sleep/await/yield/recv + let text = match node.utf8_text(source.as_bytes()) { + Ok(t) => t, + Err(_) => return false, + }; + + // If the loop body contains any blocking/yielding call, it's not a busy wait + let has_yield = text.contains("sleep") + || text.contains("await") + || text.contains("yield") + || text.contains("recv") + || text.contains("park") + || text.contains("wait") + || text.contains(".await"); + + !has_yield +} + +/// Check if any loop body contains string concatenation +fn has_string_concat_in_loop(source: &str, node: &tree_sitter::Node) -> bool { + find_in_loop_body( + node, + |child, _src| { + // Look for binary_expression with "+" operator on strings + // or format! macro calls + child.kind() == "binary_expression" || child.kind() == "macro_invocation" + }, + source, + ) +} + +/// Check if any loop body contains .clone() calls +fn has_clone_in_loop(source: &str, node: &tree_sitter::Node) -> bool { + find_in_loop_body( + node, + |child, src| { + if child.kind() == "call_expression" || child.kind() == "method_call_expression" { + if let Ok(text) = child.utf8_text(src.as_bytes()) { + return text.contains(".clone()"); + } + } + false + }, + source, + ) +} + +/// Check for File::open/create without BufReader/BufWriter nearby +fn has_unbuffered_io(source: &str, node: &tree_sitter::Node) -> bool { + let text = match node.utf8_text(source.as_bytes()) { + Ok(t) => t, + Err(_) => return false, + }; + + let has_file_io = text.contains("File::open") || text.contains("File::create"); + let has_buffering = text.contains("BufReader") || text.contains("BufWriter"); + + has_file_io && !has_buffering +} + +/// Check for large allocations: Vec::with_capacity(>1_000_000) or vec![0; large] +fn has_large_allocation(source: &str, node: &tree_sitter::Node) -> bool { + let text = match node.utf8_text(source.as_bytes()) { + Ok(t) => t, + Err(_) => return false, + }; + + // Simple heuristic: look for large numeric literals near allocation calls + if text.contains("with_capacity") || text.contains("vec![") { + // Check for numbers > 1_000_000 + for word in text.split(|c: char| !c.is_ascii_digit() && c != '_') { + let clean: String = word.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = clean.parse::() { + if n > 1_000_000 { + return true; + } + } + } + } + false +} + +/// Check for .to_string() or .to_owned() calls that may be redundant +fn has_redundant_to_string(source: &str, node: &tree_sitter::Node) -> bool { + let text = match node.utf8_text(source.as_bytes()) { + Ok(t) => t, + Err(_) => return false, + }; + + // Count occurrences as a heuristic — many .to_string() in one function is suspicious + let to_string_count = text.matches(".to_string()").count(); + let to_owned_count = text.matches(".to_owned()").count(); + + (to_string_count + to_owned_count) >= 5 +} + +/// Find a pattern match inside loop bodies +fn find_in_loop_body( + node: &tree_sitter::Node, + predicate: impl Fn(&tree_sitter::Node, &str) -> bool + Copy, + source: &str, +) -> bool { + let is_loop = matches!( + node.kind(), + "for_expression" + | "while_expression" + | "loop_expression" + | "for_statement" + | "while_statement" + ); + + if is_loop { + // Search inside loop body for the pattern + return subtree_matches(node, predicate, source); + } + + // Recurse to find loops + let mut cursor = node.walk(); + if cursor.goto_first_child() { + loop { + if find_in_loop_body(&cursor.node(), predicate, source) { + return true; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + false +} + +/// Check if any node in the subtree matches the predicate +fn subtree_matches( + node: &tree_sitter::Node, + predicate: impl Fn(&tree_sitter::Node, &str) -> bool + Copy, + source: &str, +) -> bool { + if predicate(node, source) { + return true; + } + let mut cursor = node.walk(); + if cursor.goto_first_child() { + loop { + if subtree_matches(&cursor.node(), predicate, source) { + return true; + } + if !cursor.goto_next_sibling() { + break; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pattern_match_fields() { + let pm = PatternMatch { + name: "test".to_string(), + description: "Test pattern".to_string(), + suggestion: Some("Fix it".to_string()), + impact_multiplier: 1.5, + }; + assert_eq!(pm.name, "test"); + assert!(pm.suggestion.is_some()); + assert!(pm.impact_multiplier > 1.0); + } +} diff --git a/crates/oikosbot-analysis/src/security.rs b/crates/oikosbot-analysis/src/security.rs new file mode 100644 index 0000000..a29d12c --- /dev/null +++ b/crates/oikosbot-analysis/src/security.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! Security-sustainability correlation engine. +//! +//! Maps panic-attack weak points to sustainability impact, producing +//! findings that correlate security risk with ecological cost. +//! +//! This module is only available when the `panic-attack` feature is enabled. + +#[cfg(feature = "panic-attack")] +mod inner { + use anyhow::Result; + use oikosbot_metrics::*; + use std::path::Path; + + /// Security-sustainability correlation result + #[derive(Debug, Clone)] + pub struct SecurityCorrelation { + /// The original analysis results for the repo + pub eco_results: Vec, + /// Security-derived sustainability findings + pub security_findings: Vec, + /// Composite score (0-100) combining security and eco scores + pub composite_score: f64, + } + + /// Run panic-attack scan and correlate with sustainability analysis. + pub fn correlate( + repo_path: &Path, + eco_results: &[AnalysisResult], + ) -> Result { + // Run panic-attack scan + let report = panic_attack::xray::analyze(repo_path)?; + + let mut security_findings = Vec::new(); + + for wp in &report.weak_points { + let (impact_desc, energy_multiplier) = match wp.category { + panic_attack::types::WeakPointCategory::UnsafeCode => ( + "Crash risk from unsafe code: all prior computation wasted on crash", + 3.0, + ), + panic_attack::types::WeakPointCategory::PanicPath => ( + "Panic/abort path: energy and carbon invested in computation becomes sunk cost", + 2.5, + ), + panic_attack::types::WeakPointCategory::UnboundedLoop => { + ("Unbounded loop: potential CPU waste and carbon spike", 4.0) + } + panic_attack::types::WeakPointCategory::UncheckedAllocation => ( + "Unchecked allocation: memory waste from potential OOM or oversized alloc", + 2.0, + ), + panic_attack::types::WeakPointCategory::ResourceLeak => ( + "Resource leak: ongoing waste of handles, goroutines, or memory", + 3.5, + ), + panic_attack::types::WeakPointCategory::RaceCondition => ( + "Race condition: unpredictable resource usage, potential for wasted retries", + 2.0, + ), + panic_attack::types::WeakPointCategory::BlockingIO => ( + "Blocking I/O: thread held idle, wasting CPU time and energy", + 1.5, + ), + panic_attack::types::WeakPointCategory::DeadlockPotential => ( + "Deadlock potential: complete CPU waste when threads stall", + 4.0, + ), + }; + + let severity_str = format!("{}", wp.severity); + + // Estimate energy impact based on severity and category + let base_energy = match wp.severity { + panic_attack::types::Severity::Critical => 100.0, + panic_attack::types::Severity::High => 50.0, + panic_attack::types::Severity::Medium => 20.0, + panic_attack::types::Severity::Low => 5.0, + }; + + let energy = Energy::joules(base_energy * energy_multiplier); + let carbon = crate::carbon::estimate_carbon(energy); + + let file = wp + .location + .clone() + .unwrap_or_else(|| "".to_string()); + + // Check if any eco result overlaps this location (boost severity) + let has_eco_overlap = eco_results.iter().any(|r| r.location.file == file); + let boost = if has_eco_overlap { 1.5 } else { 1.0 }; + + let eco_score = EcoScore::new((100.0 - (energy.0 * boost).ln() * 10.0).max(0.0)); + let econ_score = EconScore::new(50.0); // neutral economic impact + + let health = HealthIndex::compute(eco_score, econ_score, 50.0); + + let finding = AnalysisResult { + location: CodeLocation { + file, + line: 0, // panic-attack doesn't provide line numbers in WeakPoint + column: 0, + end_line: None, + end_column: None, + name: Some(format!("{:?}", wp.category)), + }, + resources: ResourceProfile { + energy, + duration: Duration::milliseconds(base_energy * 0.5), + carbon, + memory: Memory::kilobytes((base_energy * 10.0) as usize), + }, + health, + recommendations: vec![ + format!("[{}] {}", severity_str, impact_desc), + wp.description.clone(), + ], + rule_id: "oikosbot/security-sustainability".to_string(), + suggestion: Some(format!( + "Address {:?} weak point to prevent {} energy waste", + wp.category, impact_desc + )), + end_location: None, + confidence: Confidence::Estimated, + }; + + security_findings.push(finding); + } + + // Calculate composite score + let all_eco: Vec = eco_results + .iter() + .chain(security_findings.iter()) + .map(|r| r.health.eco_score.0) + .collect(); + + let composite_score = if all_eco.is_empty() { + 100.0 + } else { + all_eco.iter().sum::() / all_eco.len() as f64 + }; + + Ok(SecurityCorrelation { + eco_results: eco_results.to_vec(), + security_findings, + composite_score, + }) + } +} + +#[cfg(feature = "panic-attack")] +pub use inner::*; + +/// Stub types available even without the feature, for API compatibility +#[cfg(not(feature = "panic-attack"))] +pub mod stub { + /// Security correlation is unavailable without the `panic-attack` feature. + pub fn is_available() -> bool { + false + } +} + +#[cfg(not(feature = "panic-attack"))] +pub use stub::*; diff --git a/crates/oikosbot-cli/Cargo.toml b/crates/oikosbot-cli/Cargo.toml new file mode 100644 index 0000000..4c840be --- /dev/null +++ b/crates/oikosbot-cli/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[package] +name = "oikosbot-cli" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "oikosbot" +path = "src/main.rs" + +[features] +default = [] +security = ["oikosbot-analysis/panic-attack"] + +[dependencies] +oikosbot-analysis = { path = "../oikosbot-analysis" } +oikosbot-metrics = { path = "../oikosbot-metrics" } +oikosbot-sarif = { path = "../oikosbot-sarif" } +oikosbot-eclexia = { path = "../oikosbot-eclexia" } +# NB: `oikosbot-fleet` (the gitbot-fleet bridge) is deliberately NOT a dependency +# here. It is excluded from the default workspace so OikosBot stays standalone. +# The `fleet` subcommand prints how to run the bridge; see crates/oikosbot-fleet. +clap.workspace = true +anyhow.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +walkdir = "2" diff --git a/crates/oikosbot-cli/src/main.rs b/crates/oikosbot-cli/src/main.rs new file mode 100644 index 0000000..a4a6958 --- /dev/null +++ b/crates/oikosbot-cli/src/main.rs @@ -0,0 +1,509 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! # OikosBot CLI +//! +//! Ecological and economic code analysis tool. +//! Built with Eclexia principles - proving resource-aware design works. + +#![forbid(unsafe_code)] +use anyhow::Result; +use clap::{Parser, Subcommand}; +use oikosbot_analysis::analyze_file; +use std::fs; +use std::path::PathBuf; +use tracing::info; +use walkdir::WalkDir; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +#[command(name = "oikosbot")] +#[command(about = "Ecological & Economic Code Analysis", long_about = None)] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Enable verbose logging + #[arg(short, long, global = true)] + verbose: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// Analyze a single file + Analyze { + /// File to analyze + file: PathBuf, + + /// Output format (text, json, sarif) + #[arg(short, long, default_value = "text")] + format: String, + + /// Write output to file instead of stdout + #[arg(short, long)] + output: Option, + }, + + /// Analyze a directory recursively + Check { + /// Directory to check + path: PathBuf, + + /// Minimum eco score threshold (0-100) + #[arg(long, default_value = "50")] + eco_threshold: f64, + + /// Output format (text, json, sarif) + #[arg(short, long, default_value = "text")] + format: String, + + /// Write output to file instead of stdout + #[arg(short, long)] + output: Option, + + /// Include security-sustainability correlation (requires panic-attack feature) + #[arg(long)] + security: bool, + + /// Directory containing Eclexia policy files (.ecl) + #[arg(long)] + policy_dir: Option, + }, + + /// Generate a full report for a directory (alias for check with defaults) + Report { + /// Directory to analyze + path: PathBuf, + + /// Output format (text, json, sarif) + #[arg(short, long, default_value = "sarif")] + format: String, + + /// Write output to file instead of stdout + #[arg(short, long)] + output: Option, + + /// Minimum eco score threshold (0-100) + #[arg(long, default_value = "50")] + eco_threshold: f64, + + /// Include security-sustainability correlation (requires panic-attack feature) + #[arg(long)] + security: bool, + + /// Directory containing Eclexia policy files (.ecl) + #[arg(long)] + policy_dir: Option, + }, + + /// Run as a gitbot-fleet member + Fleet { + /// Repository path to analyze + path: PathBuf, + + /// Path to shared context JSON file + #[arg(short, long)] + context: Option, + }, + + /// Show analysis of oikosbot itself (dogfooding!) + SelfAnalyze, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Set up logging + let log_level = if cli.verbose { "debug" } else { "info" }; + tracing_subscriber::fmt().with_env_filter(log_level).init(); + + match cli.command { + Commands::Analyze { + file, + format, + output, + } => { + info!("Analyzing file: {}", file.display()); + let results = analyze_file(&file)?; + emit_output(&results, &format, output.as_deref())?; + } + + Commands::Check { + path, + eco_threshold, + format, + output, + security, + policy_dir, + } + | Commands::Report { + path, + format, + output, + eco_threshold, + security, + policy_dir, + } => { + info!("Checking directory: {}", path.display()); + + let mut all_results = collect_directory_results(&path)?; + + // Security-sustainability correlation + if security { + run_security_correlation(&path, &mut all_results); + } + + // Policy evaluation + if let Some(ref pdir) = policy_dir { + run_policy_evaluation(pdir, &mut all_results); + } + + // Emit formatted output + match format.as_str() { + "sarif" | "json" => { + emit_output(&all_results, &format, output.as_deref())?; + } + "text" => { + println!( + "Checking directory: {} (eco threshold: {})\n", + path.display(), + eco_threshold + ); + + let mut files_below_threshold = 0u32; + for result in &all_results { + if result.health.eco_score.0 < eco_threshold { + files_below_threshold += 1; + println!( + " BELOW THRESHOLD: {} :: {} (eco: {:.1}, threshold: {})", + result.location.file, + result.location.name.as_deref().unwrap_or(""), + result.health.eco_score.0, + eco_threshold + ); + } + } + + print_summary(&all_results, eco_threshold, files_below_threshold); + + if let Some(ref out_path) = output { + // Also write text summary to file + let text = format_results_text(&all_results); + fs::write(out_path, text)?; + println!("\nOutput written to: {}", out_path.display()); + } + + if files_below_threshold > 0 { + std::process::exit(1); + } + } + _ => { + eprintln!("Unsupported format: {}", format); + } + } + } + + Commands::Fleet { path, context } => { + // The gitbot-fleet bridge (`oikosbot-fleet`) is intentionally NOT a + // dependency of the standalone CLI — OikosBot and gitbot-fleet are + // separate projects. The bridge crate is excluded from the default + // workspace; build it explicitly when wiring OikosBot into the fleet. + let ctx = context + .as_deref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + eprintln!( + "Fleet integration lives in the optional `oikosbot-fleet` crate, which is\n\ + excluded from the default workspace so OikosBot builds standalone.\n\n\ + To run it (with hyperpolymath/gitbot-fleet checked out as a sibling):\n \ + cargo run --manifest-path crates/oikosbot-fleet/Cargo.toml -- {} {}\n\n\ + See crates/oikosbot-fleet/README.md and DISAMBIGUATION.adoc.", + path.display(), + ctx, + ); + } + + Commands::SelfAnalyze => { + println!("OikosBot Self-Analysis (Dogfooding!)"); + println!("==========================================\n"); + println!("Analyzing oikosbot's own resource usage...\n"); + + let analyzer_src = PathBuf::from("crates/oikosbot-analysis/src/analyzer.rs"); + if analyzer_src.exists() { + let results = analyze_file(&analyzer_src)?; + print_results_text(&results); + + println!("\nMeta-Analysis:"); + println!("This analyzer used minimal resources to analyze itself."); + println!("Eclexia-inspired design: explicit resource tracking from day 1."); + } else { + println!("Run from oikosbot repository root."); + } + } + } + + Ok(()) +} + +/// Collect analysis results from all supported files in a directory +fn collect_directory_results( + path: &std::path::Path, +) -> Result> { + let mut all_results = Vec::new(); + + for entry in WalkDir::new(path) + .follow_links(false) + .into_iter() + .filter_entry(|e| { + let name = e.file_name().to_str().unwrap_or(""); + !matches!( + name, + "target" | "node_modules" | ".git" | "dist" | "build" | ".cache" + ) + }) + .filter_map(|e| e.ok()) + { + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + + let ext = entry_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + if !matches!(ext, "rs" | "js" | "py") { + continue; + } + + match analyze_file(entry_path) { + Ok(results) => { + all_results.extend(results); + } + Err(e) => { + info!("Skipping {}: {}", entry_path.display(), e); + } + } + } + + Ok(all_results) +} + +/// Emit analysis results in the requested format +fn emit_output( + results: &[oikosbot_metrics::AnalysisResult], + format: &str, + output: Option<&std::path::Path>, +) -> Result<()> { + let text = match format { + "sarif" => oikosbot_sarif::to_sarif_json(results, VERSION)?, + "json" => serde_json::to_string_pretty(results)?, + "text" => { + print_results_text(results); + return Ok(()); + } + other => { + eprintln!("Unsupported format: {}", other); + return Ok(()); + } + }; + + match output { + Some(path) => { + fs::write(path, &text)?; + eprintln!("Output written to: {}", path.display()); + } + None => { + println!("{}", text); + } + } + + Ok(()) +} + +fn print_summary( + all_results: &[oikosbot_metrics::AnalysisResult], + eco_threshold: f64, + files_below_threshold: u32, +) { + let total_files = all_results + .iter() + .map(|r| r.location.file.as_str()) + .collect::>() + .len(); + + println!("\n--- Summary ---"); + println!("Files analyzed: {}", total_files); + println!("Functions found: {}", all_results.len()); + println!("Below threshold: {}", files_below_threshold); + + if !all_results.is_empty() { + let avg_eco: f64 = all_results + .iter() + .map(|r| r.health.eco_score.0) + .sum::() + / all_results.len() as f64; + let avg_overall: f64 = + all_results.iter().map(|r| r.health.overall).sum::() / all_results.len() as f64; + let total_energy: f64 = all_results.iter().map(|r| r.resources.energy.0).sum(); + let total_carbon: f64 = all_results.iter().map(|r| r.resources.carbon.0).sum(); + + println!("Avg eco score: {:.1}/100", avg_eco); + println!("Avg overall health: {:.1}/100", avg_overall); + println!("Total est. energy: {:.2} J", total_energy); + println!("Total est. carbon: {:.4} gCO2e", total_carbon); + } + + if files_below_threshold > 0 { + println!( + "\nResult: FAIL ({} functions below eco threshold {})", + files_below_threshold, eco_threshold + ); + } else { + println!( + "\nResult: PASS (all functions meet eco threshold {})", + eco_threshold + ); + } +} + +fn print_results_text(results: &[oikosbot_metrics::AnalysisResult]) { + for result in results { + println!( + "\nFunction: {}", + result.location.name.as_deref().unwrap_or("") + ); + println!( + " Location: {}:{}:{}", + result.location.file, result.location.line, result.location.column + ); + println!("\n Resources:"); + println!(" Energy: {:.2} J", result.resources.energy.0); + println!(" Time: {:.2} ms", result.resources.duration.0); + println!(" Carbon: {:.4} gCO2e", result.resources.carbon.0); + println!(" Memory: {} bytes", result.resources.memory.0); + + println!("\n Health Index:"); + println!(" Eco: {:.1}/100", result.health.eco_score.0); + println!(" Econ: {:.1}/100", result.health.econ_score.0); + println!(" Quality: {:.1}/100", result.health.quality_score); + println!(" Overall: {:.1}/100", result.health.overall); + + if !result.recommendations.is_empty() { + println!("\n Recommendations:"); + for rec in &result.recommendations { + println!(" - {}", rec); + } + } + } + + println!("\nAnalysis complete"); +} + +/// Run Eclexia policy evaluation +fn run_policy_evaluation( + policy_dir: &std::path::Path, + results: &mut Vec, +) { + match oikosbot_eclexia::evaluate_policies(policy_dir, results) { + Ok(decisions) => { + let warns = decisions + .iter() + .filter(|d| d.outcome != oikosbot_eclexia::PolicyOutcome::Pass) + .count(); + eprintln!( + "Policy evaluation: {} policies, {} warnings/failures", + decisions.len(), + warns + ); + for d in &decisions { + if d.outcome != oikosbot_eclexia::PolicyOutcome::Pass { + eprintln!(" {:?}: {} - {}", d.outcome, d.policy_name, d.message); + } + } + // Convert policy decisions to analysis results for SARIF output + let policy_results = oikosbot_eclexia::decisions_to_results(&decisions); + results.extend(policy_results); + } + Err(e) => { + eprintln!("Policy evaluation failed: {}", e); + } + } +} + +/// Run security-sustainability correlation if the feature is available +fn run_security_correlation( + path: &std::path::Path, + _results: &mut Vec, +) { + // Check for .machine_readable/bot_directives/panic-attack.scm + let directive = oikosbot_analysis::directives::check_directive(path, "panic-attack"); + + match directive { + Some(ref d) if !d.allow => { + eprintln!( + "Security scan denied by .machine_readable/bot_directives/panic-attack.scm: {}", + d.notes.as_deref().unwrap_or("no reason given") + ); + return; + } + None => { + eprintln!( + "Warning: No .machine_readable/bot_directives/panic-attack.scm found in {}. \ + Running security scan anyway.", + path.display() + ); + } + _ => {} + } + + #[cfg(feature = "security")] + { + match oikosbot_analysis::security::correlate(path, results) { + Ok(correlation) => { + eprintln!( + "Security scan: {} findings, composite score: {:.1}", + correlation.security_findings.len(), + correlation.composite_score + ); + results.extend(correlation.security_findings); + } + Err(e) => { + eprintln!("Security scan failed: {}", e); + } + } + } + + #[cfg(not(feature = "security"))] + { + eprintln!("Security correlation unavailable: build with --features security"); + } +} + +fn format_results_text(results: &[oikosbot_metrics::AnalysisResult]) -> String { + let mut out = String::new(); + + for result in results { + out.push_str(&format!( + "\nFunction: {}\n", + result.location.name.as_deref().unwrap_or("") + )); + out.push_str(&format!( + " Location: {}:{}:{}\n", + result.location.file, result.location.line, result.location.column + )); + out.push_str(&format!(" Energy: {:.2} J\n", result.resources.energy.0)); + out.push_str(&format!( + " Carbon: {:.4} gCO2e\n", + result.resources.carbon.0 + )); + out.push_str(&format!( + " Eco: {:.1}/100 Overall: {:.1}/100\n", + result.health.eco_score.0, result.health.overall + )); + } + + out +} diff --git a/crates/oikosbot-eclexia/Cargo.toml b/crates/oikosbot-eclexia/Cargo.toml new file mode 100644 index 0000000..24d41ee --- /dev/null +++ b/crates/oikosbot-eclexia/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[package] +name = "oikosbot-eclexia" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Eclexia policy engine integration for oikosbot" + +[features] +default = [] +# Native Eclexia compiler integration. The upstream `eclexia` compiler crates +# (a separate hyperpolymath repo) were decoupled when OikosBot was extracted +# from gitbot-fleet so this crate builds standalone; the feature is retained as +# a no-op seam to be re-wired (path or git dep) when native evaluation is added. +# Until then the crate ships its lightweight built-in `.ecl` policy evaluator. +eclexia-native = [] + +[dependencies] +oikosbot-metrics = { path = "../oikosbot-metrics" } +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true diff --git a/crates/oikosbot-eclexia/src/lib.rs b/crates/oikosbot-eclexia/src/lib.rs new file mode 100644 index 0000000..79c54f1 --- /dev/null +++ b/crates/oikosbot-eclexia/src/lib.rs @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! # OikosBot-Eclexia Integration +//! +//! Policy engine integration with two backends: +//! - **Default**: shells out to `eclexia` binary (works without eclexia repo on disk) +//! - **Native** (`eclexia-native` feature): direct library integration via eclexia-interp +//! +//! Both backends implement the same `PolicyEngine` trait. + +#![forbid(unsafe_code)] +use anyhow::{Context, Result}; +use oikosbot_metrics::{AnalysisResult, ResourceProfile}; +use std::path::Path; + +/// Policy evaluation outcome +#[derive(Debug, Clone)] +pub struct PolicyDecision { + /// The verdict + pub outcome: PolicyOutcome, + /// Human-readable explanation + pub message: String, + /// Suggestion for fixing a policy violation + pub suggestion: Option, + /// Resource cost of evaluating this policy (dogfooding!) + pub evaluation_cost: Option, + /// Name of the policy that produced this decision + pub policy_name: String, +} + +/// Policy verdict +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyOutcome { + /// Code meets all policy criteria + Pass, + /// Code has issues worth noting but not blocking + Warn, + /// Code violates a hard policy requirement + Fail, +} + +/// Evaluate policies against analysis results. +/// +/// Uses the binary backend by default, or native eclexia-interp +/// when the `eclexia-native` feature is enabled. +pub fn evaluate_policies( + policy_dir: &Path, + results: &[AnalysisResult], +) -> Result> { + let mut decisions = Vec::new(); + + // Find all .ecl files in the policy directory + if !policy_dir.exists() { + return Ok(decisions); + } + + let entries = std::fs::read_dir(policy_dir) + .with_context(|| format!("Failed to read policy dir: {}", policy_dir.display()))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("ecl") { + let policy_name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + match evaluate_single_policy(&path, results) { + Ok(decision) => decisions.push(decision), + Err(e) => { + tracing::warn!("Policy {} failed: {}", policy_name, e); + decisions.push(PolicyDecision { + outcome: PolicyOutcome::Warn, + message: format!("Policy evaluation error: {}", e), + suggestion: None, + evaluation_cost: None, + policy_name, + }); + } + } + } + } + + Ok(decisions) +} + +/// Evaluate a single policy file against analysis results. +fn evaluate_single_policy( + policy_path: &Path, + results: &[AnalysisResult], +) -> Result { + let policy_name = policy_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + #[cfg(feature = "eclexia-native")] + { + evaluate_native(policy_path, results, &policy_name) + } + + #[cfg(not(feature = "eclexia-native"))] + { + evaluate_binary(policy_path, results, &policy_name) + } +} + +/// Binary backend: shells out to `eclexia` CLI +#[cfg(not(feature = "eclexia-native"))] +fn evaluate_binary( + policy_path: &Path, + results: &[AnalysisResult], + policy_name: &str, +) -> Result { + use std::process::Command; + + // Serialize results summary as JSON input + let input = serde_json::json!({ + "function_count": results.len(), + "total_energy": results.iter().map(|r| r.resources.energy.0).sum::(), + "total_carbon": results.iter().map(|r| r.resources.carbon.0).sum::(), + "avg_eco_score": if results.is_empty() { 100.0 } else { + results.iter().map(|r| r.health.eco_score.0).sum::() / results.len() as f64 + }, + "below_threshold_count": results.iter() + .filter(|r| r.health.eco_score.0 < 50.0) + .count(), + }); + + // Try to find eclexia binary + let eclexia = which_eclexia(); + + match eclexia { + Some(binary) => { + let output = Command::new(&binary) + .arg("run") + .arg(policy_path) + .arg("--input") + .arg(input.to_string()) + .output() + .with_context(|| format!("Failed to execute eclexia at {}", binary))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let result: bool = serde_json::from_str(stdout.trim()).unwrap_or(false); + + Ok(PolicyDecision { + outcome: if result { + PolicyOutcome::Warn + } else { + PolicyOutcome::Pass + }, + message: format!("Policy '{}' evaluated via eclexia binary", policy_name), + suggestion: None, + evaluation_cost: Some(placeholder_cost()), + policy_name: policy_name.to_string(), + }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Eclexia policy failed: {}", stderr); + } + } + None => { + // No eclexia binary found — use built-in policy evaluation + evaluate_builtin(results, policy_name) + } + } +} + +/// Native backend: direct eclexia-interp library integration +#[cfg(feature = "eclexia-native")] +fn evaluate_native( + policy_path: &Path, + results: &[AnalysisResult], + policy_name: &str, +) -> Result { + let source = std::fs::read_to_string(policy_path) + .with_context(|| format!("Failed to read policy: {}", policy_path.display()))?; + + let (ast, errors) = eclexia_parser::parse(&source); + + if !errors.is_empty() { + let error_msgs: Vec = errors.iter().map(|e| format!("{:?}", e)).collect(); + anyhow::bail!( + "Policy parse errors in {}: {}", + policy_name, + error_msgs.join("; ") + ); + } + + // Run the policy through the interpreter + let mut interp = eclexia_interp::Interpreter::new(); + // Set a tight budget for policy evaluation itself (dogfooding!) + interp.set_energy_budget(1.0); // 1 Joule max for policy eval + interp.set_carbon_budget(0.001); // 0.001g CO2e max + + match eclexia_interp::run(&ast) { + Ok(value) => { + let should_warn = value.is_truthy(); + + Ok(PolicyDecision { + outcome: if should_warn { + PolicyOutcome::Warn + } else { + PolicyOutcome::Pass + }, + message: format!( + "Policy '{}' evaluated natively: result={:?}", + policy_name, value + ), + suggestion: None, + evaluation_cost: Some(placeholder_cost()), + policy_name: policy_name.to_string(), + }) + } + Err(e) => { + // Runtime error — treat as warning + Ok(PolicyDecision { + outcome: PolicyOutcome::Warn, + message: format!("Policy '{}' runtime error: {:?}", policy_name, e), + suggestion: Some("Check policy syntax and logic".to_string()), + evaluation_cost: None, + policy_name: policy_name.to_string(), + }) + } + } +} + +/// Built-in policy evaluation when eclexia binary is not available. +/// +/// Implements the core policies in Rust as a fallback. +fn evaluate_builtin(results: &[AnalysisResult], policy_name: &str) -> Result { + let total_energy: f64 = results.iter().map(|r| r.resources.energy.0).sum(); + let total_carbon: f64 = results.iter().map(|r| r.resources.carbon.0).sum(); + let avg_eco = if results.is_empty() { + 100.0 + } else { + results.iter().map(|r| r.health.eco_score.0).sum::() / results.len() as f64 + }; + + let (outcome, message, suggestion) = match policy_name { + "energy_threshold" => { + if total_energy > 1000.0 { + ( + PolicyOutcome::Fail, + format!("Total energy {:.2}J exceeds 1000J budget", total_energy), + Some("Optimize hot functions to reduce energy consumption".to_string()), + ) + } else if total_energy > 500.0 { + ( + PolicyOutcome::Warn, + format!("Total energy {:.2}J approaching 1000J budget", total_energy), + Some("Consider optimizing highest-energy functions".to_string()), + ) + } else { + ( + PolicyOutcome::Pass, + format!("Total energy {:.2}J within budget", total_energy), + None, + ) + } + } + "carbon_budget" => { + if total_carbon > 1.0 { + ( + PolicyOutcome::Fail, + format!( + "Carbon footprint {:.4}gCO2e exceeds 1g budget", + total_carbon + ), + Some("Reduce computation intensity to lower carbon emissions".to_string()), + ) + } else { + ( + PolicyOutcome::Pass, + format!("Carbon footprint {:.4}gCO2e within budget", total_carbon), + None, + ) + } + } + "memory_efficiency" => { + let large_allocs: Vec<_> = results + .iter() + .filter(|r| r.resources.memory.0 > 1_048_576) // >1MB + .collect(); + if !large_allocs.is_empty() { + ( + PolicyOutcome::Warn, + format!("{} functions have >1MB allocations", large_allocs.len()), + Some( + "Review large allocations; consider streaming or chunked processing" + .to_string(), + ), + ) + } else { + ( + PolicyOutcome::Pass, + "All allocations within bounds".to_string(), + None, + ) + } + } + _ => { + // Unknown policy — apply generic eco threshold check + if avg_eco < 50.0 { + ( + PolicyOutcome::Warn, + format!("Average eco score {:.1} below 50.0 threshold", avg_eco), + Some("Improve code efficiency in low-scoring functions".to_string()), + ) + } else { + ( + PolicyOutcome::Pass, + format!("Average eco score {:.1} meets threshold", avg_eco), + None, + ) + } + } + }; + + Ok(PolicyDecision { + outcome, + message, + suggestion, + evaluation_cost: Some(placeholder_cost()), + policy_name: format!("{} (builtin)", policy_name), + }) +} + +/// Try to find the eclexia binary +fn which_eclexia() -> Option { + // Check common locations + let candidates = [ + "eclexia", + "~/.asdf/installs/rust/nightly/bin/eclexia", + "../eclexia/target/release/eclexia", + ]; + + for candidate in &candidates { + let expanded = shellexpand(candidate); + if std::path::Path::new(&expanded).exists() { + return Some(expanded); + } + } + + // Try PATH + if std::process::Command::new("which") + .arg("eclexia") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Some("eclexia".to_string()); + } + + None +} + +fn shellexpand(path: &str) -> String { + if path.starts_with('~') { + if let Ok(home) = std::env::var("HOME") { + return path.replacen('~', &home, 1); + } + } + path.to_string() +} + +fn placeholder_cost() -> ResourceProfile { + ResourceProfile { + energy: oikosbot_metrics::Energy::joules(0.05), + duration: oikosbot_metrics::Duration::milliseconds(1.0), + carbon: oikosbot_metrics::Carbon::grams_co2e(0.000007), + memory: oikosbot_metrics::Memory::kilobytes(50), + } +} + +/// Example policy in Eclexia (to be written to policies/ directory) +pub const EXAMPLE_POLICY: &str = r#" +// SPDX-License-Identifier: MPL-2.0 +// Example OikosBot policy in Eclexia + +// This policy runs IN Eclexia, analyzing code's resource usage. +// Meta-level: The analyzer itself has provable resource bounds! + +def should_warn_high_energy(energy_joules: Float) -> Bool { + energy_joules > 100.0 +} + +def should_warn_high_carbon(carbon_grams: Float) -> Bool { + carbon_grams > 10.0 +} + +def evaluate_policy(energy: Float, carbon: Float) -> Bool + @requires: energy < 1J, carbon < 0.001gCO2e // Policy itself is cheap! +{ + should_warn_high_energy(energy) || should_warn_high_carbon(carbon) +} +"#; + +/// Convert policy decisions to analysis results for SARIF output +pub fn decisions_to_results(decisions: &[PolicyDecision]) -> Vec { + decisions + .iter() + .map(|d| { + let eco_score = match d.outcome { + PolicyOutcome::Pass => 90.0, + PolicyOutcome::Warn => 50.0, + PolicyOutcome::Fail => 20.0, + }; + + let cost = d.evaluation_cost.clone().unwrap_or(ResourceProfile { + energy: oikosbot_metrics::Energy::ZERO, + duration: oikosbot_metrics::Duration::ZERO, + carbon: oikosbot_metrics::Carbon::ZERO, + memory: oikosbot_metrics::Memory::ZERO, + }); + + AnalysisResult { + location: oikosbot_metrics::CodeLocation { + file: "policies/".to_string(), + line: 0, + column: 0, + end_line: None, + end_column: None, + name: Some(d.policy_name.clone()), + }, + resources: cost, + health: oikosbot_metrics::HealthIndex::compute( + oikosbot_metrics::EcoScore::new(eco_score), + oikosbot_metrics::EconScore::new(80.0), + 70.0, + ), + recommendations: { + let mut recs = vec![d.message.clone()]; + if let Some(ref s) = d.suggestion { + recs.push(s.clone()); + } + recs + }, + rule_id: format!("oikosbot/policy-{}", d.policy_name), + suggestion: d.suggestion.clone(), + end_location: None, + confidence: oikosbot_metrics::Confidence::Estimated, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_example_policy_syntax() { + assert!(EXAMPLE_POLICY.contains("def evaluate_policy")); + assert!(EXAMPLE_POLICY.contains("@requires")); + } + + #[test] + fn test_builtin_energy_policy_pass() { + let results = vec![sample_result(10.0)]; + let decision = evaluate_builtin(&results, "energy_threshold").unwrap(); + assert_eq!(decision.outcome, PolicyOutcome::Pass); + } + + #[test] + fn test_builtin_energy_policy_warn() { + // Create many results to exceed 500J total + let results: Vec = (0..60).map(|_| sample_result(10.0)).collect(); + let decision = evaluate_builtin(&results, "energy_threshold").unwrap(); + assert_eq!(decision.outcome, PolicyOutcome::Warn); + } + + #[test] + fn test_builtin_energy_policy_fail() { + // Exceed 1000J + let results: Vec = (0..200).map(|_| sample_result(10.0)).collect(); + let decision = evaluate_builtin(&results, "energy_threshold").unwrap(); + assert_eq!(decision.outcome, PolicyOutcome::Fail); + } + + #[test] + fn test_decisions_to_results() { + let decisions = vec![PolicyDecision { + outcome: PolicyOutcome::Warn, + message: "Test warning".to_string(), + suggestion: Some("Fix it".to_string()), + evaluation_cost: None, + policy_name: "test".to_string(), + }]; + + let results = decisions_to_results(&decisions); + assert_eq!(results.len(), 1); + assert_eq!(results[0].rule_id, "oikosbot/policy-test"); + assert!(results[0].suggestion.is_some()); + } + + fn sample_result(energy_j: f64) -> AnalysisResult { + AnalysisResult { + location: oikosbot_metrics::CodeLocation { + file: "test.rs".to_string(), + line: 1, + column: 1, + end_line: None, + end_column: None, + name: Some("test_fn".to_string()), + }, + resources: ResourceProfile { + energy: oikosbot_metrics::Energy::joules(energy_j), + duration: oikosbot_metrics::Duration::milliseconds(5.0), + carbon: oikosbot_metrics::Carbon::grams_co2e(0.001), + memory: oikosbot_metrics::Memory::kilobytes(100), + }, + health: oikosbot_metrics::HealthIndex::compute( + oikosbot_metrics::EcoScore::new(80.0), + oikosbot_metrics::EconScore::new(70.0), + 75.0, + ), + recommendations: vec![], + rule_id: "oikosbot/general".to_string(), + suggestion: None, + end_location: None, + confidence: oikosbot_metrics::Confidence::Estimated, + } + } +} diff --git a/crates/oikosbot-fleet/Cargo.toml b/crates/oikosbot-fleet/Cargo.toml new file mode 100644 index 0000000..8254fb4 --- /dev/null +++ b/crates/oikosbot-fleet/Cargo.toml @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +# OPTIONAL gitbot-fleet bridge — excluded from the default OikosBot workspace. +# +# This crate is the ONLY part of OikosBot that depends on +# `hyperpolymath/gitbot-fleet`. It is intentionally kept out of the default +# workspace (see ../../Cargo.toml `exclude`) so that OikosBot builds with no +# coupling to the fleet. Build it explicitly, with gitbot-fleet checked out as +# a sibling of this repository: +# +# cargo build --manifest-path crates/oikosbot-fleet/Cargo.toml +# +# It currently publishes findings under the fleet's existing `BotId::Sustainabot` +# slot; a dedicated `BotId::Oikosbot` upstream in gitbot-shared-context is the +# follow-up. See README.md in this directory and DISAMBIGUATION.adoc. + +# NB: concrete package metadata (not `*.workspace = true`) because this crate is +# EXCLUDED from the root workspace and must resolve standalone. +[package] +name = "oikosbot-fleet" +version = "0.1.0" +authors = ["Jonathan D.A. Jewell "] +edition = "2021" +license = "MPL-2.0" +repository = "https://github.com/hyperpolymath/oikosbot" + +[dependencies] +gitbot-shared-context = { path = "../../../gitbot-fleet/shared-context" } +oikosbot-analysis = { path = "../oikosbot-analysis" } +oikosbot-metrics = { path = "../oikosbot-metrics" } +anyhow = "1" +serde_json = "1" diff --git a/crates/oikosbot-fleet/README.md b/crates/oikosbot-fleet/README.md new file mode 100644 index 0000000..6598e41 --- /dev/null +++ b/crates/oikosbot-fleet/README.md @@ -0,0 +1,46 @@ + +# `oikosbot-fleet` — optional gitbot-fleet bridge + +This crate is the **only** part of OikosBot that knows about +[`hyperpolymath/gitbot-fleet`](https://github.com/hyperpolymath/gitbot-fleet). +It converts OikosBot's `AnalysisResult`s into the fleet's shared-context +`Finding`s so OikosBot can run as a fleet member. + +## Why it is excluded from the default workspace + +OikosBot and gitbot-fleet are **separate projects** (see +[`../../DISAMBIGUATION.adoc`](../../DISAMBIGUATION.adoc)). To keep that boundary +honest, the root `Cargo.toml` lists this crate under `exclude`, **not** +`members`. As a result: + +- `cargo build` / `cargo test` at the repo root build OikosBot with **zero** + dependency on gitbot-fleet. +- This bridge is compiled only when you ask for it explicitly. + +## Building / running the bridge + +The bridge has a `path` dependency on `gitbot-fleet/shared-context`, so check +out gitbot-fleet as a **sibling** of this repository: + +```text +parent/ +├── oikosbot/ # this repo +└── gitbot-fleet/ # hyperpolymath/gitbot-fleet +``` + +Then: + +```bash +cargo build --manifest-path crates/oikosbot-fleet/Cargo.toml +cargo run --manifest-path crates/oikosbot-fleet/Cargo.toml -- [--context ctx.json] +``` + +## Known follow-up + +The bridge currently publishes under the fleet's existing `BotId::Sustainabot` +slot. A dedicated `BotId::Oikosbot` in `gitbot-shared-context` is the intended +follow-up so the fleet can distinguish OikosBot from the (separate, reserved) +`sustainabot` slot. diff --git a/crates/oikosbot-fleet/src/lib.rs b/crates/oikosbot-fleet/src/lib.rs new file mode 100644 index 0000000..0c822cc --- /dev/null +++ b/crates/oikosbot-fleet/src/lib.rs @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell + +//! Gitbot-fleet bridge for OikosBot (optional; excluded from the default workspace). +//! +//! Publishes ecological and economic analysis findings to the shared context +//! layer for consumption by other bots in the fleet. + +#![forbid(unsafe_code)] +use anyhow::Result; +use gitbot_shared_context::{BotId, Context, Finding, Severity}; +use oikosbot_analysis::directives; +use oikosbot_metrics::AnalysisResult; +use std::path::{Path, PathBuf}; + +/// Ecological thresholds for reporting +pub struct EcologicalThresholds { + /// Total energy threshold in kilojoules + pub total_energy_threshold_kj: f64, + /// Total carbon threshold in grams + pub total_carbon_threshold_grams: f64, + /// Per-function energy threshold in joules + pub energy_per_function_joules: f64, +} + +impl Default for EcologicalThresholds { + fn default() -> Self { + Self { + total_energy_threshold_kj: 10.0, + total_carbon_threshold_grams: 2.0, + energy_per_function_joules: 100.0, + } + } +} + +/// Publish sustainabot analysis findings to the fleet shared context. +/// +/// This is the primary integration point: converts `AnalysisResult` items +/// from `sustainabot-metrics` into `Finding` objects for the fleet. +pub fn publish_findings( + ctx: &mut Context, + results: &[AnalysisResult], + thresholds: &EcologicalThresholds, +) -> Result<()> { + let mut total_energy_j = 0.0f64; + let mut total_carbon_g = 0.0f64; + let mut high_impact_functions = Vec::new(); + + for result in results { + total_energy_j += result.resources.energy.0; + total_carbon_g += result.resources.carbon.0; + + // Flag high-impact functions + if result.resources.energy.0 > thresholds.energy_per_function_joules { + high_impact_functions.push(( + result + .location + .name + .clone() + .unwrap_or_else(|| "".to_string()), + result.resources.energy.0, + )); + } + + // Convert each result to a rich Finding + let finding = convert_to_finding(result); + ctx.add_finding(finding); + } + + // Report overall resource usage + let total_energy_kj = total_energy_j / 1000.0; + if total_energy_kj > thresholds.total_energy_threshold_kj { + ctx.add_finding( + Finding::new( + BotId::Sustainabot, + "SUSTAIN-HIGH-ENERGY", + Severity::Warning, + &format!( + "High total energy consumption: {:.2} kJ (threshold: {:.2} kJ)", + total_energy_kj, thresholds.total_energy_threshold_kj + ), + ) + .with_category("sustainability"), + ); + } + + if total_carbon_g > thresholds.total_carbon_threshold_grams { + ctx.add_finding( + Finding::new( + BotId::Sustainabot, + "SUSTAIN-HIGH-CARBON", + Severity::Warning, + &format!( + "High carbon footprint: {:.4}g CO2e (threshold: {:.2}g)", + total_carbon_g, thresholds.total_carbon_threshold_grams + ), + ) + .with_category("sustainability"), + ); + } + + // Report high-impact functions + if !high_impact_functions.is_empty() { + let function_list = high_impact_functions + .iter() + .map(|(name, energy)| format!("{} ({:.2}J)", name, energy)) + .collect::>() + .join(", "); + + ctx.add_finding( + Finding::new( + BotId::Sustainabot, + "SUSTAIN-HIGH-IMPACT-FUNCTIONS", + Severity::Info, + &format!( + "{} function(s) exceed per-function energy threshold: {}", + high_impact_functions.len(), + function_list + ), + ) + .with_category("sustainability"), + ); + } + + // Efficiency rating + let rating = calculate_efficiency_rating(results); + ctx.add_finding( + Finding::new( + BotId::Sustainabot, + "SUSTAIN-EFFICIENCY-RATING", + Severity::Info, + &format!("Ecological efficiency rating: {}", rating), + ) + .with_category("sustainability") + .with_metadata(serde_json::json!({ + "rating": rating, + "total_energy_kj": total_energy_kj, + "total_carbon_g": total_carbon_g, + "functions_analyzed": results.len(), + })), + ); + + Ok(()) +} + +/// Convert a sustainabot AnalysisResult to a gitbot-fleet Finding +/// using ALL builder fields for rich integration. +fn convert_to_finding(result: &AnalysisResult) -> Finding { + let func_name = result.location.name.as_deref().unwrap_or(""); + + let severity = if result.health.eco_score.0 < 30.0 { + Severity::Error + } else if result.health.eco_score.0 < 60.0 { + Severity::Warning + } else if result.health.eco_score.0 < 80.0 { + Severity::Info + } else { + Severity::Suggestion + }; + + let message = format!( + "{}: eco={:.0}/100 energy={:.2}J carbon={:.4}gCO2e. {}", + func_name, + result.health.eco_score.0, + result.resources.energy.0, + result.resources.carbon.0, + result.recommendations.join("; "), + ); + + let mut finding = Finding::new(BotId::Sustainabot, &result.rule_id, severity, &message) + .with_rule_name(&format!("Sustainability: {}", result.rule_id)) + .with_category("sustainability") + .with_file(PathBuf::from(&result.location.file)) + .with_location(result.location.line, result.location.column) + .with_metadata(serde_json::json!({ + "eco_score": result.health.eco_score.0, + "econ_score": result.health.econ_score.0, + "quality_score": result.health.quality_score, + "overall_health": result.health.overall, + "energy_joules": result.resources.energy.0, + "carbon_gco2e": result.resources.carbon.0, + "duration_ms": result.resources.duration.0, + "memory_bytes": result.resources.memory.0, + "confidence": format!("{:?}", result.confidence), + })); + + if let Some(ref suggestion) = result.suggestion { + finding = finding.with_suggestion(suggestion); + } + + // Mark as fixable if there's a concrete suggestion + if result.suggestion.is_some() { + finding = finding.fixable(); + } + + finding +} + +/// Run sustainabot as a fleet member with directive awareness. +/// +/// Reads `.machine_readable/bot_directives/sustainabot.scm` (legacy fallback +/// supported) to determine allowed scopes, +/// then runs analysis respecting the directive. +pub fn run_fleet_analysis(repo_path: &Path, context_path: Option<&Path>) -> Result<()> { + // Check for sustainabot directive + let directive = directives::check_directive(repo_path, "sustainabot"); + + if let Some(ref d) = directive { + if !d.allow { + eprintln!( + "Sustainabot denied by .machine_readable/bot_directives/sustainabot.scm: {}", + d.notes.as_deref().unwrap_or("no reason given") + ); + return Ok(()); + } + } + + // Collect analysis results + let results = collect_results(repo_path)?; + + // Build thresholds from directive if available + let thresholds = if let Some(ref d) = directive { + let mut t = EcologicalThresholds::default(); + for (key, val) in &d.thresholds { + match key.as_str() { + "energy" => t.energy_per_function_joules = *val, + "carbon" => t.total_carbon_threshold_grams = *val, + _ => {} + } + } + t + } else { + EcologicalThresholds::default() + }; + + // If context file provided, publish to shared context + if let Some(ctx_path) = context_path { + let content = std::fs::read_to_string(ctx_path)?; + let mut ctx: Context = serde_json::from_str(&content)?; + + ctx.start_bot(BotId::Sustainabot)?; + publish_findings(&mut ctx, &results, &thresholds)?; + ctx.complete_bot(BotId::Sustainabot, results.len(), 0, 0)?; + + let output = serde_json::to_string_pretty(&ctx)?; + std::fs::write(ctx_path, output)?; + } else { + // Standalone mode: just print findings + let thresholds = EcologicalThresholds::default(); + let rating = calculate_efficiency_rating(&results); + + eprintln!("Sustainabot fleet analysis: {} functions", results.len()); + eprintln!("Efficiency rating: {}", rating); + + let below: Vec<_> = results + .iter() + .filter(|r| r.health.eco_score.0 < 60.0) + .collect(); + + if !below.is_empty() { + eprintln!("{} functions below eco threshold:", below.len()); + for r in &below { + eprintln!( + " {} (eco: {:.0}, energy: {:.2}J)", + r.location.name.as_deref().unwrap_or(""), + r.health.eco_score.0, + r.resources.energy.0, + ); + } + } + + let total_energy: f64 = results.iter().map(|r| r.resources.energy.0).sum(); + if total_energy / 1000.0 > thresholds.total_energy_threshold_kj { + eprintln!( + "WARNING: Total energy {:.2} kJ exceeds threshold {:.2} kJ", + total_energy / 1000.0, + thresholds.total_energy_threshold_kj + ); + } + } + + Ok(()) +} + +fn collect_results(repo_path: &Path) -> Result> { + let mut results = Vec::new(); + + for entry in walkdir(repo_path) { + match oikosbot_analysis::analyze_file(&entry) { + Ok(file_results) => results.extend(file_results), + Err(_) => continue, + } + } + + Ok(results) +} + +/// Simple directory walker for supported files +fn walkdir(path: &Path) -> Vec { + let mut files = Vec::new(); + walk_recursive(path, &mut files); + files +} + +fn walk_recursive(path: &Path, files: &mut Vec) { + let entries = match std::fs::read_dir(path) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let p = entry.path(); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Skip common non-source directories + if p.is_dir() { + if !matches!( + name, + "target" | "node_modules" | ".git" | "dist" | "build" | ".cache" | "__pycache__" + ) { + walk_recursive(&p, files); + } + continue; + } + + // Only include supported source files + if let Some(ext) = p.extension().and_then(|e| e.to_str()) { + if matches!(ext, "rs" | "js" | "py") { + files.push(p); + } + } + } +} + +/// Calculate efficiency rating (A-F scale) +fn calculate_efficiency_rating(results: &[AnalysisResult]) -> String { + if results.is_empty() { + return "N/A".to_string(); + } + + let avg_energy: f64 = + results.iter().map(|r| r.resources.energy.0).sum::() / results.len() as f64; + + if avg_energy < 10.0 { + "A (Excellent)".to_string() + } else if avg_energy < 50.0 { + "B (Good)".to_string() + } else if avg_energy < 100.0 { + "C (Average)".to_string() + } else if avg_energy < 200.0 { + "D (Below Average)".to_string() + } else if avg_energy < 500.0 { + "E (Poor)".to_string() + } else { + "F (Very Poor)".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oikosbot_metrics::*; + + fn sample_result(energy_j: f64) -> AnalysisResult { + AnalysisResult { + location: CodeLocation { + file: "test.rs".to_string(), + line: 1, + column: 1, + end_line: Some(10), + end_column: Some(2), + name: Some("test_fn".to_string()), + }, + resources: ResourceProfile { + energy: Energy::joules(energy_j), + duration: Duration::milliseconds(energy_j * 0.5), + carbon: Carbon::grams_co2e(energy_j * 0.0001), + memory: Memory::kilobytes(100), + }, + health: HealthIndex::compute(EcoScore::new(80.0), EconScore::new(70.0), 75.0), + recommendations: vec!["Code looks efficient".to_string()], + rule_id: "sustainabot/general".to_string(), + suggestion: None, + end_location: Some((10, 2)), + confidence: Confidence::Estimated, + } + } + + #[test] + fn test_efficiency_rating() { + let results = vec![sample_result(5.0)]; + let rating = calculate_efficiency_rating(&results); + assert!(rating.starts_with('A')); + } + + #[test] + fn test_convert_to_finding() { + let result = sample_result(5.0); + let finding = convert_to_finding(&result); + + assert_eq!(finding.source, BotId::Sustainabot); + assert_eq!(finding.rule_id, "sustainabot/general"); + assert_eq!(finding.category, "sustainability"); + assert!(finding.file.is_some()); + assert!(finding.line.is_some()); + } + + #[test] + fn test_convert_finding_with_suggestion() { + let mut result = sample_result(5.0); + result.suggestion = Some("Use hash map for O(1) lookup".to_string()); + result.rule_id = "sustainabot/nested-loops".to_string(); + + let finding = convert_to_finding(&result); + assert!(finding.fixable); + assert!(finding.suggestion.is_some()); + } + + #[test] + fn test_severity_mapping() { + // Low eco score → Error + let mut result = sample_result(5.0); + result.health = HealthIndex::compute(EcoScore::new(20.0), EconScore::new(70.0), 75.0); + let finding = convert_to_finding(&result); + assert_eq!(finding.severity, Severity::Error); + + // Medium eco score → Warning + result.health = HealthIndex::compute(EcoScore::new(50.0), EconScore::new(70.0), 75.0); + let finding = convert_to_finding(&result); + assert_eq!(finding.severity, Severity::Warning); + + // High eco score → Info + result.health = HealthIndex::compute(EcoScore::new(75.0), EconScore::new(70.0), 75.0); + let finding = convert_to_finding(&result); + assert_eq!(finding.severity, Severity::Info); + } +} diff --git a/crates/oikosbot-metrics/Cargo.toml b/crates/oikosbot-metrics/Cargo.toml new file mode 100644 index 0000000..bd3543e --- /dev/null +++ b/crates/oikosbot-metrics/Cargo.toml @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[package] +name = "oikosbot-metrics" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/crates/oikosbot-metrics/src/lib.rs b/crates/oikosbot-metrics/src/lib.rs new file mode 100644 index 0000000..5d3d17b --- /dev/null +++ b/crates/oikosbot-metrics/src/lib.rs @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! # OikosBot Metrics +//! +//! Core data types for ecological and economic code analysis. +//! Inspired by Eclexia's resource-aware design principles. + +#![forbid(unsafe_code)] +use serde::{Deserialize, Serialize}; +use std::ops::{Add, Mul}; + +/// Energy measurement in Joules +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct Energy(pub f64); + +impl Energy { + pub const ZERO: Self = Energy(0.0); + + pub fn joules(j: f64) -> Self { + Energy(j) + } + + pub fn kilojoules(kj: f64) -> Self { + Energy(kj * 1000.0) + } +} + +impl Add for Energy { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Energy(self.0 + rhs.0) + } +} + +impl Mul for Energy { + type Output = Self; + fn mul(self, rhs: f64) -> Self::Output { + Energy(self.0 * rhs) + } +} + +/// Time duration in milliseconds +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct Duration(pub f64); + +impl Duration { + pub const ZERO: Self = Duration(0.0); + + pub fn milliseconds(ms: f64) -> Self { + Duration(ms) + } + + pub fn seconds(s: f64) -> Self { + Duration(s * 1000.0) + } +} + +impl Add for Duration { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Duration(self.0 + rhs.0) + } +} + +/// Carbon emissions in grams of CO2 equivalent +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct Carbon(pub f64); + +impl Carbon { + pub const ZERO: Self = Carbon(0.0); + + pub fn grams_co2e(g: f64) -> Self { + Carbon(g) + } + + pub fn kilograms_co2e(kg: f64) -> Self { + Carbon(kg * 1000.0) + } +} + +impl Add for Carbon { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Carbon(self.0 + rhs.0) + } +} + +impl Mul for Carbon { + type Output = Self; + fn mul(self, rhs: f64) -> Self::Output { + Carbon(self.0 * rhs) + } +} + +/// Memory usage in bytes +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct Memory(pub usize); + +impl Memory { + pub const ZERO: Self = Memory(0); + + pub fn bytes(b: usize) -> Self { + Memory(b) + } + + pub fn kilobytes(kb: usize) -> Self { + Memory(kb * 1024) + } + + pub fn megabytes(mb: usize) -> Self { + Memory(mb * 1024 * 1024) + } +} + +impl Add for Memory { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Memory(self.0 + rhs.0) + } +} + +/// Complete resource profile for a code unit +/// +/// This is inspired by Eclexia's `@provides` annotations but tracked +/// at runtime during analysis rather than compile-time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceProfile { + pub energy: Energy, + pub duration: Duration, + pub carbon: Carbon, + pub memory: Memory, +} + +impl ResourceProfile { + pub fn zero() -> Self { + ResourceProfile { + energy: Energy::ZERO, + duration: Duration::ZERO, + carbon: Carbon::ZERO, + memory: Memory::ZERO, + } + } + + /// Calculate cost using shadow prices (Eclexia-inspired) + /// + /// Cost = λ_energy * energy + λ_time * time + λ_carbon * carbon + pub fn cost(&self, shadow_prices: &ShadowPrices) -> f64 { + shadow_prices.energy * self.energy.0 + + shadow_prices.time * self.duration.0 + + shadow_prices.carbon * self.carbon.0 + } +} + +impl Add for ResourceProfile { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + ResourceProfile { + energy: self.energy + rhs.energy, + duration: self.duration + rhs.duration, + carbon: self.carbon + rhs.carbon, + memory: self.memory + rhs.memory, + } + } +} + +/// Shadow prices for resources (economic optimization) +/// +/// These represent the marginal value of each resource, guiding +/// trade-off decisions. Inspired by Eclexia's shadow price system. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShadowPrices { + /// Value per Joule of energy + pub energy: f64, + /// Value per millisecond of time + pub time: f64, + /// Value per gram of CO2e + pub carbon: f64, +} + +impl Default for ShadowPrices { + fn default() -> Self { + // Default weights favoring carbon reduction + ShadowPrices { + energy: 1.0, + time: 0.5, + carbon: 2.0, // Carbon twice as important as energy + } + } +} + +/// Ecological score (0-100) +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct EcoScore(pub f64); + +impl EcoScore { + pub fn new(score: f64) -> Self { + EcoScore(score.clamp(0.0, 100.0)) + } +} + +/// Economic score (0-100) - measures Pareto efficiency +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize)] +pub struct EconScore(pub f64); + +impl EconScore { + pub fn new(score: f64) -> Self { + EconScore(score.clamp(0.0, 100.0)) + } +} + +/// Confidence level for an estimate +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum Confidence { + /// Derived from actual measurements or profiling data + Measured, + /// Calibrated against known baselines + Calibrated, + /// Heuristic estimate with reasonable basis + Estimated, + /// No strong basis; placeholder value + #[default] + Unknown, +} + +/// Overall health index combining eco and econ scores +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthIndex { + pub eco_score: EcoScore, + pub econ_score: EconScore, + pub quality_score: f64, + pub overall: f64, +} + +impl HealthIndex { + pub fn compute(eco: EcoScore, econ: EconScore, quality: f64) -> Self { + // Formula from README: 0.4 × Eco + 0.3 × Econ + 0.3 × Quality + let overall = 0.4 * eco.0 + 0.3 * econ.0 + 0.3 * quality; + + HealthIndex { + eco_score: eco, + econ_score: econ, + quality_score: quality, + overall, + } + } +} + +/// Analysis result for a single code unit (function, file, module) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnalysisResult { + pub location: CodeLocation, + pub resources: ResourceProfile, + pub health: HealthIndex, + pub recommendations: Vec, + /// Machine-readable rule identifier (e.g. "oikosbot/nested-loops") + #[serde(default)] + pub rule_id: String, + /// Concrete suggestion for fixing the finding + #[serde(default)] + pub suggestion: Option, + /// End location for range-based annotations + #[serde(default)] + pub end_location: Option<(usize, usize)>, + /// How confident is this estimate? + #[serde(default)] + pub confidence: Confidence, +} + +/// Source code location +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeLocation { + pub file: String, + pub line: usize, + pub column: usize, + /// End line (1-indexed) + #[serde(default)] + pub end_line: Option, + /// End column (1-indexed) + #[serde(default)] + pub end_column: Option, + pub name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_energy_arithmetic() { + let e1 = Energy::joules(10.0); + let e2 = Energy::joules(5.0); + assert_eq!(e1 + e2, Energy::joules(15.0)); + assert_eq!(e1 * 2.0, Energy::joules(20.0)); + } + + #[test] + fn test_resource_cost() { + let profile = ResourceProfile { + energy: Energy::joules(10.0), + duration: Duration::milliseconds(100.0), + carbon: Carbon::grams_co2e(5.0), + memory: Memory::bytes(1024), + }; + + let prices = ShadowPrices::default(); + let cost = profile.cost(&prices); + + // cost = 1.0*10 + 0.5*100 + 2.0*5 = 10 + 50 + 10 = 70 + assert_eq!(cost, 70.0); + } + + #[test] + fn test_health_index() { + let health = HealthIndex::compute(EcoScore::new(80.0), EconScore::new(70.0), 60.0); + + // 0.4*80 + 0.3*70 + 0.3*60 = 32 + 21 + 18 = 71 + assert_eq!(health.overall, 71.0); + } +} diff --git a/crates/oikosbot-sarif/Cargo.toml b/crates/oikosbot-sarif/Cargo.toml new file mode 100644 index 0000000..ce00f5b --- /dev/null +++ b/crates/oikosbot-sarif/Cargo.toml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +[package] +name = "oikosbot-sarif" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "SARIF 2.1.0 output for oikosbot analysis results" + +[dependencies] +oikosbot-metrics = { path = "../oikosbot-metrics" } +serde.workspace = true +serde_json.workspace = true diff --git a/crates/oikosbot-sarif/src/lib.rs b/crates/oikosbot-sarif/src/lib.rs new file mode 100644 index 0000000..2105717 --- /dev/null +++ b/crates/oikosbot-sarif/src/lib.rs @@ -0,0 +1,501 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell + +//! SARIF 2.1.0 output for oikosbot analysis results. +//! +//! Produces machine-readable SARIF that GitHub/IDEs render as inline annotations. + +#![forbid(unsafe_code)] +use oikosbot_metrics::AnalysisResult; +use serde::{Deserialize, Serialize}; + +/// SARIF schema version +const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json"; +const SARIF_VERSION: &str = "2.1.0"; +const TOOL_NAME: &str = "oikosbot"; +const TOOL_INFO_URI: &str = "https://github.com/hyperpolymath/oikosbot"; + +/// Top-level SARIF log +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SarifLog { + #[serde(rename = "$schema")] + pub schema: String, + pub version: String, + pub runs: Vec, +} + +/// A single SARIF run +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Run { + pub tool: Tool, + pub results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub invocations: Option>, +} + +/// Tool description +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tool { + pub driver: ToolComponent, +} + +/// Tool component with rules +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolComponent { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub information_uri: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, +} + +/// Rule definition +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportingDescriptor { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub short_description: Message, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub help: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_configuration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option, +} + +/// Reporting configuration for severity +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReportingConfiguration { + pub level: SarifLevel, +} + +/// A single finding result +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SarifResult { + pub rule_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub rule_index: Option, + pub level: SarifLevel, + pub message: Message, + pub locations: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub fixes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option, +} + +/// SARIF severity levels +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SarifLevel { + Error, + Warning, + Note, + None, +} + +/// Simple message +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Message { + pub text: String, +} + +/// Multiformat message (text + markdown) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MultiformatMessage { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub markdown: Option, +} + +/// Code location +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Location { + pub physical_location: PhysicalLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub logical_locations: Option>, +} + +/// Physical file location +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PhysicalLocation { + pub artifact_location: ArtifactLocation, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +/// File path reference +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactLocation { + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri_base_id: Option, +} + +/// Line/column region +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Region { + #[serde(skip_serializing_if = "Option::is_none")] + pub start_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_column: Option, +} + +/// Logical location (function name, etc.) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogicalLocation { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, +} + +/// Suggested fix +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Fix { + pub description: Message, +} + +/// Invocation metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Invocation { + pub execution_successful: bool, +} + +/// Known oikosbot rules +fn builtin_rules() -> Vec { + vec![ + rule( + "oikosbot/general", + "General sustainability finding", + "Code unit analyzed for ecological and economic efficiency.", + SarifLevel::Note, + ), + rule( + "oikosbot/nested-loops", + "Deeply nested loops", + "Deeply nested loops create O(n^k) complexity, wasting CPU and energy.", + SarifLevel::Warning, + ), + rule( + "oikosbot/busy-wait", + "Busy-wait loop", + "Loop without sleep/await/yield burns CPU continuously.", + SarifLevel::Warning, + ), + rule( + "oikosbot/string-concat-in-loop", + "String concatenation in loop", + "String concatenation inside loops causes repeated heap allocation.", + SarifLevel::Warning, + ), + rule( + "oikosbot/clone-in-loop", + "Clone in loop", + ".clone() inside loop body causes repeated deep copies.", + SarifLevel::Note, + ), + rule( + "oikosbot/unbuffered-io", + "Unbuffered I/O", + "File I/O without buffering causes excessive system calls.", + SarifLevel::Warning, + ), + rule( + "oikosbot/large-allocation", + "Large allocation", + "Heap allocation exceeding 1MB detected.", + SarifLevel::Note, + ), + rule( + "oikosbot/redundant-allocation", + "Redundant allocation", + "Excessive .to_string()/.to_owned() where borrows suffice.", + SarifLevel::Note, + ), + rule( + "oikosbot/eco-threshold", + "Below eco threshold", + "Function's ecological score is below the configured threshold.", + SarifLevel::Warning, + ), + rule( + "oikosbot/carbon-intensity", + "High carbon intensity", + "Estimated carbon emissions exceed sustainable baseline.", + SarifLevel::Warning, + ), + rule( + "oikosbot/security-sustainability", + "Security-sustainability correlation", + "Security weak point with sustainability impact detected.", + SarifLevel::Warning, + ), + ] +} + +fn rule(id: &str, name: &str, desc: &str, level: SarifLevel) -> ReportingDescriptor { + ReportingDescriptor { + id: id.to_string(), + name: Some(name.to_string()), + short_description: Message { + text: desc.to_string(), + }, + full_description: None, + help: None, + default_configuration: Some(ReportingConfiguration { level }), + properties: None, + } +} + +/// Convert oikosbot analysis results into a SARIF log. +pub fn to_sarif(results: &[AnalysisResult], version: &str) -> SarifLog { + let rules = builtin_rules(); + let rule_ids: Vec<&str> = rules.iter().map(|r| r.id.as_str()).collect(); + + let sarif_results: Vec = results + .iter() + .map(|r| convert_result(r, &rule_ids)) + .collect(); + + SarifLog { + schema: SARIF_SCHEMA.to_string(), + version: SARIF_VERSION.to_string(), + runs: vec![Run { + tool: Tool { + driver: ToolComponent { + name: TOOL_NAME.to_string(), + semantic_version: Some(version.to_string()), + information_uri: Some(TOOL_INFO_URI.to_string()), + rules, + }, + }, + results: sarif_results, + invocations: Some(vec![Invocation { + execution_successful: true, + }]), + }], + } +} + +/// Convert oikosbot analysis results to SARIF JSON string. +pub fn to_sarif_json( + results: &[AnalysisResult], + version: &str, +) -> Result { + let log = to_sarif(results, version); + serde_json::to_string_pretty(&log) +} + +fn convert_result(result: &AnalysisResult, rule_ids: &[&str]) -> SarifResult { + let rule_id = &result.rule_id; + let rule_index = rule_ids.iter().position(|&id| id == rule_id); + + // Determine severity from health scores + let level = if result.health.eco_score.0 < 30.0 { + SarifLevel::Error + } else if result.health.eco_score.0 < 60.0 { + SarifLevel::Warning + } else { + SarifLevel::Note + }; + + // Build message + let func_name = result.location.name.as_deref().unwrap_or(""); + let message_text = if result.recommendations.is_empty() { + format!( + "{}: eco={:.0}/100, energy={:.2}J, carbon={:.4}gCO2e", + func_name, + result.health.eco_score.0, + result.resources.energy.0, + result.resources.carbon.0, + ) + } else { + format!( + "{}: eco={:.0}/100, energy={:.2}J, carbon={:.4}gCO2e. {}", + func_name, + result.health.eco_score.0, + result.resources.energy.0, + result.resources.carbon.0, + result.recommendations.join("; "), + ) + }; + + // Build location + let location = Location { + physical_location: PhysicalLocation { + artifact_location: ArtifactLocation { + uri: result.location.file.clone(), + uri_base_id: Some("%SRCROOT%".to_string()), + }, + region: Some(Region { + start_line: Some(result.location.line), + start_column: Some(result.location.column), + end_line: result.location.end_line, + end_column: result.location.end_column, + }), + }, + logical_locations: result.location.name.as_ref().map(|name| { + vec![LogicalLocation { + name: name.clone(), + kind: Some("function".to_string()), + }] + }), + }; + + // Build fixes from suggestion + let fixes = result + .suggestion + .as_ref() + .map(|s| { + vec![Fix { + description: Message { text: s.clone() }, + }] + }) + .unwrap_or_default(); + + // Custom properties with eco/econ scores and resource profile + let properties = serde_json::json!({ + "eco_score": result.health.eco_score.0, + "econ_score": result.health.econ_score.0, + "quality_score": result.health.quality_score, + "overall_health": result.health.overall, + "energy_joules": result.resources.energy.0, + "carbon_gco2e": result.resources.carbon.0, + "duration_ms": result.resources.duration.0, + "memory_bytes": result.resources.memory.0, + "confidence": format!("{:?}", result.confidence), + }); + + SarifResult { + rule_id: rule_id.clone(), + rule_index, + level, + message: Message { text: message_text }, + locations: vec![location], + fixes, + properties: Some(properties), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oikosbot_metrics::*; + + fn sample_result() -> AnalysisResult { + AnalysisResult { + location: CodeLocation { + file: "src/main.rs".to_string(), + line: 10, + column: 1, + end_line: Some(25), + end_column: Some(2), + name: Some("process_data".to_string()), + }, + resources: ResourceProfile { + energy: Energy::joules(5.0), + duration: Duration::milliseconds(25.0), + carbon: Carbon::grams_co2e(0.0007), + memory: Memory::kilobytes(100), + }, + health: HealthIndex::compute(EcoScore::new(75.0), EconScore::new(80.0), 85.0), + recommendations: vec!["Code looks efficient".to_string()], + rule_id: "oikosbot/general".to_string(), + suggestion: None, + end_location: Some((25, 2)), + confidence: Confidence::Estimated, + } + } + + #[test] + fn test_sarif_structure() { + let results = vec![sample_result()]; + let log = to_sarif(&results, "0.1.0"); + + assert_eq!(log.version, "2.1.0"); + assert_eq!(log.runs.len(), 1); + assert_eq!(log.runs[0].tool.driver.name, "oikosbot"); + assert!(!log.runs[0].tool.driver.rules.is_empty()); + assert_eq!(log.runs[0].results.len(), 1); + } + + #[test] + fn test_sarif_json_valid() { + let results = vec![sample_result()]; + let json = to_sarif_json(&results, "0.1.0").unwrap(); + + // Should be valid JSON + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["version"], "2.1.0"); + assert!( + parsed["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"] + ["startLine"] + .as_u64() + .unwrap() + == 10 + ); + } + + #[test] + fn test_sarif_with_suggestion() { + let mut result = sample_result(); + result.rule_id = "oikosbot/nested-loops".to_string(); + result.suggestion = Some("Use hash map for O(1) lookup".to_string()); + + let log = to_sarif(&[result], "0.1.0"); + let sarif_result = &log.runs[0].results[0]; + + assert_eq!(sarif_result.rule_id, "oikosbot/nested-loops"); + assert_eq!(sarif_result.fixes.len(), 1); + assert_eq!( + sarif_result.fixes[0].description.text, + "Use hash map for O(1) lookup" + ); + } + + #[test] + fn test_sarif_severity_mapping() { + // Low eco score → Error + let mut result = sample_result(); + result.health = HealthIndex::compute(EcoScore::new(20.0), EconScore::new(80.0), 85.0); + let log = to_sarif(&[result], "0.1.0"); + assert!(matches!(log.runs[0].results[0].level, SarifLevel::Error)); + + // Medium eco score → Warning + let mut result = sample_result(); + result.health = HealthIndex::compute(EcoScore::new(45.0), EconScore::new(80.0), 85.0); + let log = to_sarif(&[result], "0.1.0"); + assert!(matches!(log.runs[0].results[0].level, SarifLevel::Warning)); + + // High eco score → Note + let mut result = sample_result(); + result.health = HealthIndex::compute(EcoScore::new(85.0), EconScore::new(80.0), 85.0); + let log = to_sarif(&[result], "0.1.0"); + assert!(matches!(log.runs[0].results[0].level, SarifLevel::Note)); + } +} diff --git a/examples/oikosbot-ci.yml b/examples/oikosbot-ci.yml new file mode 100644 index 0000000..c8f7fcd --- /dev/null +++ b/examples/oikosbot-ci.yml @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: MPL-2.0 +# Example GitHub Actions workflow for oikosbot SARIF integration +# +# This workflow runs oikosbot on your codebase and uploads the SARIF +# results to GitHub's Security tab, giving you inline PR annotations. +# +# Usage: Copy this file to .github/workflows/oikosbot.yml in your repo. + +name: Sustainability Analysis + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + security-events: write + contents: read + +jobs: + oikosbot: + name: Eco & Economic Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install oikosbot + run: cargo install --path . --locked + # Or, if published to crates.io: + # run: cargo install oikosbot + + - name: Run sustainability analysis + run: | + oikosbot report ./src \ + --format sarif \ + --output results.sarif \ + --eco-threshold 50 + + - name: Upload SARIF to GitHub Security + if: always() + uses: github/codeql-action/upload-sarif@6624720a57d4c312633c7b953db2f2da5bcb4c3a # v3 + with: + sarif_file: results.sarif + category: oikosbot + + # Optional: Run with policy evaluation + - name: Run policy evaluation + if: hashFiles('policies/') != '' + run: | + oikosbot check ./src \ + --format text \ + --policy-dir policies/ \ + --eco-threshold 50 diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..160e062 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +# Fuzz the analysis engine. (The workspace root is virtual — there is no crate +# named `oikosbot` at `..`; target the actual library crate instead.) +[dependencies.oikosbot-analysis] +path = "../crates/oikosbot-analysis" + +[[bin]] +name = "fuzz_main" +path = "fuzz_targets/fuzz_main.rs" +test = false +doc = false diff --git a/fuzz/fuzz_targets/fuzz_main.rs b/fuzz/fuzz_targets/fuzz_main.rs new file mode 100644 index 0000000..1f71ba3 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_main.rs @@ -0,0 +1,22 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Generic fuzzing for memory safety and crash detection + if data.is_empty() || data.len() > 100000 { + return; + } + + // Test UTF-8 validity + if let Ok(text) = std::str::from_utf8(data) { + // Test string operations + let _ = text.to_lowercase(); + let _ = text.chars().count(); + let _ = text.split_whitespace().collect::>(); + } + + // Test binary data handling + if data.len() >= 8 { + let _chunk = &data[..8]; + } +}); diff --git a/policies/carbon_budget.ecl b/policies/carbon_budget.ecl new file mode 100644 index 0000000..9ca7dc8 --- /dev/null +++ b/policies/carbon_budget.ecl @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +// OikosBot Policy: Carbon Budget +// +// Per-function and per-project carbon budgets. +// Enforces sustainable computation practices. + +def exceeds_function_carbon(carbon_grams: Float) -> Bool + @requires: energy < 0.1J +{ + carbon_grams > 0.5 +} + +def exceeds_project_carbon(total_carbon_grams: Float) -> Bool + @requires: energy < 0.1J +{ + total_carbon_grams > 5.0 +} + +def evaluate_carbon_budget(function_carbon: Float, total_carbon: Float) -> Bool + @requires: energy < 1J, carbon < 0.001gCO2e +{ + exceeds_function_carbon(function_carbon) || exceeds_project_carbon(total_carbon) +} diff --git a/policies/energy_threshold.ecl b/policies/energy_threshold.ecl new file mode 100644 index 0000000..0840a67 --- /dev/null +++ b/policies/energy_threshold.ecl @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// OikosBot Policy: Energy Threshold Check +// +// This policy is written in ECLEXIA - proving dogfooding works! +// The policy engine itself has provable resource bounds. + +// Check if a function uses too much energy +def exceeds_energy_threshold(energy_joules: Float) -> Bool + @requires: energy < 0.1J, carbon < 0.001gCO2e // This policy is CHEAP to run + @optimize: minimize latency +{ + energy_joules > 50.0 +} + +// Check if carbon footprint is too high +def exceeds_carbon_threshold(carbon_grams: Float) -> Bool + @requires: energy < 0.1J +{ + carbon_grams > 5.0 +} + +// Main policy evaluation function +// Returns true if the code should trigger a warning +adaptive def should_warn(energy: Float, carbon: Float) -> Bool + @requires: energy < 1J, latency < 5ms + @optimize: minimize energy, minimize latency +{ + @solution "fast_check": + @when: energy < 10.0 && carbon < 1.0 + @provides: energy: 0.05J, latency: 1ms + { + false // Obviously fine, skip detailed check + } + + @solution "detailed_check": + @provides: energy: 0.2J, latency: 3ms + { + exceeds_energy_threshold(energy) || exceeds_carbon_threshold(carbon) + } +} + +// Example: This policy used < 1J to decide if your code uses > 50J +// Meta-level efficiency: The analyzer is more efficient than what it analyzes! diff --git a/policies/memory_efficiency.ecl b/policies/memory_efficiency.ecl new file mode 100644 index 0000000..ac784ba --- /dev/null +++ b/policies/memory_efficiency.ecl @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 +// OikosBot Policy: Memory Efficiency +// +// Allocation pattern rules to catch wasteful memory usage. + +def exceeds_allocation_limit(bytes: Float) -> Bool + @requires: energy < 0.05J +{ + bytes > 1048576.0 // 1MB +} + +def high_allocation_count(count: Float) -> Bool + @requires: energy < 0.05J +{ + count > 100.0 +} + +def evaluate_memory(allocation_bytes: Float, allocation_count: Float) -> Bool + @requires: energy < 0.5J +{ + exceeds_allocation_limit(allocation_bytes) || high_allocation_count(allocation_count) +} diff --git a/policies/security_sustainability.ecl b/policies/security_sustainability.ecl new file mode 100644 index 0000000..9c8375a --- /dev/null +++ b/policies/security_sustainability.ecl @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MPL-2.0 +// OikosBot Policy: Security-Sustainability Correlation +// +// Correlates panic-attack findings with eco scores. +// Functions that are BOTH high-energy AND have security weak points +// get elevated severity. + +def is_high_energy(energy_joules: Float) -> Bool + @requires: energy < 0.05J +{ + energy_joules > 50.0 +} + +def has_security_issues(weak_point_count: Float) -> Bool + @requires: energy < 0.05J +{ + weak_point_count > 0.0 +} + +def correlate(energy: Float, weak_points: Float) -> Bool + @requires: energy < 0.5J +{ + is_high_energy(energy) && has_security_issues(weak_points) +}