diff --git a/.agents/README.md b/.agents/README.md
index 80b4d28..b36056f 100644
--- a/.agents/README.md
+++ b/.agents/README.md
@@ -6,18 +6,27 @@ Project-local skills for Cursor agents working on this repository.
```
.agents/skills//SKILL.md
+.cursor/rules/*.mdc # always-on / scoped project rules
```
-Each skill teaches the agent domain-specific workflows for Fusion (Rust core + Python / Node / C# bindings).
+Each skill teaches domain-specific workflows for Fusion (Rust core + Python / Node / C# bindings) and the companion `fusion` CLI.
## Available skills
| Skill | Use when |
|-------|----------|
-| `fusion-architecture` | Understanding repo layout, binding layers, where logic belongs |
-| `fusion-bindings-parity` | Changing behavior that must stay aligned across Python, Node, C# |
-| `fusion-http-routes` | Routes, `@http_get` / `[HttpGet]`, `[module]`, `[action]`, Swagger |
+| `fusion-architecture` | Repo layout, binding layers, where logic belongs |
+| `fusion-bindings-parity` | Feature must land in Python **and** Node **and** C# |
+| `fusion-coding-standards` | Comments, tests preference, git staging, skill/doc hygiene |
+| `fusion-cli` | `fusion init` / commands / scaffold tree / CLI ↔ framework |
+| `fusion-http-routes` | Routes, `http_get` / `[HttpGet]`, `[module]`, `[action]`, Swagger |
| `fusion-release` | Version bumps, manifests, publish prep |
-| `fusion-testing` | Running checks and binding-specific tests |
+| `fusion-testing` | Running checks; investigating failed tests |
-Skills are loaded when the task matches the skill description (or when you name the skill explicitly).
+## Always-on rules
+
+`.cursor/rules/fusion-engineering.mdc` applies every session: parity across bindings, **examples in all three languages for new features**, function comments, prefer tests, never `git add .`, investigate failures, update skills when needed.
+
+## Hygiene
+
+When you add a new concept agents must remember, either extend an existing skill or add `.agents/skills//SKILL.md` and a row in this table.
diff --git a/.agents/skills/fusion-architecture/SKILL.md b/.agents/skills/fusion-architecture/SKILL.md
index 4f85506..8efcc50 100644
--- a/.agents/skills/fusion-architecture/SKILL.md
+++ b/.agents/skills/fusion-architecture/SKILL.md
@@ -1,9 +1,10 @@
---
name: fusion-architecture
description: >-
- Explains Fusion Framework repository layout, crate boundaries, and where
- new logic belongs (fusion-core vs Python/Node/C# bindings). Use when
- navigating the codebase, adding features, or deciding which layer to change.
+ Explains Fusion Framework repository layout, crate boundaries, where new
+ logic belongs (fusion-core vs Python/Node/C# bindings), tests layout, and
+ relationship to the fusion CLI. Use when navigating the codebase, adding
+ features, or deciding which layer to change.
---
# Fusion Architecture
@@ -12,18 +13,24 @@ description: >-
| Path | Role |
|------|------|
-| `crates/fusion-core/` | Shared Rust: naming, route tokens, HTTP conventions |
+| `crates/fusion-core/` | Shared Rust: naming, route tokens, HTTP conventions, settings helpers |
| `crates/fusion-py/` | Python binding (PyO3) + `python/fusion_framework/` package |
| `crates/fusion-node/` | Node binding (`index.js`, N-API) |
-| `bindings/csharp/FusionFramework/` | C# binding (source of truth for NuGet layout) |
+| `crates/fusion-ffi/` | C ABI for the C# binding |
+| `bindings/csharp/FusionFramework/` | C# binding (NuGet layout source of truth) |
+| `tests/` | Executable tests (Python / Node / C#) — not inside installable packages |
| `examples/` | Runnable samples per binding |
-| `scripts/` | Release tooling (`set-version.sh`) |
+| `scripts/` | Dev install, version bumps (`set-version.sh`) |
+| `.agents/skills/` | Agent skills for this repo |
+
+**Related external repo:** [fusion-tool](https://github.com/cipherunits/fusion-tool) — `fusion` CLI that scaffolds apps. See `fusion-cli` skill.
## Layering rules
1. **Put shared semantics in `fusion-core`** — route token resolution (`[module]`, `[action]`), path joining, handler naming. Bindings should call Rust helpers via FFI where possible.
2. **Bindings mirror behavior** — Python decorators, Node functions, C# attributes must produce the same mount paths and OpenAPI shapes.
3. **Do not duplicate business logic in three languages** — only binding-specific glue (decorators, reflection, module registration).
+4. **Tests live under `tests/`** — do not add `test_*.py` inside `fusion_framework/` package sources.
## Key entry points
@@ -33,6 +40,9 @@ description: >-
## When adding a feature
-1. Identify if it is cross-binding (yes → start in `fusion-core`).
-2. Implement mount + OpenAPI in all three bindings in one PR when possible.
-3. Add or extend an example under `examples/`.
+1. Identify if it is cross-binding (yes → start in `fusion-core` when semantics are shared).
+2. Implement in **Python, Node, and C#** in one change set (see `fusion-bindings-parity`).
+3. Add tests under `tests/` (preferred) and/or Rust unit tests.
+4. Add **usage examples in all three languages** under `examples/` (`.py` / `.mjs` / `.cs`) so the API shape is visible.
+5. If scaffolds or env JSON contracts change, update the `fusion-cli` skill and consider fusion-tool templates.
+6. Comment new functions; document dense logic (see `fusion-coding-standards`).
diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md
index b050822..6ef408b 100644
--- a/.agents/skills/fusion-bindings-parity/SKILL.md
+++ b/.agents/skills/fusion-bindings-parity/SKILL.md
@@ -2,20 +2,39 @@
name: fusion-bindings-parity
description: >-
Keeps Python, Node, and C# Fusion bindings aligned when changing APIs, routes,
- Swagger, or middleware. Use when editing more than one binding or adding
- cross-language behavior.
+ Swagger, middleware, or permissions. Use when editing more than one binding
+ or when the user asks to add a feature (always implement all three languages
+ unless they limit scope).
---
# Bindings Parity
+## Hard rule
+
+If the user says “add X” (permissions, middleware, route option, Swagger behavior, settings key, etc.) and X is framework surface area, implement it for:
+
+1. **Python**
+2. **Node**
+3. **C#**
+
+in the **same** change set unless they explicitly say “only Python” (or only one binding).
+
+Do not leave one language behind “for later” without saying so and getting confirmation.
+
## Checklist (every cross-binding change)
- [ ] `fusion-core` updated if semantics are shared
-- [ ] Python: `fusion_framework/` + `crates/fusion-py/src/api_types.rs`
-- [ ] Node: `crates/fusion-node/index.js`
+- [ ] Python: `fusion_framework/` + `crates/fusion-py/src/api_types.rs` as needed
+- [ ] Node: `crates/fusion-node/index.js` (+ `index.d.ts` if public types change)
- [ ] C#: `bindings/csharp/FusionFramework/*.cs`
-- [ ] Example snippet in `examples/` (at least one runnable file + others documented)
+- [ ] Tests under `tests/python/`, `tests/node/`, and/or `tests/csharp/` when behavior is testable
+- [ ] **Examples in all three languages** under `examples/` (`.py`, `.mjs`, `.cs`) showing how to use the new API
- [ ] README in C# binding updated if public API changed
+- [ ] Skills/docs updated if agents need new knowledge (`fusion-cli`, `fusion-http-routes`, …)
+
+## Examples rule
+
+New public surface → show usage in **Python + Node + C#**. Prefer the same basename for the trio (see `custom_http_routes.*`, `pagination.*`). Examples should be short and runnable enough to see the API shape, not full apps.
## Parity matrix
@@ -24,8 +43,11 @@ description: >-
| Module route | `@route("/api/[module]")` | `route('/api/[module]')(Cls)` | `[Route("/api/[module]")]` |
| Convention HTTP | `def get(self)` | `get()` method | `Get()` method |
| Custom HTTP | `@http_get("path/[action]")` | `httpGet('path/[action]')(proto.method)` | `[HttpGet("path/[action]")]` |
+| Middleware | `middleware.py` factories | factories in `index.js` | `Middleware.cs` |
+| Permissions | `permissions=` / `require_permissions` | `permissions` / `requirePermissions` | `PermissionTypes` / `RequirePermissions` |
| OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` |
| Version navbar | per-version OpenAPI routes | same | same |
+| Template routes | omit from OpenAPI | omit | omit |
## Verification commands
@@ -34,10 +56,13 @@ cargo test -p fusion-core naming
cargo check -p fusion-py
node --check crates/fusion-node/index.js
dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj
-python -m pytest crates/fusion-py/python/fusion_framework/test_http_route.py -q
+./tests/scripts/run-python.sh -q
+./tests/scripts/run-node.sh
+./tests/scripts/run-csharp.sh -q
```
## Style
- Match existing naming in each language (snake_case Python, camelCase Node helpers, PascalCase C#).
- Prefer minimal diffs; do not refactor unrelated binding code.
+- Comment new exported helpers (see `fusion-coding-standards`).
diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md
new file mode 100644
index 0000000..7575ea9
--- /dev/null
+++ b/.agents/skills/fusion-cli/SKILL.md
@@ -0,0 +1,167 @@
+---
+name: fusion-cli
+description: >-
+ Documents the Fusion Tool CLI (fusion init, command, module, add, update),
+ the scaffolded project tree, and how the CLI relates to this framework repo.
+ Use when explaining project layout, scaffolding, env JSON, or when framework
+ API changes must stay compatible with fusion-tool generators.
+---
+
+# Fusion CLI (fusion-tool)
+
+Apps are usually created with **Fusion Tool** (`fusion`), a separate repo:
+https://github.com/cipherunits/fusion-tool
+
+This skill describes the CLI from the **framework** side so agents know what
+generated projects look like and what must stay compatible.
+
+## Install & entry
+
+```bash
+fusion --help
+fusion --version
+```
+
+Binary name: `fusion`. Source of truth for generators: `fusion-tool` (`src/command/`, `src/setting/structure.rs`, `src/setting/environment.rs`).
+
+## Commands overview
+
+| Command | Purpose |
+|---------|---------|
+| `fusion init` | Scaffold a new Fusion app (Python / TypeScript / ASP.NET Core) |
+| `fusion command ` | Run a named command from `fusion..json` |
+| `fusion load-env` | Load `fusion..json` into the process environment |
+| `fusion module init` | Scaffold a **publishable library package** (not an app route module) |
+| `fusion add --github OWNER/REPO` | Vendor a module into the current app |
+| `fusion update` | Self-update the CLI binary |
+
+### `fusion init`
+
+```bash
+fusion init
+fusion init my-app
+fusion init --lang python --name myproject --description "…"
+```
+
+| Flag / arg | Values |
+|------------|--------|
+| `[DIRECTORY]` | Target dir (created if missing; default = cwd) |
+| `--lang` | `python`, `typescript`, `asp-core` |
+| `--name` | Project name |
+| `--description` | Short description |
+
+Writes `fusion-framework.toml`, `fusion.{dev,stage,prod}.json`, `.gitignore`, language entrypoint, sample route module, templates, and dependency pins to the framework version.
+
+### `fusion command`
+
+Commands live under the `commands` object in `fusion..json`.
+
+```bash
+fusion command run # default env: FUSION_ENV or `dev`
+fusion command run --stage
+fusion command run:stage # same
+fusion command run --prod
+fusion command run --env test
+fusion command --stage # list commands for that env
+```
+
+Runs via the shell from the project root with `FUSION_ENV` set so `core/settings` loads the matching file.
+
+### Modules vs route modules
+
+| Term | Meaning |
+|------|---------|
+| Route module | App code: `FusionBaseApi` / template under `src/modules/…` |
+| Library module | Separate package from `fusion module init` (`fusion.module.toml`), installed with `fusion add` |
+
+Do not confuse the two when naming APIs or writing docs.
+
+### `fusion module init` / `fusion add`
+
+```bash
+fusion module init --lang python --name example --description "…"
+fusion add --github OWNER/MODULE_NAME
+fusion add --github OWNER/MODULE_NAME@v1.0.0
+```
+
+Vendors under `.fusion/modules//` and records `[[modules]]` in `fusion-framework.toml`.
+
+## Scaffolded app layout (`fusion init`)
+
+Python shown; TypeScript/C# use the same tree with language extensions.
+
+```text
+/
+├── core/
+│ └── settings.py # Overlay (RELOAD, TEMPLATES_DIR, …)
+├── src/
+│ └── modules/
+│ └── products/
+│ └── products.py # HomePage (template) + ProductModule (API)
+├── templates/
+│ └── home/
+│ ├── index.html
+│ └── style.css
+├── main.py # Register middleware + FusionApp.listen()
+├── requirements.txt # Python pin (or package.json / *.csproj)
+├── pyproject.toml # Python only
+├── fusion-framework.toml # Project metadata + tool/framework versions
+├── fusion.dev.json # env=dev, port 8080, swagger on, reload
+├── fusion.stage.json # port 8081
+├── fusion.prod.json # port 9090
+└── .gitignore
+```
+
+TypeScript: `main.ts`, `core/settings.ts`, `package.json`, `tsconfig.json`.
+C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`.
+
+### What the starter demonstrates
+
+- `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger).
+- `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`.
+- Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`).
+- Opt-in middleware list in `main` (e.g. `request_id`, `cors`, `cache_headers`, `security_headers`, `framework_headers`). Framework does **not** auto-enable middleware; the scaffold opts in.
+
+### Default ports
+
+| Env | Port |
+|-----|------|
+| dev | 8080 |
+| stage | 8081 |
+| prod | 9090 |
+
+### Environment JSON shape
+
+```json
+{
+ "env": "dev",
+ "config": {
+ "host": "127.0.0.1",
+ "port": 8080,
+ "debug": true,
+ "fingerprint": { "enabled": false },
+ "swagger": { "enabled": true, "path": "/swagger" }
+ },
+ "commands": {
+ "run": "python main.py"
+ }
+}
+```
+
+`FUSION_ENV` selects `fusion..json` (default `dev`). Unresolved `HOST` placeholders must not crash listen — framework resolves safe defaults.
+
+## Compatibility duties (framework ↔ CLI)
+
+When changing public Fusion APIs used by scaffolds:
+
+1. Prefer keeping generated starter patterns working (or update **fusion-tool** templates in a follow-up / paired PR).
+2. Do not invent decorators/config keys that only exist in one binding.
+3. After middleware / route / settings changes, check whether `fusion-tool` `structure.rs` / `environment.rs` comments or defaults need updates.
+4. Version pin in the CLI (`FUSION_FRAMEWORK_VERSION`) is separate from this repo’s version bump (`./scripts/set-version.sh`).
+
+## Related
+
+- Framework layout: `fusion-architecture`
+- Binding alignment: `fusion-bindings-parity`
+- Routes: `fusion-http-routes`
+- CLI repo skills (if editing the tool itself): fusion-tool `.agents/skills/fusion-cli*`
diff --git a/.agents/skills/fusion-coding-standards/SKILL.md b/.agents/skills/fusion-coding-standards/SKILL.md
new file mode 100644
index 0000000..321c0ae
--- /dev/null
+++ b/.agents/skills/fusion-coding-standards/SKILL.md
@@ -0,0 +1,82 @@
+---
+name: fusion-coding-standards
+description: >-
+ Coding standards for Fusion Framework: function comments, clarifying
+ comments for complex code, preferred tests, failure investigation, and when
+ to update skills/docs. Use when writing or reviewing code in this repo.
+---
+
+# Coding standards
+
+## Comments
+
+### Functions / methods
+
+Every **new** public or non-trivial private function, method, or exported helper must have a one-line (or short) comment/docstring that states **what it does**.
+
+| Language | Prefer |
+|----------|--------|
+| Python | Docstring or `#` above `def` |
+| JavaScript | `/** … */` or `//` above the function |
+| C# | `/// …` or `//` above the member |
+| Rust | `///` for public items; `//` for local helpers when non-obvious |
+
+Do not restate the name alone (`// get user` on `get_user`). Say the behavior or contract.
+
+### Dense / hard sections
+
+When code becomes branching-heavy, protocol-sensitive, or easy to break (OpenAPI fill, middleware chain, route slot mounting, FFI):
+
+- Add short **why** comments at the tricky points.
+- Prefer extracting a named helper with a docstring over a wall of uncommented logic.
+
+## Tests (prefer writing them)
+
+When you add behavior:
+
+1. Prefer a test under `tests/python/`, `tests/node/`, `tests/csharp/`, or Rust `#[cfg(test)]`.
+2. Mirror coverage across bindings when the feature is cross-binding (see `fusion-bindings-parity`).
+3. Run the relevant script from `fusion-testing` before claiming done.
+
+### When a test fails
+
+1. Read the failure output (assertion, path, expected vs actual).
+2. Trace to implementation (wrong name stripping, async not awaited, header case, version prefix, etc.).
+3. Tell the user **what broke and why** in plain language.
+4. Fix the code or the incorrect expectation — never silently weaken assertions without saying so.
+
+## Git
+
+- Stage **individual files only**. Never `git add .` / `git add -A`.
+- Only commit when the user asks.
+
+## Docs & skills hygiene
+
+After introducing something agents or developers must know later:
+
+| Change type | Update |
+|-------------|--------|
+| New public API / middleware / route option | Binding parity + **examples in all three languages** (`examples/.py`, `.mjs`, `.cs`) + relevant skill |
+| New test layout or runner | `tests/README.md` + `fusion-testing` |
+| CLI-facing scaffold contract | `fusion-cli` skill; coordinate with fusion-tool if generators break |
+| Entirely new workflow | New `.agents/skills//SKILL.md` + row in `.agents/README.md` |
+
+### Examples (required for new features)
+
+Always add side-by-side usage demos so humans/agents can see the API shape:
+
+```text
+examples/.py
+examples/.mjs
+examples/.cs
+```
+
+Follow existing trios (`custom_http_routes.*`, `pagination.*`). Do not leave a language without an example when the feature exists in that binding.
+
+Keep skills concise; link to code paths instead of pasting large dumps.
+
+## Style reminders
+
+- Match existing naming (snake_case Python, camelCase Node, PascalCase C#).
+- Minimal diffs; no drive-by refactors.
+- Shared logic → `fusion-core` when possible.
diff --git a/.agents/skills/fusion-http-routes/SKILL.md b/.agents/skills/fusion-http-routes/SKILL.md
index 459cb06..b68186b 100644
--- a/.agents/skills/fusion-http-routes/SKILL.md
+++ b/.agents/skills/fusion-http-routes/SKILL.md
@@ -63,4 +63,8 @@ public class UserModule : FusionBaseApi {
## Tests
- Rust: `cargo test -p fusion-core naming`
-- Python: `test_http_route.py`
+- Python: `pytest tests/python`
+- Node: `./tests/scripts/run-node.sh`
+- C#: `./tests/scripts/run-csharp.sh`
+
+Cross-binding route/Swagger changes must update **all three** languages (`fusion-bindings-parity`).
diff --git a/.agents/skills/fusion-release/SKILL.md b/.agents/skills/fusion-release/SKILL.md
index e3d92c7..f32d243 100644
--- a/.agents/skills/fusion-release/SKILL.md
+++ b/.agents/skills/fusion-release/SKILL.md
@@ -34,3 +34,5 @@ Updates (when present):
- Keep all bindings on the **same version** for a release.
- Do not commit `bin/`, `obj/`, or `.pdb` artifacts from local `dotnet build`.
- Changelog/README updates only when the user requests documentation.
+- Stage release files **individually** (never `git add .`).
+- After a release that changes public APIs used by scaffolds, note whether fusion-tool’s `FUSION_FRAMEWORK_VERSION` / templates need a bump (see `fusion-cli`).
diff --git a/.agents/skills/fusion-testing/SKILL.md b/.agents/skills/fusion-testing/SKILL.md
index 8d8ad4c..5a8a07e 100644
--- a/.agents/skills/fusion-testing/SKILL.md
+++ b/.agents/skills/fusion-testing/SKILL.md
@@ -2,20 +2,63 @@
name: fusion-testing
description: >-
Runs Fusion Framework verification across Rust, Python, Node, and C#. Use
- after code changes, before commits, or when CI-like validation is needed
- locally.
+ after code changes, before commits, when CI-like validation is needed, or
+ when tests fail and need root-cause investigation.
---
# Testing & Verification
+## Prefer writing tests
+
+New behavior should land with tests when practical:
+
+| Layer | Where |
+|-------|--------|
+| Rust shared logic | `#[cfg(test)]` in `crates/fusion-core` (and related crates) |
+| Python | `tests/python/unit/` (pytest) |
+| Node | `tests/node/unit/*.test.js` (`node --test`) |
+| C# | `tests/csharp/FusionFramework.Tests/` (xUnit) |
+
+Do **not** put `test_*.py` under `crates/fusion-py/python/fusion_framework/`.
+
+## Full suite (recommended)
+
+```bash
+./tests/scripts/run-all.sh
+```
+
## Quick smoke (after route/API changes)
```bash
cargo test -p fusion-core naming
cargo check -p fusion-py
node --check crates/fusion-node/index.js
-dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj --no-restore 2>/dev/null || dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj
-python -m pytest crates/fusion-py/python/fusion_framework/test_http_route.py -q
+dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj
+./tests/scripts/run-python.sh -q
+```
+
+## Python (pytest)
+
+```bash
+./scripts/dev-install-python.sh --venv .venv
+source .venv/bin/activate
+pytest
+./tests/scripts/run-python.sh
+pytest tests/python/unit/test_http_route.py -q
+```
+
+## Node
+
+```bash
+cd crates/fusion-node && npm install && npm run build:debug
+./tests/scripts/run-node.sh
+```
+
+## C#
+
+```bash
+./tests/scripts/run-csharp.sh
+# builds fusion-ffi then: dotnet test … -c Release
```
## Full Rust workspace
@@ -29,18 +72,27 @@ cargo test --workspace
| Binding | Command |
|---------|---------|
-| Python package | `cd crates/fusion-py && maturin develop` (if building extension) |
-| Node | `node --check crates/fusion-node/index.js` |
-| C# | `dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj` |
+| Python | `pytest tests/python` (after `dev-install-python.sh`) |
+| Node | `./tests/scripts/run-node.sh` |
+| C# | `./tests/scripts/run-csharp.sh` |
+
+## Layout
+
+See `tests/README.md` for folder structure and conventions.
+
+## When tests fail (required)
+
+1. **Investigate** — read assertion, stack, expected vs actual.
+2. **Explain** — tell the user the root cause (e.g. class name → `[module]` stripping, async middleware not awaited, CORS header casing, version prefix missing).
+3. **Fix** — correct implementation or wrong expectation; do not hide failures.
+4. Order of suspicion for route/OpenAPI issues:
+ - `fusion-core` naming
+ - Python registry (`api_types.rs`) as reference
+ - Node / C# mount + OpenAPI fill
## What to exclude from git
- `bindings/csharp/**/bin/`, `obj/`
-- `target/`, `node_modules/`, `__pycache__/`
+- `target/`, `node_modules/`, `__pycache__/`, `.pytest_cache/`
- Local `.pdb` changes from debug builds
-
-## When tests fail
-
-1. Fix `fusion-core` first if naming/route tests fail.
-2. Then fix Python registry (`api_types.rs`) — often the reference implementation.
-3. Align Node and C# mount/OpenAPI with Python behavior.
+- Scratch `.nuget/` / `.tmp/` caches if created locally
diff --git a/.cursor/rules/fusion-engineering.mdc b/.cursor/rules/fusion-engineering.mdc
new file mode 100644
index 0000000..2f4c2c8
--- /dev/null
+++ b/.cursor/rules/fusion-engineering.mdc
@@ -0,0 +1,53 @@
+---
+description: Core Fusion Framework engineering rules — parity, comments, tests, git, docs
+alwaysApply: true
+---
+
+# Fusion engineering (always)
+
+## Cross-binding parity
+
+When the user asks to add or change a framework feature (routes, middleware, permissions, Swagger, settings, etc.), implement it in **all three** bindings in the same change set unless they explicitly limit scope:
+
+- Python (`crates/fusion-py/`)
+- Node (`crates/fusion-node/`)
+- C# (`bindings/csharp/FusionFramework/`)
+
+Shared semantics belong in `crates/fusion-core/` first. Follow the `fusion-bindings-parity` skill.
+
+## Examples (every new public feature)
+
+Whenever a **new** user-facing feature or API is added (middleware, route options, permissions, pagination helpers, settings keys, etc.), add **usage examples in all three languages** under `examples/` so the shape is visible side by side:
+
+- Python: `examples/.py`
+- Node: `examples/.mjs`
+- C#: `examples/.cs` (or a small folder if a project is required)
+
+Match existing naming (`custom_http_routes.py` / `.mjs` / `.cs`, `pagination.py` / `.mjs` / `.cs`). Extend an existing trio when the feature fits; otherwise create a new trio. Do not ship Python-only demos for cross-binding APIs.
+
+## Comments
+
+- Every **new or meaningfully changed** function/method must have a short comment or docstring stating what it does.
+- When logic is dense, non-obvious, or branched, add inline comments that explain **why**, not just what the syntax does.
+
+## Tests
+
+- Prefer adding or updating tests for new behavior under `tests/` (Python / Node / C#) or `#[cfg(test)]` in Rust.
+- If a test fails: **investigate**, report the root cause clearly, then fix. Do not ignore or skip without explaining why.
+
+## Git staging
+
+- **Never** run `git add .` or `git add -A` / `git add --all`.
+- Stage files **one path at a time** (`git add path/to/file`).
+
+## Documentation & skills
+
+When you add a user-facing or agent-facing concept (new CLI-related behavior, new API surface, new test layout, new workflow):
+
+- Update the relevant skill under `.agents/skills/` and/or `.agents/README.md`.
+- Add a new skill if no existing one covers it.
+- Keep framework docs/examples aligned when public API changes.
+
+## Related skills
+
+Read when relevant: `fusion-architecture`, `fusion-bindings-parity`, `fusion-http-routes`, `fusion-testing`, `fusion-cli`, `fusion-coding-standards`, `fusion-release`.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 14d4365..a19a793 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -44,10 +44,10 @@ jobs:
run: python -m pip install ruff
- name: ruff check
- run: ruff check crates/fusion-py/python
+ run: ruff check crates/fusion-py/python tests/python
- name: ruff format
- run: ruff format --check crates/fusion-py/python
+ run: ruff format --check crates/fusion-py/python tests/python
# Node binding is a thin CommonJS wrapper over N-API; no eslint config in this repo.
- uses: actions/setup-node@v4
@@ -100,7 +100,9 @@ jobs:
shell: bash
run: |
pip install dist/*.whl
+ pip install pytest
python -c "from fusion_framework.api import FusionBaseApi; from fusion_framework.route import router; from fusion_framework import settings; print('ok')"
+ pytest tests/python -q
node:
name: Node addon (${{ matrix.settings.target }})
diff --git a/crates/fusion-py/python/fusion_framework/test_middleware.py b/crates/fusion-py/python/fusion_framework/test_middleware.py
deleted file mode 100644
index 82cc48b..0000000
--- a/crates/fusion-py/python/fusion_framework/test_middleware.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""Unit tests for middleware chain (no server required)."""
-
-import asyncio
-import inspect
-
-from fusion_framework.middleware import (
- bearer_jwt,
- clear_active_global,
- cors,
- dispatch_route,
- framework_headers,
- request_id,
- require_roles,
- set_active_global,
-)
-
-
-def _handler(request):
- return {"status": 200, "body": {"state": request.get("state", {})}}
-
-
-async def _async_handler(request):
- return {"status": 200, "body": {"ok": True, "path": request.get("path")}}
-
-
-def _sync_invoker_like(request):
- """Mirrors PyO3 HandlerInvoker: sync ``__call__`` that may return a coroutine."""
- return _async_handler(request)
-
-
-def test_require_roles_allows_matching_role():
- request = {"headers": {}, "state": {"jwt": {"roles": ["admin"]}}}
- chain = [require_roles("admin", "super_admin")]
- result = dispatch_route(request, _handler, chain)
- assert result["status"] == 200
-
-
-def test_require_roles_blocks_missing_role():
- request = {"headers": {}, "state": {"jwt": {"roles": ["user"]}}}
- chain = [require_roles("admin")]
- result = dispatch_route(request, _handler, chain)
- assert result["status"] == 403
-
-
-def test_bearer_jwt_populates_state():
- token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0."
- request = {"headers": {"Authorization": f"Bearer {token}"}}
- set_active_global([bearer_jwt()])
- try:
- result = dispatch_route(request, _handler, [])
- assert result["status"] == 200
- assert result["body"]["state"]["jwt"]["sub"] == "1"
- finally:
- clear_active_global()
-
-
-def test_no_middleware_by_default():
- """FusionApp does not inject framework headers unless explicitly added."""
- clear_active_global()
- set_active_global([])
- try:
- request = {"path": "/", "headers": {}, "method": "GET"}
- result = dispatch_route(request, _handler, [])
- assert result["status"] == 200
- headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
- assert "x-powered-by" not in headers
- finally:
- clear_active_global()
-
-
-def test_request_id_header():
- clear_active_global()
- set_active_global([request_id()])
- try:
- request = {"path": "/", "headers": {}, "method": "GET"}
- result = dispatch_route(request, _handler, [])
- headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
- assert "x-request-id" in headers
- assert result["body"]["state"]["request_id"] == headers["x-request-id"]
- finally:
- clear_active_global()
-
-
-def test_cors_options_preflight():
- clear_active_global()
- set_active_global([cors()])
- try:
- request = {
- "path": "/api",
- "headers": {"Origin": "https://example.com"},
- "method": "OPTIONS",
- }
- result = dispatch_route(request, _handler, [])
- assert result["status"] == 204
- headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
- assert headers.get("access-control-allow-origin")
- finally:
- clear_active_global()
-
-
-def test_framework_headers_awaits_async_handler():
- """Regression: sync framework_headers must not stringify coroutine bodies.
-
- HandlerInvoker is sync, so the chain stays sync; call_next returns a raw
- coroutine that must be awaited before headers are merged.
- """
- clear_active_global()
- set_active_global([framework_headers()])
- try:
- request = {"path": "/membership", "headers": {}, "method": "GET"}
- result = dispatch_route(request, _sync_invoker_like, [])
- assert inspect.isawaitable(result), "async handler result must stay awaitable for Rust"
- resolved = asyncio.run(result)
- assert resolved["status"] == 200
- assert resolved["body"] == {"ok": True, "path": "/membership"}
- headers = {str(k).lower(): v for k, v in (resolved.get("headers") or {}).items()}
- assert "x-powered-by" in headers
- body = resolved.get("body")
- assert not (isinstance(body, str) and body.startswith("}}',
- encoding="utf-8",
- )
- html = render_template("page.html", {}, templates_root=root)
- assert "fusion-btn" in html
- assert "Go" in html
-
-
-def test_fusion_base_template_context():
- class Page(FusionBaseTemplate):
- template = "hello.html"
-
- def context(self):
- return {"name": "Fusion"}
-
- with tempfile.TemporaryDirectory() as tmp:
- root = Path(tmp)
- (root / "hello.html").write_text("Hello {{ name }}!
", encoding="utf-8")
-
- page = Page({"method": "GET", "path": "/"})
- page.templates_dir = str(root)
- out = page.render()
- assert out["status"] == 200
- assert out["headers"]["content-type"].startswith("text/html")
- assert "Hello Fusion!" in out["body"]
-
-
-def test_template_name_required():
- class Bad(FusionBaseTemplate):
- pass
-
- with pytest.raises(ValueError, match="template"):
- Bad({}).template_name()
-
-
-def test_template_get_returns_json_with_accept():
- class Page(FusionBaseTemplate):
- template = "hello.html"
-
- def context(self):
- return {"name": "Fusion"}
-
- with tempfile.TemporaryDirectory() as tmp:
- root = Path(tmp)
- (root / "hello.html").write_text("Hello {{ name }}!
", encoding="utf-8")
-
- page = Page(
- {
- "method": "GET",
- "path": "/pages/home",
- "headers": {"accept": "application/json"},
- }
- )
- page.templates_dir = str(root)
- assert page.get() == {"name": "Fusion"}
-
-
-def test_template_get_returns_json_with_format_query():
- class Page(FusionBaseTemplate):
- template = "hello.html"
-
- def context(self):
- return {"name": "Fusion"}
-
- page = Page(
- {
- "method": "GET",
- "path": "/pages/home",
- "query": {"format": "json"},
- }
- )
- assert page.get() == {"name": "Fusion"}
-
-
-def test_template_get_returns_html_for_browser_accept():
- class Page(FusionBaseTemplate):
- template = "hello.html"
-
- def context(self):
- return {"name": "Fusion"}
-
- with tempfile.TemporaryDirectory() as tmp:
- root = Path(tmp)
- (root / "hello.html").write_text("Hello {{ name }}!
", encoding="utf-8")
-
- page = Page(
- {
- "method": "GET",
- "path": "/pages/home",
- "headers": {
- "accept": "text/html,application/xhtml+xml,application/xml;q=0.9"
- },
- }
- )
- page.templates_dir = str(root)
- out = page.get()
- assert out["status"] == 200
- assert out["headers"]["content-type"].startswith("text/html")
- assert "Hello Fusion!" in out["body"]
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..d12fb50
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,7 @@
+[pytest]
+testpaths = tests/python
+pythonpath = crates/fusion-py/python
+addopts = -ra --strict-markers
+markers =
+ integration: tests that require a running HTTP server
+ slow: long-running tests (opt-in)
diff --git a/scripts/README.md b/scripts/README.md
index 97f0588..d62c3e5 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -155,7 +155,7 @@ Each script runs a small smoke check before it finishes. After install, you can
```bash
python -c "from fusion_framework import settings; print('python ok')"
python examples/template_demo.py
-python -m pytest crates/fusion-py/python/fusion_framework/test_http_route.py -q
+python -m pytest tests/python -q
```
**Node**
diff --git a/scripts/dev-install-python.sh b/scripts/dev-install-python.sh
index 0405887..ef1572b 100755
--- a/scripts/dev-install-python.sh
+++ b/scripts/dev-install-python.sh
@@ -94,5 +94,5 @@ cat <}}
diff --git a/tests/python/conftest.py b/tests/python/conftest.py
new file mode 100644
index 0000000..c691af9
--- /dev/null
+++ b/tests/python/conftest.py
@@ -0,0 +1,20 @@
+"""Shared pytest fixtures for Fusion Framework Python tests."""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("fusion_framework")
+
+from fusion_framework._fusion import clear_routes
+from fusion_framework.middleware import clear_active_global
+
+
+@pytest.fixture(autouse=True)
+def _isolate_fusion_state():
+ """Reset route registry and global middleware between tests."""
+ clear_routes()
+ clear_active_global()
+ yield
+ clear_routes()
+ clear_active_global()
diff --git a/tests/python/integration/README.md b/tests/python/integration/README.md
new file mode 100644
index 0000000..3fb8614
--- /dev/null
+++ b/tests/python/integration/README.md
@@ -0,0 +1,9 @@
+# Integration tests (future)
+
+HTTP-level tests that start a real `FusionApp` and hit endpoints with `httpx` or `curl` belong here.
+
+Mark with `@pytest.mark.integration` and run:
+
+```bash
+pytest tests/python/integration -m integration
+```
diff --git a/crates/fusion-py/python/fusion_framework/test_http_route.py b/tests/python/unit/test_http_route.py
similarity index 86%
rename from crates/fusion-py/python/fusion_framework/test_http_route.py
rename to tests/python/unit/test_http_route.py
index b7e06b6..2fd5306 100644
--- a/crates/fusion-py/python/fusion_framework/test_http_route.py
+++ b/tests/python/unit/test_http_route.py
@@ -1,19 +1,11 @@
"""Tests for custom HTTP method routes (@http_get / HttpGet)."""
-from fusion_framework._fusion import clear_routes
+from fusion_framework._fusion import openapi_spec
from fusion_framework.api import FusionBaseApi
from fusion_framework.http_route import http_get
from fusion_framework.route import route
-def setup_function():
- clear_routes()
-
-
-def teardown_function():
- clear_routes()
-
-
def test_custom_http_get_with_action_token():
@route("/api/[module]")
class UserModule(FusionBaseApi):
@@ -21,8 +13,6 @@ class UserModule(FusionBaseApi):
def UserAction(self):
return {"ok": True}
- from fusion_framework._fusion import openapi_spec
-
spec = openapi_spec()
assert "/api/user/test/user" in spec["paths"]
assert "get" in spec["paths"]["/api/user/test/user"]
@@ -39,8 +29,6 @@ def get(self):
def ListAction(self):
return {"mode": "custom"}
- from fusion_framework._fusion import openapi_spec
-
spec = openapi_spec()
assert "/api/product" in spec["paths"]
assert "get" in spec["paths"]["/api/product"]
@@ -61,8 +49,6 @@ def CatalogAction(self):
def AdminAction(self):
return {"ok": True}
- from fusion_framework._fusion import openapi_spec
-
spec = openapi_spec()
convention = spec["paths"]["/api/product"]["get"]
catalog = spec["paths"]["/api/product/catalog/catalog"]["get"]
diff --git a/tests/python/unit/test_middleware.py b/tests/python/unit/test_middleware.py
new file mode 100644
index 0000000..d7234bd
--- /dev/null
+++ b/tests/python/unit/test_middleware.py
@@ -0,0 +1,106 @@
+"""Unit tests for middleware chain (no server required)."""
+
+import asyncio
+import inspect
+
+from fusion_framework.middleware import (
+ bearer_jwt,
+ clear_active_global,
+ cors,
+ dispatch_route,
+ framework_headers,
+ request_id,
+ require_roles,
+ set_active_global,
+)
+
+
+def _handler(request):
+ return {"status": 200, "body": {"state": request.get("state", {})}}
+
+
+async def _async_handler(request):
+ return {"status": 200, "body": {"ok": True, "path": request.get("path")}}
+
+
+def _sync_invoker_like(request):
+ """Mirrors PyO3 HandlerInvoker: sync ``__call__`` that may return a coroutine."""
+ return _async_handler(request)
+
+
+def test_require_roles_allows_matching_role():
+ request = {"headers": {}, "state": {"jwt": {"roles": ["admin"]}}}
+ chain = [require_roles("admin", "super_admin")]
+ result = dispatch_route(request, _handler, chain)
+ assert result["status"] == 200
+
+
+def test_require_roles_blocks_missing_role():
+ request = {"headers": {}, "state": {"jwt": {"roles": ["user"]}}}
+ chain = [require_roles("admin")]
+ result = dispatch_route(request, _handler, chain)
+ assert result["status"] == 403
+
+
+def test_bearer_jwt_populates_state():
+ token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZXMiOlsiYWRtaW4iXX0."
+ request = {"headers": {"Authorization": f"Bearer {token}"}}
+ set_active_global([bearer_jwt()])
+ result = dispatch_route(request, _handler, [])
+ assert result["status"] == 200
+ assert result["body"]["state"]["jwt"]["sub"] == "1"
+
+
+def test_no_middleware_by_default():
+ """FusionApp does not inject framework headers unless explicitly added."""
+ set_active_global([])
+ request = {"path": "/", "headers": {}, "method": "GET"}
+ result = dispatch_route(request, _handler, [])
+ assert result["status"] == 200
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert "x-powered-by" not in headers
+
+
+def test_request_id_header():
+ set_active_global([request_id()])
+ request = {"path": "/", "headers": {}, "method": "GET"}
+ result = dispatch_route(request, _handler, [])
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert "x-request-id" in headers
+ assert result["body"]["state"]["request_id"] == headers["x-request-id"]
+
+
+def test_cors_options_preflight():
+ set_active_global([cors()])
+ request = {
+ "path": "/api",
+ "headers": {"Origin": "https://example.com"},
+ "method": "OPTIONS",
+ }
+ result = dispatch_route(request, _handler, [])
+ assert result["status"] == 204
+ headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()}
+ assert headers.get("access-control-allow-origin")
+
+
+def test_framework_headers_awaits_async_handler():
+ """Regression: sync framework_headers must not stringify coroutine bodies."""
+ set_active_global([framework_headers()])
+ request = {"path": "/membership", "headers": {}, "method": "GET"}
+ result = dispatch_route(request, _sync_invoker_like, [])
+ assert inspect.isawaitable(result), "async handler result must stay awaitable for Rust"
+ resolved = asyncio.run(result)
+ assert resolved["status"] == 200
+ assert resolved["body"] == {"ok": True, "path": "/membership"}
+ headers = {str(k).lower(): v for k, v in (resolved.get("headers") or {}).items()}
+ assert "x-powered-by" in headers
+ body = resolved.get("body")
+ assert not (isinstance(body, str) and body.startswith("Hello {{ name }}!
", encoding="utf-8")
+
+ page = Page({"method": "GET", "path": "/"})
+ page.templates_dir = str(tmp_path)
+ out = page.render()
+ assert out["status"] == 200
+ assert out["headers"]["content-type"].startswith("text/html")
+ assert "Hello Fusion!" in out["body"]
+
+
+def test_template_name_required():
+ class Bad(FusionBaseTemplate):
+ pass
+
+ with pytest.raises(ValueError, match="template"):
+ Bad({}).template_name()
+
+
+def test_template_get_returns_json_with_accept(tmp_path: Path):
+ class Page(FusionBaseTemplate):
+ template = "hello.html"
+
+ def context(self):
+ return {"name": "Fusion"}
+
+ (tmp_path / "hello.html").write_text("Hello {{ name }}!
", encoding="utf-8")
+
+ page = Page(
+ {
+ "method": "GET",
+ "path": "/pages/home",
+ "headers": {"accept": "application/json"},
+ }
+ )
+ page.templates_dir = str(tmp_path)
+ assert page.get() == {"name": "Fusion"}
+
+
+def test_template_get_returns_json_with_format_query():
+ class Page(FusionBaseTemplate):
+ template = "hello.html"
+
+ def context(self):
+ return {"name": "Fusion"}
+
+ page = Page(
+ {
+ "method": "GET",
+ "path": "/pages/home",
+ "query": {"format": "json"},
+ }
+ )
+ assert page.get() == {"name": "Fusion"}
+
+
+def test_template_get_returns_html_for_browser_accept(tmp_path: Path):
+ class Page(FusionBaseTemplate):
+ template = "hello.html"
+
+ def context(self):
+ return {"name": "Fusion"}
+
+ (tmp_path / "hello.html").write_text("Hello {{ name }}!
", encoding="utf-8")
+
+ page = Page(
+ {
+ "method": "GET",
+ "path": "/pages/home",
+ "headers": {
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9"
+ },
+ }
+ )
+ page.templates_dir = str(tmp_path)
+ out = page.get()
+ assert out["status"] == 200
+ assert out["headers"]["content-type"].startswith("text/html")
+ assert "Hello Fusion!" in out["body"]
diff --git a/tests/scripts/run-all.sh b/tests/scripts/run-all.sh
new file mode 100755
index 0000000..6029a91
--- /dev/null
+++ b/tests/scripts/run-all.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Run the full Fusion Framework test suite (local).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+echo "==> Rust (fusion-core)"
+cargo test -p fusion-core
+
+echo "==> Node syntax"
+node --check crates/fusion-node/index.js
+
+echo "==> C# build"
+dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj --no-restore 2>/dev/null \
+ || dotnet build bindings/csharp/FusionFramework/FusionFramework.csproj
+
+echo "==> Python (pytest)"
+if ! python3 -c "import fusion_framework" 2>/dev/null; then
+ echo " fusion_framework not installed — run: ./scripts/dev-install-python.sh --venv .venv"
+ exit 1
+fi
+python3 -m pytest tests/python -q
+
+echo "==> All checks passed"
diff --git a/tests/scripts/run-python.sh b/tests/scripts/run-python.sh
new file mode 100755
index 0000000..0a9f49f
--- /dev/null
+++ b/tests/scripts/run-python.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+cd "$ROOT"
+
+if ! python3 -c "import fusion_framework" 2>/dev/null; then
+ echo "fusion_framework not installed — run: ./scripts/dev-install-python.sh --venv .venv" >&2
+ exit 1
+fi
+
+python3 -m pytest tests/python "$@"