From 8b4c79c43180c7b8eb659e6c25d170577f4d0c19 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 15 Sep 2026 23:26:37 +0200 Subject: [PATCH] 0.1.0-0-pre.1 --- .cargo/config.toml | 6 + .gitignore | 65 ++ .settings/org.eclipse.core.resources.prefs | 2 + Android/README.md | 19 + Android/build.gradle | 4 + Android/common/build.gradle | 4 + .../sasedev/games/common/NativeBridge.java | 19 + .../games/common/SaseGameActivity.java | 18 + Android/game-reflex-poc/build.gradle | 4 + .../sasedev/games/reflex/ReflexActivity.java | 15 + .../src/main/res/values/strings.xml | 5 + Android/game-snake-poc/build.gradle | 4 + .../sasedev/games/snake/SnakeActivity.java | 15 + .../src/main/res/values/strings.xml | 5 + Android/settings.gradle | 8 + CHANGELOG.md | 15 + Cargo.toml | 47 ++ README.md | 34 + ROADMAP.md | 19 + RULES.md | 23 + assets/common/README.md | 6 + assets/game-reflex-poc/README.md | 6 + assets/game-snake-poc/README.md | 6 + clippy.toml | 28 + crates/engines/engine-v1-common/Cargo.toml | 14 + crates/engines/engine-v1-common/src/action.rs | 21 + crates/engines/engine-v1-common/src/lib.rs | 13 + .../engines/engine-v1-platform-api/Cargo.toml | 14 + .../engines/engine-v1-platform-api/src/lib.rs | 13 + .../engine-v1-platform-api/src/service.rs | 17 + crates/engines/engine-v1-sdl/Cargo.toml | 17 + crates/engines/engine-v1-sdl/src/lib.rs | 15 + crates/engines/engine-v1-sdl/src/runtime.rs | 14 + crates/games/game-reflex-poc/Cargo.toml | 19 + crates/games/game-reflex-poc/src/lib.rs | 13 + crates/games/game-reflex-poc/src/state.rs | 37 ++ crates/games/game-snake-poc/Cargo.toml | 19 + crates/games/game-snake-poc/src/lib.rs | 13 + crates/games/game-snake-poc/src/state.rs | 43 ++ deltas/0.1.0/0-pre.1.md | 36 ++ docs/000-README.md | 17 + .../001-WORKSPACE_ARCHITECTURE.md | 56 ++ docs/architecture/002-ANDROID_ARCHITECTURE.md | 42 ++ docs/rules/FILE_CONTRACTS.md | 17 + docs/rules/RULES_DOCUMENTATION.md | 14 + docs/rules/RULES_GENERAL.md | 37 ++ docs/rules/RULES_PROJECT.md | 51 ++ docs/rules/RULES_RUST.md | 45 ++ docs/rules/VERSION_WORKFLOW.md | 75 +++ docs/validation/001-VALIDATION_GATES.md | 17 + prompts/000-V0_1_0_START_PROMPT.md | 6 + rustfmt.toml | 23 + scripts/audit_markdown_tables.py | 251 ++++++++ scripts/audit_project_workspace_rules.py | 63 ++ scripts/audit_rust_export_completeness.py | 283 +++++++++ scripts/audit_rust_general_rules.py | 587 ++++++++++++++++++ scripts/audit_rust_workspace_rules.py | 35 ++ 57 files changed, 2314 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .gitignore create mode 100644 .settings/org.eclipse.core.resources.prefs create mode 100644 Android/README.md create mode 100644 Android/build.gradle create mode 100644 Android/common/build.gradle create mode 100644 Android/common/src/main/java/com/sasedev/games/common/NativeBridge.java create mode 100644 Android/common/src/main/java/com/sasedev/games/common/SaseGameActivity.java create mode 100644 Android/game-reflex-poc/build.gradle create mode 100644 Android/game-reflex-poc/src/main/java/com/sasedev/games/reflex/ReflexActivity.java create mode 100644 Android/game-reflex-poc/src/main/res/values/strings.xml create mode 100644 Android/game-snake-poc/build.gradle create mode 100644 Android/game-snake-poc/src/main/java/com/sasedev/games/snake/SnakeActivity.java create mode 100644 Android/game-snake-poc/src/main/res/values/strings.xml create mode 100644 Android/settings.gradle create mode 100644 CHANGELOG.md create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 RULES.md create mode 100644 assets/common/README.md create mode 100644 assets/game-reflex-poc/README.md create mode 100644 assets/game-snake-poc/README.md create mode 100644 clippy.toml create mode 100644 crates/engines/engine-v1-common/Cargo.toml create mode 100644 crates/engines/engine-v1-common/src/action.rs create mode 100644 crates/engines/engine-v1-common/src/lib.rs create mode 100644 crates/engines/engine-v1-platform-api/Cargo.toml create mode 100644 crates/engines/engine-v1-platform-api/src/lib.rs create mode 100644 crates/engines/engine-v1-platform-api/src/service.rs create mode 100644 crates/engines/engine-v1-sdl/Cargo.toml create mode 100644 crates/engines/engine-v1-sdl/src/lib.rs create mode 100644 crates/engines/engine-v1-sdl/src/runtime.rs create mode 100644 crates/games/game-reflex-poc/Cargo.toml create mode 100644 crates/games/game-reflex-poc/src/lib.rs create mode 100644 crates/games/game-reflex-poc/src/state.rs create mode 100644 crates/games/game-snake-poc/Cargo.toml create mode 100644 crates/games/game-snake-poc/src/lib.rs create mode 100644 crates/games/game-snake-poc/src/state.rs create mode 100644 deltas/0.1.0/0-pre.1.md create mode 100644 docs/000-README.md create mode 100644 docs/architecture/001-WORKSPACE_ARCHITECTURE.md create mode 100644 docs/architecture/002-ANDROID_ARCHITECTURE.md create mode 100644 docs/rules/FILE_CONTRACTS.md create mode 100644 docs/rules/RULES_DOCUMENTATION.md create mode 100644 docs/rules/RULES_GENERAL.md create mode 100644 docs/rules/RULES_PROJECT.md create mode 100644 docs/rules/RULES_RUST.md create mode 100644 docs/rules/VERSION_WORKFLOW.md create mode 100644 docs/validation/001-VALIDATION_GATES.md create mode 100644 prompts/000-V0_1_0_START_PROMPT.md create mode 100644 rustfmt.toml create mode 100755 scripts/audit_markdown_tables.py create mode 100755 scripts/audit_project_workspace_rules.py create mode 100755 scripts/audit_rust_export_completeness.py create mode 100755 scripts/audit_rust_general_rules.py create mode 100755 scripts/audit_rust_workspace_rules.py diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b9cff0a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# file: .cargo/config.toml +# version: 1 + +[build] +target-dir = "../builds/sasedev-games/target" # path of where to place generated artifacts +build-dir = "../builds/sasedev-games/target" # path of where to place intermediate build artifacts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a6dcd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# file: .gitignore +# version: 2 + +# Rust build artifacts +# /target/ # target is outside. +Cargo.lock + +# Logs +/logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Node / frontend dependencies and generated artifacts +**/node_modules/ +**/dist/ +bindings/ +gen/ +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Android +/Android/.gradle/ +/Android/**/build/ + +# Editor and IDE files +.vscode/* +!.vscode/extensions.json +.idea/ +.settings/* +!.settings/org.eclipse.core.resources.prefs +.project +*.suo +*.ntvs* +*.njsproj +*.sln +*.swp +*.swo +*~ +.pydevproject +*.iml + +# Operating system files +.DS_Store +Thumbs.db + +# Runtime/local data +/var/ + +# Environment and local secrets +.env +.env.* +!.env.example + +# Python helper artifacts +__pycache__/ +*.py[cod] + +# Others +*.jks +*.keystore +local.properties diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 0000000..99f26c0 --- /dev/null +++ b/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/Android/README.md b/Android/README.md new file mode 100644 index 0000000..4415e99 --- /dev/null +++ b/Android/README.md @@ -0,0 +1,19 @@ + + + +# Android + +Squelette du frontend Android multi-module. + +La baseline définit les frontières Java et les modules, mais ne fige pas encore Android Gradle Plugin, SDK/NDK, SDL3 AAR ni le pipeline Cargo/NDK. Ces éléments seront ajoutés ensemble dans la prerelease Android exécutable afin d'éviter une fausse configuration partiellement fonctionnelle. + +Structure cible : + +```text +Android/ +├── common/ +├── game-reflex-poc/ +└── game-snake-poc/ +``` + +`common` deviendra une Android Library. Chaque module jeu deviendra une application indépendante et réutilisera directement les crates Rust du workspace racine sans les recopier. diff --git a/Android/build.gradle b/Android/build.gradle new file mode 100644 index 0000000..254b818 --- /dev/null +++ b/Android/build.gradle @@ -0,0 +1,4 @@ +// file: Android/build.gradle +// version: 1 + +// Android Gradle Plugin and repositories are intentionally introduced in the dedicated executable Android prerelease. diff --git a/Android/common/build.gradle b/Android/common/build.gradle new file mode 100644 index 0000000..5af6d2a --- /dev/null +++ b/Android/common/build.gradle @@ -0,0 +1,4 @@ +// file: Android/common/build.gradle +// version: 1 + +// This module will become the reusable Android library containing SDLActivity integration and common platform services. diff --git a/Android/common/src/main/java/com/sasedev/games/common/NativeBridge.java b/Android/common/src/main/java/com/sasedev/games/common/NativeBridge.java new file mode 100644 index 0000000..ce5cf7e --- /dev/null +++ b/Android/common/src/main/java/com/sasedev/games/common/NativeBridge.java @@ -0,0 +1,19 @@ +// file: Android/common/src/main/java/com/sasedev/games/common/NativeBridge.java +// version: 1 + +package com.sasedev.games.common; + +/** Stable Java side of the future Java/JNI platform bridge. */ +public final class NativeBridge { + private NativeBridge() { + } + + /** + * Returns the bridge contract version. + * + * @return bridge contract version + */ + public static int contractVersion() { + return 1; + } +} diff --git a/Android/common/src/main/java/com/sasedev/games/common/SaseGameActivity.java b/Android/common/src/main/java/com/sasedev/games/common/SaseGameActivity.java new file mode 100644 index 0000000..ad82e34 --- /dev/null +++ b/Android/common/src/main/java/com/sasedev/games/common/SaseGameActivity.java @@ -0,0 +1,18 @@ +// file: Android/common/src/main/java/com/sasedev/games/common/SaseGameActivity.java +// version: 1 + +package com.sasedev.games.common; + +/** + * Common Android activity boundary for games.sasedev applications. + * + *

The executable Android delta will make this class extend SDLActivity once the SDL3 AAR is introduced.

+ */ +public abstract class SaseGameActivity { + /** + * Returns the stable identifier of the game hosted by the activity. + * + * @return game identifier + */ + public abstract String gameId(); +} diff --git a/Android/game-reflex-poc/build.gradle b/Android/game-reflex-poc/build.gradle new file mode 100644 index 0000000..0b2eaa7 --- /dev/null +++ b/Android/game-reflex-poc/build.gradle @@ -0,0 +1,4 @@ +// file: Android/game-reflex-poc/build.gradle +// version: 1 + +// This module will become the Reflex POC Android application. diff --git a/Android/game-reflex-poc/src/main/java/com/sasedev/games/reflex/ReflexActivity.java b/Android/game-reflex-poc/src/main/java/com/sasedev/games/reflex/ReflexActivity.java new file mode 100644 index 0000000..38f6662 --- /dev/null +++ b/Android/game-reflex-poc/src/main/java/com/sasedev/games/reflex/ReflexActivity.java @@ -0,0 +1,15 @@ +// file: Android/game-reflex-poc/src/main/java/com/sasedev/games/reflex/ReflexActivity.java +// version: 1 + +package com.sasedev.games.reflex; + +import com.sasedev.games.common.SaseGameActivity; + +/** Android-specific host declaration for the Reflex POC. */ +public final class ReflexActivity extends SaseGameActivity { + /** {@inheritDoc} */ + @Override + public String gameId() { + return "game-reflex-poc"; + } +} diff --git a/Android/game-reflex-poc/src/main/res/values/strings.xml b/Android/game-reflex-poc/src/main/res/values/strings.xml new file mode 100644 index 0000000..015a836 --- /dev/null +++ b/Android/game-reflex-poc/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + Reflex POC + diff --git a/Android/game-snake-poc/build.gradle b/Android/game-snake-poc/build.gradle new file mode 100644 index 0000000..89932aa --- /dev/null +++ b/Android/game-snake-poc/build.gradle @@ -0,0 +1,4 @@ +// file: Android/game-snake-poc/build.gradle +// version: 1 + +// This module will become the Snake POC Android application. diff --git a/Android/game-snake-poc/src/main/java/com/sasedev/games/snake/SnakeActivity.java b/Android/game-snake-poc/src/main/java/com/sasedev/games/snake/SnakeActivity.java new file mode 100644 index 0000000..cb936ec --- /dev/null +++ b/Android/game-snake-poc/src/main/java/com/sasedev/games/snake/SnakeActivity.java @@ -0,0 +1,15 @@ +// file: Android/game-snake-poc/src/main/java/com/sasedev/games/snake/SnakeActivity.java +// version: 1 + +package com.sasedev.games.snake; + +import com.sasedev.games.common.SaseGameActivity; + +/** Android-specific host declaration for the Snake POC. */ +public final class SnakeActivity extends SaseGameActivity { + /** {@inheritDoc} */ + @Override + public String gameId() { + return "game-snake-poc"; + } +} diff --git a/Android/game-snake-poc/src/main/res/values/strings.xml b/Android/game-snake-poc/src/main/res/values/strings.xml new file mode 100644 index 0000000..ceea697 --- /dev/null +++ b/Android/game-snake-poc/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + + Snake POC + diff --git a/Android/settings.gradle b/Android/settings.gradle new file mode 100644 index 0000000..9891dcc --- /dev/null +++ b/Android/settings.gradle @@ -0,0 +1,8 @@ +// file: Android/settings.gradle +// version: 1 + +rootProject.name = "games-sasedev-android" + +include(":common") +include(":game-reflex-poc") +include(":game-snake-poc") diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..be626be --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ + + + +# Changelog + +## 0.1.0-0-pre.1 — 2026-09-15 + +- création du workspace Cargo multi-crates ; +- introduction de la génération `engine-v1` ; +- création de deux POC structurels Reflex et Snake ; +- séparation stricte des assets hors crates ; +- création de la structure Android Java commune et spécifique par jeu ; +- adaptation des règles et audits issus de l'expérience KSP ; +- adoption du cycle SemVer `pre -> alpha -> beta -> rc -> stable` avec correctifs `.fix.N` ; +- adoption des livraisons par archives delta. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..06469b3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +# file: Cargo.toml +# version: 2 + +[workspace] +resolver = "3" +members = [ + "crates/engines/engine-v1-common", + "crates/engines/engine-v1-platform-api", + "crates/engines/engine-v1-sdl", + "crates/games/game-reflex-poc", + "crates/games/game-snake-poc", +] + +[workspace.package] +version = "0.1.0-0-pre.1" +edition = "2024" +license = "MIT" +repository = "https://git.sasedev.com/Sasedev/games" +authors = ["Sasedev "] +publish = false + +[workspace.dependencies] + +[workspace.lints.rust] +missing_docs = "warn" +unreachable_pub = "deny" +unsafe_code = "forbid" + +[workspace.lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +implicit_return = "deny" +needless_return = "allow" +useless_vec = "deny" +question_mark = "deny" +question_mark_used = "deny" +needless_match = "allow" +manual_ok_err = "allow" +manual_unwrap_or = "allow" +manual_map = "allow" +match_like_matches_macro = "allow" +single_match = "allow" +manual_unwrap_or_default = "allow" +manual_find = "allow" +explicit_counter_loop = "allow" +get_first = "allow" +implicit_saturating_sub = "allow" diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa042a --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ + + + +# games.sasedev + +Workspace expérimental puis productif pour des jeux multiplateformes principalement développés en Rust, avec une première orientation Android et un socle SDL3. + +## Principes de départ + +- un seul workspace Cargo ; +- toutes les crates Rust sous `crates/` ; +- générations de moteur coexistantes sous `crates/engines/engine-vN-*` ; +- une ou plusieurs crates par jeu sous `crates/games/` ; +- assets hors des crates sous `assets/` ; +- `assets/common/` pour les ressources mutualisées et un répertoire par jeu pour les ressources spécifiques ; +- frontend Android sous `Android/`, en Java, avec une partie commune et une partie spécifique par jeu ; +- documentation sous `docs/` ; +- règles normatives indexées par `RULES.md` ; +- livraison par archives delta versionnées ; +- versionnement SemVer avec labels de maturité normalisés. + +## Baseline + +Version initiale : `0.1.0-0-pre.1`. + +Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-snake-poc`. Ils existent d'abord pour valider les frontières du workspace, le moteur, les assets et le packaging multiplateforme. + +## Entrées documentaires + +- [`RULES.md`](RULES.md) +- [`ROADMAP.md`](ROADMAP.md) +- [`CHANGELOG.md`](CHANGELOG.md) +- [`docs/000-README.md`](docs/000-README.md) +- [`docs/architecture/001-WORKSPACE_ARCHITECTURE.md`](docs/architecture/001-WORKSPACE_ARCHITECTURE.md) diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..019f229 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,19 @@ + + + +# Roadmap + +## 0.1.0 — Fondation POC + +- [x] `0-pre.1` — squelette du workspace, règles, architecture, versions, assets, Java Android commun/spécifique et deux crates POC. +- [ ] `0-pre.2` — première boucle moteur exécutable Desktop : temps, input abstrait, état de jeu minimal. +- [ ] `0-pre.3` — intégration SDL3 Desktop réelle et premier rendu POC Reflex. +- [ ] `0-pre.4` — projet Gradle Android exécutable, SDL3 AAR/NDK, compilation Rust `cdylib`, lancement sur appareil/émulateur. +- [ ] `0-pre.5` — bridge Java/JNI minimal et input tactile Android. +- [ ] `0-pre.6` — assets communs + spécifiques empaquetés sans copie dans les crates. +- [ ] `0-pre.7` — POC Reflex jouable Desktop + Android. +- [ ] `0-pre.8` — POC Snake jouable et validation de la réutilisation du moteur. +- [ ] `1-alpha.1` — première API moteur V1 volontairement stabilisée. +- [ ] `2-beta.1` — stabilisation, packaging, tests multi-appareils. +- [ ] `3-rc.1` — candidat de release du socle 0.1.0. +- [ ] `0.1.0` — première baseline stable du framework POC. diff --git a/RULES.md b/RULES.md new file mode 100644 index 0000000..d12eb26 --- /dev/null +++ b/RULES.md @@ -0,0 +1,23 @@ + + + +# Index normatif games.sasedev + +`RULES.md` est le point d'entrée obligatoire des règles du dépôt. + +Les règles détaillées sont maintenues sous `docs/rules/` et sont cumulatives selon leur périmètre. + +## Documents normatifs + +1. [`docs/rules/RULES_GENERAL.md`](docs/rules/RULES_GENERAL.md) — règles universelles du dépôt et hiérarchie normative ; +2. [`docs/rules/RULES_RUST.md`](docs/rules/RULES_RUST.md) — règles applicables aux crates et sources Rust ; +3. [`docs/rules/RULES_PROJECT.md`](docs/rules/RULES_PROJECT.md) — architecture propre à games.sasedev, moteurs, jeux, assets et plateformes ; +4. [`docs/rules/RULES_DOCUMENTATION.md`](docs/rules/RULES_DOCUMENTATION.md) — règles Markdown et cycle documentaire ; +5. [`docs/rules/FILE_CONTRACTS.md`](docs/rules/FILE_CONTRACTS.md) — responsabilités des principales familles de fichiers ; +6. [`docs/rules/VERSION_WORKFLOW.md`](docs/rules/VERSION_WORKFLOW.md) — SemVer, niveaux de maturité, fixes, deltas et livraisons. + +## Hiérarchie + +Une règle plus spécifique peut renforcer une règle générale mais ne peut pas l'affaiblir silencieusement. Toute exception doit être explicite, locale, bornée, justifiée et documentée dans le delta qui l'introduit. + +Une validation n'est déclarée réussie que si elle a réellement été exécutée. diff --git a/assets/common/README.md b/assets/common/README.md new file mode 100644 index 0000000..0db993a --- /dev/null +++ b/assets/common/README.md @@ -0,0 +1,6 @@ + + + +# Assets common + +Ce répertoire est réservé aux assets runtime communs. Les assets restent hors des crates Rust. diff --git a/assets/game-reflex-poc/README.md b/assets/game-reflex-poc/README.md new file mode 100644 index 0000000..2519244 --- /dev/null +++ b/assets/game-reflex-poc/README.md @@ -0,0 +1,6 @@ + + + +# Assets game-reflex-poc + +Ce répertoire est réservé aux assets runtime game-reflex-poc. Les assets restent hors des crates Rust. diff --git a/assets/game-snake-poc/README.md b/assets/game-snake-poc/README.md new file mode 100644 index 0000000..ec4cd52 --- /dev/null +++ b/assets/game-snake-poc/README.md @@ -0,0 +1,6 @@ + + + +# Assets game-snake-poc + +Ce répertoire est réservé aux assets runtime game-snake-poc. Les assets restent hors des crates Rust. diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..9b450a5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,28 @@ +# file: clippy.toml +# version: 2 + +# The project favors explicit control flow and visible intent. +# These settings complement the coding rules already enforced manually +# in code review: no `?`, no `unwrap`, no `expect`, explicit error paths. + +too-many-arguments-threshold = 16 +type-complexity-threshold = 250 +single-char-binding-names-threshold = 3 +trivial-copy-size-limit = 16 +pass-by-value-size-limit = 256 +stack-size-threshold = 512000 +vec-box-size-threshold = 4096 +max-fn-params-bools = 2 +max-include-file-size = 1048576 +cognitive-complexity-threshold = 25 +too-large-for-stack = 2048 +enum-variant-size-threshold = 200 +large-error-threshold = 128 +avoid-breaking-exported-api = true +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-useless-vec-in-tests = true +disallowed-macros = [] +disallowed-methods = [] +disallowed-names = ["foo", "bar", "baz", "tmp"] +allowed-idents-below-min-chars = ["id", "x", "y", "dt", "ui"] diff --git a/crates/engines/engine-v1-common/Cargo.toml b/crates/engines/engine-v1-common/Cargo.toml new file mode 100644 index 0000000..7386b14 --- /dev/null +++ b/crates/engines/engine-v1-common/Cargo.toml @@ -0,0 +1,14 @@ +# file: crates/engines/engine-v1-common/Cargo.toml +# version: 1 + +[package] +name = "engine-v1-common" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[lints] +workspace = true diff --git a/crates/engines/engine-v1-common/src/action.rs b/crates/engines/engine-v1-common/src/action.rs new file mode 100644 index 0000000..9ab7c18 --- /dev/null +++ b/crates/engines/engine-v1-common/src/action.rs @@ -0,0 +1,21 @@ +// file: crates/engines/engine-v1-common/src/action.rs +// version: 1 + +/// Logical game actions independent from physical input devices. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GameAction { + /// Move or select left. + Left, + /// Move or select right. + Right, + /// Move or select upward. + Up, + /// Move or select downward. + Down, + /// Trigger the primary game action. + Primary, + /// Trigger the secondary game action. + Secondary, + /// Pause or resume the game. + Pause, +} diff --git a/crates/engines/engine-v1-common/src/lib.rs b/crates/engines/engine-v1-common/src/lib.rs new file mode 100644 index 0000000..2cb5665 --- /dev/null +++ b/crates/engines/engine-v1-common/src/lib.rs @@ -0,0 +1,13 @@ +// file: crates/engines/engine-v1-common/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Common, platform-independent primitives for engine generation V1. + +mod action; + +/// Re-export of the canonical logical input action used by engine V1 games. +pub use self::action::GameAction; diff --git a/crates/engines/engine-v1-platform-api/Cargo.toml b/crates/engines/engine-v1-platform-api/Cargo.toml new file mode 100644 index 0000000..8bc464d --- /dev/null +++ b/crates/engines/engine-v1-platform-api/Cargo.toml @@ -0,0 +1,14 @@ +# file: crates/engines/engine-v1-platform-api/Cargo.toml +# version: 1 + +[package] +name = "engine-v1-platform-api" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[lints] +workspace = true diff --git a/crates/engines/engine-v1-platform-api/src/lib.rs b/crates/engines/engine-v1-platform-api/src/lib.rs new file mode 100644 index 0000000..69d6b84 --- /dev/null +++ b/crates/engines/engine-v1-platform-api/src/lib.rs @@ -0,0 +1,13 @@ +// file: crates/engines/engine-v1-platform-api/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Platform service contracts shared by engine generation V1. + +mod service; + +/// Re-export of the minimal platform service capability enumeration. +pub use self::service::PlatformCapability; diff --git a/crates/engines/engine-v1-platform-api/src/service.rs b/crates/engines/engine-v1-platform-api/src/service.rs new file mode 100644 index 0000000..5df3c2c --- /dev/null +++ b/crates/engines/engine-v1-platform-api/src/service.rs @@ -0,0 +1,17 @@ +// file: crates/engines/engine-v1-platform-api/src/service.rs +// version: 1 + +/// Platform capabilities that may have different implementations per target. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlatformCapability { + /// Advertising integration. + Advertising, + /// In-app billing integration. + Billing, + /// Native share integration. + Sharing, + /// Haptic feedback integration. + Haptics, + /// Online leaderboard integration. + Leaderboard, +} diff --git a/crates/engines/engine-v1-sdl/Cargo.toml b/crates/engines/engine-v1-sdl/Cargo.toml new file mode 100644 index 0000000..8356b7e --- /dev/null +++ b/crates/engines/engine-v1-sdl/Cargo.toml @@ -0,0 +1,17 @@ +# file: crates/engines/engine-v1-sdl/Cargo.toml +# version: 1 + +[package] +name = "engine-v1-sdl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +engine-v1-common = { path = "../engine-v1-common" } + +[lints] +workspace = true diff --git a/crates/engines/engine-v1-sdl/src/lib.rs b/crates/engines/engine-v1-sdl/src/lib.rs new file mode 100644 index 0000000..2b41c82 --- /dev/null +++ b/crates/engines/engine-v1-sdl/src/lib.rs @@ -0,0 +1,15 @@ +// file: crates/engines/engine-v1-sdl/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! SDL boundary for engine generation V1. +//! +//! The baseline intentionally contains only the boundary type. The external SDL3 dependency is introduced in the dedicated SDL integration delta. + +mod runtime; + +/// Re-export of the SDL runtime boundary marker. +pub use self::runtime::SdlRuntimeBoundary; diff --git a/crates/engines/engine-v1-sdl/src/runtime.rs b/crates/engines/engine-v1-sdl/src/runtime.rs new file mode 100644 index 0000000..9a50b0f --- /dev/null +++ b/crates/engines/engine-v1-sdl/src/runtime.rs @@ -0,0 +1,14 @@ +// file: crates/engines/engine-v1-sdl/src/runtime.rs +// version: 1 + +/// Marker representing the future SDL3 runtime boundary for engine V1. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SdlRuntimeBoundary; + +impl SdlRuntimeBoundary { + /// Returns the engine generation served by this boundary. + #[must_use] + pub const fn engine_generation() -> u16 { + return 1; + } +} diff --git a/crates/games/game-reflex-poc/Cargo.toml b/crates/games/game-reflex-poc/Cargo.toml new file mode 100644 index 0000000..14bfe09 --- /dev/null +++ b/crates/games/game-reflex-poc/Cargo.toml @@ -0,0 +1,19 @@ +# file: crates/games/game-reflex-poc/Cargo.toml +# version: 1 + +[package] +name = "game-reflex-poc" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +engine-v1-common = { path = "../../engines/engine-v1-common" } +engine-v1-platform-api = { path = "../../engines/engine-v1-platform-api" } +engine-v1-sdl = { path = "../../engines/engine-v1-sdl" } + +[lints] +workspace = true diff --git a/crates/games/game-reflex-poc/src/lib.rs b/crates/games/game-reflex-poc/src/lib.rs new file mode 100644 index 0000000..993ee38 --- /dev/null +++ b/crates/games/game-reflex-poc/src/lib.rs @@ -0,0 +1,13 @@ +// file: crates/games/game-reflex-poc/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Structural POC for a one-button reflex game using engine generation V1. + +mod state; + +/// Re-export of the Reflex POC state. +pub use self::state::ReflexState; diff --git a/crates/games/game-reflex-poc/src/state.rs b/crates/games/game-reflex-poc/src/state.rs new file mode 100644 index 0000000..90b17ea --- /dev/null +++ b/crates/games/game-reflex-poc/src/state.rs @@ -0,0 +1,37 @@ +// file: crates/games/game-reflex-poc/src/state.rs +// version: 1 + +/// Minimal deterministic state used to validate the first game boundary. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ReflexState { + score: u64, +} + +impl ReflexState { + /// Creates a fresh Reflex state. + #[must_use] + pub const fn new() -> Self { + return Self { score: 0 }; + } + + /// Returns the current score. + #[must_use] + pub const fn score(self) -> u64 { + return self.score; + } + + /// Registers one successful reflex action. + pub const fn register_success(&mut self) { + self.score = self.score.saturating_add(1); + } +} + +#[cfg(test)] +mod unit_tests { + #[test] + fn success_increments_score() { + let mut state = crate::ReflexState::new(); + state.register_success(); + assert_eq!(state.score(), 1); + } +} diff --git a/crates/games/game-snake-poc/Cargo.toml b/crates/games/game-snake-poc/Cargo.toml new file mode 100644 index 0000000..f8870c2 --- /dev/null +++ b/crates/games/game-snake-poc/Cargo.toml @@ -0,0 +1,19 @@ +# file: crates/games/game-snake-poc/Cargo.toml +# version: 1 + +[package] +name = "game-snake-poc" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +publish.workspace = true + +[dependencies] +engine-v1-common = { path = "../../engines/engine-v1-common" } +engine-v1-platform-api = { path = "../../engines/engine-v1-platform-api" } +engine-v1-sdl = { path = "../../engines/engine-v1-sdl" } + +[lints] +workspace = true diff --git a/crates/games/game-snake-poc/src/lib.rs b/crates/games/game-snake-poc/src/lib.rs new file mode 100644 index 0000000..a6f8cb0 --- /dev/null +++ b/crates/games/game-snake-poc/src/lib.rs @@ -0,0 +1,13 @@ +// file: crates/games/game-snake-poc/src/lib.rs +// version: 1 + +#![warn(missing_docs)] +#![deny(unreachable_pub)] +#![forbid(unsafe_code)] + +//! Structural POC for a Snake-style game using engine generation V1. + +mod state; + +/// Re-export of the Snake POC state. +pub use self::state::SnakeState; diff --git a/crates/games/game-snake-poc/src/state.rs b/crates/games/game-snake-poc/src/state.rs new file mode 100644 index 0000000..cceb2f0 --- /dev/null +++ b/crates/games/game-snake-poc/src/state.rs @@ -0,0 +1,43 @@ +// file: crates/games/game-snake-poc/src/state.rs +// version: 1 + +/// Minimal Snake state used to validate reusable engine dependencies. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SnakeState { + length: u32, +} + +impl Default for SnakeState { + fn default() -> Self { + return Self::new(); + } +} + +impl SnakeState { + /// Creates a fresh Snake state. + #[must_use] + pub const fn new() -> Self { + return Self { length: 1 }; + } + + /// Returns the current snake length. + #[must_use] + pub const fn length(self) -> u32 { + return self.length; + } + + /// Registers one consumed growth item. + pub const fn grow(&mut self) { + self.length = self.length.saturating_add(1); + } +} + +#[cfg(test)] +mod unit_tests { + #[test] + fn growth_increments_length() { + let mut state = crate::SnakeState::new(); + state.grow(); + assert_eq!(state.length(), 2); + } +} diff --git a/deltas/0.1.0/0-pre.1.md b/deltas/0.1.0/0-pre.1.md new file mode 100644 index 0000000..8f7d703 --- /dev/null +++ b/deltas/0.1.0/0-pre.1.md @@ -0,0 +1,36 @@ + + + +# Delta 0.1.0-0-pre.1 + +## Base + +Aucune. Cette livraison initialise le dépôt ; son archive delta constitue donc la baseline complète. + +## Contenu + +- workspace Cargo et lints communs ; +- première génération de moteur ; +- deux crates POC ; +- architecture d'assets externes aux crates ; +- squelette Android Java multi-module ; +- documentation normative et architecture ; +- scripts d'audit ; +- roadmap initiale. + +## Validation attendue + +Voir `docs/validation/001-VALIDATION_GATES.md`. + +## Validation réalisée dans l’environnement de génération + +Les audits Python suivants ont été exécutés et sont propres : + +```text +General Rust rule audit: clean +Rust export completeness audit: 0 candidate(s) +games.sasedev workspace audit: clean +Markdown table audit: clean +``` + +Les gates `cargo fmt`, `cargo check`, `cargo clippy` et `cargo test` n'ont pas pu être exécutées dans l'environnement de génération car l'exécutable `cargo` n'y est pas installé. Elles restent obligatoires sur le poste de développement avant acceptation de la baseline. diff --git a/docs/000-README.md b/docs/000-README.md new file mode 100644 index 0000000..f74da71 --- /dev/null +++ b/docs/000-README.md @@ -0,0 +1,17 @@ + + + +# Documentation games.sasedev + +## Architecture + +- [`architecture/001-WORKSPACE_ARCHITECTURE.md`](architecture/001-WORKSPACE_ARCHITECTURE.md) — workspace, générations de moteur, jeux, assets et Android. +- [`architecture/002-ANDROID_ARCHITECTURE.md`](architecture/002-ANDROID_ARCHITECTURE.md) — séparation Rust/SDL3/Java/JNI et modules Android. + +## Règles + +Voir [`../RULES.md`](../RULES.md). + +## Validation + +- [`validation/001-VALIDATION_GATES.md`](validation/001-VALIDATION_GATES.md) — gates manuelles de la baseline. diff --git a/docs/architecture/001-WORKSPACE_ARCHITECTURE.md b/docs/architecture/001-WORKSPACE_ARCHITECTURE.md new file mode 100644 index 0000000..6132bbb --- /dev/null +++ b/docs/architecture/001-WORKSPACE_ARCHITECTURE.md @@ -0,0 +1,56 @@ + + + +# Architecture du workspace + +## Vue générale + +```text +games.sasedev/ +├── crates/ +│ ├── engines/ +│ │ ├── engine-v1-common/ +│ │ ├── engine-v1-platform-api/ +│ │ └── engine-v1-sdl/ +│ └── games/ +│ ├── game-reflex-poc/ +│ └── game-snake-poc/ +├── assets/ +│ ├── common/ +│ ├── game-reflex-poc/ +│ └── game-snake-poc/ +├── Android/ +│ ├── common/ +│ ├── game-reflex-poc/ +│ └── game-snake-poc/ +├── docs/ +├── deltas/ +├── prompts/ +└── scripts/ +``` + +## Générations du moteur + +Une génération `engine-vN` est une ligne de compatibilité. Un jeu peut rester sur `engine-v1` pendant qu'un nouveau jeu expérimente `engine-v2`. Les anciens jeux sont ensuite migrés individuellement. + +Les subdivisions sont exprimées par crates afin de ne pas forcer un jeu à dépendre de capacités inutiles. La baseline sépare déjà : + +- `engine-v1-common` : types et comportements génériques indépendants des plateformes ; +- `engine-v1-platform-api` : contrats abstraits des services plateforme ; +- `engine-v1-sdl` : frontière d'intégration SDL3, volontairement minimale dans la baseline. + +## Jeux + +Chaque jeu est une crate indépendante sous `crates/games/`. Un jeu ne duplique pas une crate moteur. Il sélectionne explicitement la génération qu'il consomme. + +## Assets + +Les assets sont extérieurs aux crates. Le packaging compose : + +```text +assets/common/ ++ +assets// +``` + +Le runtime devra conserver une distinction logique entre ressources communes et ressources spécifiques afin d'éviter les collisions silencieuses. diff --git a/docs/architecture/002-ANDROID_ARCHITECTURE.md b/docs/architecture/002-ANDROID_ARCHITECTURE.md new file mode 100644 index 0000000..8e20a00 --- /dev/null +++ b/docs/architecture/002-ANDROID_ARCHITECTURE.md @@ -0,0 +1,42 @@ + + + +# Architecture Android + +## Principe + +Android est un frontend de plateforme autour du jeu natif Rust/SDL3. Java est retenu comme langage de glue par défaut. + +```text +Android application +├── Java common layer +│ ├── SaseGameActivity +│ ├── NativeBridge +│ └── futurs Ads/Billing/Haptics managers +├── Java game-specific layer +├── SDL3 Android AAR +├── native Rust game library +├── common assets +└── game-specific assets +``` + +SDL3 documente un shim Java/JNI et recommande de dériver sa propre activité de `SDLActivity`. L'utilisation de l'AAR SDL3 doit être privilégiée lorsque l'intégration concrète démarre afin d'éviter de recopier les sources SDL dans chaque jeu. + +## Modules Gradle + +`Android/common` est destiné à devenir une Android Library réutilisable. Les modules `Android/game-*` deviennent des applications distinctes produisant chacune leur APK/AAB. + +Le squelette initial ne fige volontairement pas la version d'Android Gradle Plugin ni celle de SDL3 : ces dépendances seront introduites dans le premier delta Android réellement exécutable, avec versions vérifiées au moment de l'intégration. + +## JNI + +Le contrat JNI doit rester petit. Les services envisagés sont notamment : + +- affichage d'une publicité récompensée ; +- affichage d'un interstitiel ; +- achats intégrés ; +- partage Android ; +- haptique ; +- événements lifecycle utiles au moteur. + +Une rupture interne de `engine-v1` vers `engine-v2` ne doit pas imposer une nouvelle couche Java si ce contrat reste compatible. diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md new file mode 100644 index 0000000..90d8c56 --- /dev/null +++ b/docs/rules/FILE_CONTRACTS.md @@ -0,0 +1,17 @@ + + + +# Contrats des fichiers principaux + +- `README.md` présente le dépôt et ses entrées principales. +- `RULES.md` indexe les règles normatives. +- `ROADMAP.md` suit les objectifs futurs et leur état. +- `CHANGELOG.md` conserve l'historique synthétique inversement chronologique. +- `docs/000-README.md` indexe la documentation détaillée. +- `docs/rules/` contient les règles durables. +- `docs/architecture/` contient les décisions et descriptions d'architecture. +- `deltas/` contient un document par livraison ou correctif versionné. +- `prompts/` peut contenir les prompts de reprise de session lorsqu'ils deviennent utiles. +- `scripts/` contient des audits en lecture seule et des outils du dépôt. +- `assets/` contient les ressources runtime communes et spécifiques aux jeux ; aucune ressource runtime n'est placée dans une crate Rust. +- `Android/` contient le projet Gradle multi-module et son code Java commun/spécifique. diff --git a/docs/rules/RULES_DOCUMENTATION.md b/docs/rules/RULES_DOCUMENTATION.md new file mode 100644 index 0000000..4f5b459 --- /dev/null +++ b/docs/rules/RULES_DOCUMENTATION.md @@ -0,0 +1,14 @@ + + + +# Règles de documentation + +- **DOC-001** — La documentation structurée réside sous `docs/`. +- **DOC-002** — `docs/000-README.md` est l'entrée de navigation documentaire. +- **DOC-003** — Les documents normatifs résident sous `docs/rules/`. +- **DOC-004** — Les documents d'architecture résident sous `docs/architecture/`. +- **DOC-005** — Les documents de validation résident sous `docs/validation/`. +- **DOC-006** — Les documents internes sont en français ; code, symboles et extraits techniques conservent leur langue naturelle. +- **DOC-007** — Les tableaux Markdown suivent le format contrôlé par `scripts/audit_markdown_tables.py`. +- **DOC-008** — Deux lignes blanches consécutives sont interdites hors blocs de code. +- **DOC-009** — Un delta décrit les changements de sa version et ne devient pas un substitut au `CHANGELOG.md` ou aux règles durables. diff --git a/docs/rules/RULES_GENERAL.md b/docs/rules/RULES_GENERAL.md new file mode 100644 index 0000000..6fab11e --- /dev/null +++ b/docs/rules/RULES_GENERAL.md @@ -0,0 +1,37 @@ + + + +# Règles générales du projet + +## Portée + +Les règles `GEN-*` s'appliquent à l'ensemble du dépôt. + +## Hiérarchie normative + +- **GEN-RULE-001** — `RULES.md` est l'index normatif racine et ne duplique pas les règles détaillées. +- **GEN-RULE-002** — Les règles sont cumulatives. +- **GEN-RULE-003** — Toute exception est locale, bornée, justifiée et traçable. +- **GEN-RULE-004** — Une décision non validée reste une question ouverte et n'est pas transformée en règle par supposition. +- **GEN-RULE-005** — Une validation n'est déclarée réussie que si elle a réellement été exécutée. + +## Noms et fichiers texte + +- **GEN-FILE-001** — Les noms de fichiers et répertoires sont en anglais, sans accent ni espace, sauf contrainte externe. +- **GEN-FILE-002** — Tout fichier texte qui supporte les commentaires commence par son chemin relatif puis par une version entière de fichier. +- **GEN-FILE-003** — Pour un script avec shebang, le shebang reste en première ligne et les métadonnées suivent immédiatement. +- **GEN-FILE-004** — La version interne d'un fichier augmente à chaque enregistrement modifiant son contenu et ne diminue jamais. +- **GEN-FILE-005** — Les fichiers texte se terminent par exactement une fin de ligne lorsque leur format le permet. + +## Langues + +- **GEN-LANG-001** — Code, identifiants, commentaires de code et rustdocs sont en anglais. +- **GEN-LANG-002** — La documentation Markdown interne est rédigée en français, sauf nécessité externe explicite. + +## Travail et validation + +- **GEN-WORK-001** — Une tranche de travail traite un périmètre cohérent et validable. +- **GEN-WORK-002** — Une tranche planifiée comme trop volumineuse doit être découpée avant exécution. +- **GEN-WORK-003** — Une erreur introduite par une tranche est corrigée dans cette tranche ou explicitement reportée. +- **GEN-WORK-004** — Une erreur métier ou architecturale n'est pas masquée par une exception globale de lint ou de test. +- **GEN-WORK-005** — Les audits du dépôt sont en lecture seule sur les fichiers contrôlés. diff --git a/docs/rules/RULES_PROJECT.md b/docs/rules/RULES_PROJECT.md new file mode 100644 index 0000000..fb0f5c0 --- /dev/null +++ b/docs/rules/RULES_PROJECT.md @@ -0,0 +1,51 @@ + + + +# Règles spécifiques games.sasedev + +## Workspace et crates + +- **GAME-WS-001** — Un seul workspace Cargo racine contient les crates Rust du dépôt. +- **GAME-WS-002** — Toutes les crates Rust résident sous `crates/`. +- **GAME-WS-003** — Les crates moteur résident sous `crates/engines/` et les crates jeu sous `crates/games/`. +- **GAME-WS-004** — Une crate hérite par défaut de `workspace.package.version`. +- **GAME-WS-005** — Une crate arrivée à maturité peut porter sa propre version SemVer lorsqu'une décision documentée rend son cycle autonome nécessaire. +- **GAME-WS-006** — Les dépendances tierces communes sont centralisées sous `[workspace.dependencies]` et consommées avec `workspace = true` lorsqu'elles sont partagées. + +## Générations du moteur + +- **GAME-ENGINE-001** — Une génération incompatible de moteur reçoit un nom explicite `engine-vN-*`. +- **GAME-ENGINE-002** — `engine-v2` n'est pas créé pour une évolution mineure ; il représente une rupture d'API ou d'architecture suffisamment forte pour justifier une coexistence avec `engine-v1`. +- **GAME-ENGINE-003** — Plusieurs générations de moteur peuvent coexister afin de migrer les jeux progressivement. +- **GAME-ENGINE-004** — Un jeu déclare explicitement la génération de moteur qu'il consomme. +- **GAME-ENGINE-005** — Une génération de moteur n'est supprimée qu'après migration, retrait ou archivage de tous ses consommateurs actifs. + +## Assets + +- **GAME-ASSET-001** — Aucun asset de jeu n'est stocké dans une crate Rust. +- **GAME-ASSET-002** — Les assets sont stockés sous `assets/`. +- **GAME-ASSET-003** — `assets/common/` contient uniquement les ressources réellement mutualisées. +- **GAME-ASSET-004** — Chaque jeu peut posséder `assets//` pour ses ressources spécifiques. +- **GAME-ASSET-005** — Le packaging de chaque plateforme assemble les assets communs et spécifiques sans créer de copie source durable dans une crate. +- **GAME-ASSET-006** — Les chemins logiques d'assets doivent éviter les collisions entre espace commun et espace jeu. + +## Android + +- **GAME-ANDROID-001** — L'intégration Android réside sous `Android/` et reste extérieure au workspace Rust. +- **GAME-ANDROID-002** — Java est la langue Android commune par défaut ; Kotlin n'est pas requis. +- **GAME-ANDROID-003** — `Android/common/` contient la couche réutilisable : activité SDL dérivée, bridge natif, publicité, billing, haptique et services génériques selon les besoins. +- **GAME-ANDROID-004** — `Android//` contient uniquement la configuration et les extensions spécifiques au jeu : package, manifeste, ressources Android, identifiants et Java spécifique. +- **GAME-ANDROID-005** — Le code Java commun ne dépend pas d'une génération particulière du moteur Rust lorsque le contrat plateforme peut rester stable. +- **GAME-ANDROID-006** — Le contrat JNI est volontairement petit, stable et orienté services plateforme. +- **GAME-ANDROID-007** — SDL3 peut être consommé via son AAR officiel afin d'éviter de dupliquer ses sources Java/C dans chaque application. + +## Plateformes + +- **GAME-PLATFORM-001** — Le gameplay ne dépend pas directement d'Android, Desktop ou Web. +- **GAME-PLATFORM-002** — Les entrées physiques sont traduites en actions de jeu abstraites. +- **GAME-PLATFORM-003** — Les services Ads, Billing, Share, Haptics, Leaderboard et stockage en ligne sont consommés derrière des contrats plateforme. + +## POC + +- **GAME-POC-001** — Les premiers POC servent à valider l'architecture et doivent rester volontairement petits. +- **GAME-POC-002** — Un POC ne justifie pas l'introduction prématurée d'un ECS, moteur physique ou backend complet s'il n'en a pas besoin. diff --git a/docs/rules/RULES_RUST.md b/docs/rules/RULES_RUST.md new file mode 100644 index 0000000..1b26fd9 --- /dev/null +++ b/docs/rules/RULES_RUST.md @@ -0,0 +1,45 @@ + + + +# Règles Rust générales + +## Base + +- **RUST-BASE-001** — L'édition Rust est Rust 2024. +- **RUST-BASE-002** — Chaque `lib.rs` et `main.rs` active `missing_docs`, `unreachable_pub` et interdit `unsafe_code`. +- **RUST-BASE-003** — Les lints communs sont déclarés au workspace et hérités par les crates. +- **RUST-BASE-004** — Le code `unsafe` est interdit sauf future exception normative extrêmement ciblée et justifiée. +- **RUST-BASE-005** — Tout fichier Rust possède les en-têtes `// file: ...` et `// version: N`. + +## Documentation et API + +- **RUST-DOC-001** — Tout élément `pub` ou `pub(crate)` possède une rustdoc utile au point de déclaration. +- **RUST-DOC-002** — Toute réexportation visible depuis le crate-root possède une rustdoc adjacente. +- **RUST-DOC-003** — La façade d'une crate doit permettre de consommer son API sans dépendre des chemins internes de modules. + +## Imports et façade + +- **RUST-IMPORT-001** — Les éléments partagés appartenant à la crate sont consommés via `crate::Item` après réexport au crate-root. +- **RUST-IMPORT-002** — Les autres crates consomment l'API via `owner_crate::Item` et non via des modules internes. +- **RUST-IMPORT-003** — Aucun `pub mod` n'est utilisé pour exposer indirectement une arborescence interne. +- **RUST-IMPORT-004** — Les glob imports sont interdits. +- **RUST-IMPORT-005** — Les alias de réexport destinés à masquer des collisions de noms sont interdits ; les symboles reçoivent un nom canonique non ambigu. + +## Contrôle de flux et erreurs + +- **RUST-FLOW-001** — Les retours sont explicites ; le lint Clippy `implicit_return` est refusé. +- **RUST-FLOW-002** — `unwrap()` et `expect()` sont interdits dans le code de production. +- **RUST-FLOW-003** — L'opérateur `?` est interdit ; les chemins d'erreur restent explicites. +- **RUST-FLOW-004** — `panic!` n'est pas utilisé pour une erreur métier récupérable. + +## Formatage + +- **RUST-FMT-001** — `rustfmt.toml` racine est canonique. +- **RUST-FMT-002** — `cargo fmt --all -- --check` fait partie des gates. +- **RUST-FMT-003** — La largeur maximale canonique est 160 caractères. + +## Tests + +- **RUST-TEST-001** — Les tests unitaires restent proches de la crate ou du module testé. +- **RUST-TEST-002** — Les tests d'intégration résident sous `tests/` de la crate concernée. +- **RUST-TEST-003** — Les tests ne rendent pas artificiellement publique une API privée. diff --git a/docs/rules/VERSION_WORKFLOW.md b/docs/rules/VERSION_WORKFLOW.md new file mode 100644 index 0000000..966da11 --- /dev/null +++ b/docs/rules/VERSION_WORKFLOW.md @@ -0,0 +1,75 @@ + + + +# Versionnement, maturité et livraisons + +## SemVer canonique + +Le projet utilise SemVer et les labels de maturité normalisés suivants : + +```text +X.Y.Z-0-pre.N +X.Y.Z-0-pre.N.fix.M +X.Y.Z-1-alpha.N +X.Y.Z-1-alpha.N.fix.M +X.Y.Z-2-beta.N +X.Y.Z-2-beta.N.fix.M +X.Y.Z-3-rc.N +X.Y.Z-3-rc.N.fix.M +X.Y.Z +``` + +`N` et `M` sont des entiers positifs sans zéro initial. + +## Sens des niveaux + +- `0-pre.N` : construction initiale, architecture et fonctionnalités encore très mouvantes ; +- `1-alpha.N` : périmètre fonctionnel principal établi mais encore incomplet ou instable ; +- `2-beta.N` : fonctionnalités attendues largement présentes, priorité à la stabilisation et aux tests ; +- `3-rc.N` : candidat de publication, aucune évolution non indispensable ; +- `X.Y.Z` : version stable. + +## Correctifs + +Un suffixe `.fix.M` corrige la prerelease immédiatement précédente sans changer son objectif fonctionnel. Exemple : + +```text +0.1.0-0-pre.4 +0.1.0-0-pre.4.fix.1 +0.1.0-0-pre.4.fix.2 +0.1.0-0-pre.5 +``` + +Après une version stable, un correctif produit normalement un nouveau patch SemVer, par exemple `0.1.1`, et non `0.1.0.fix.1`. + +## Version workspace et versions autonomes + +Les crates héritent par défaut de `workspace.package.version`. Une crate mature peut adopter sa propre version lorsque son contrat, sa compatibilité ou sa distribution justifie un cycle autonome. Cette décision est documentée dans le delta qui l'introduit. + +## Livraisons + +La livraison normale est une archive delta. Le nom canonique est : + +```text +games-sasedev--delta.zip +``` + +Le delta contient uniquement les fichiers ajoutés ou modifiés relativement à la base déclarée, plus le document de delta correspondant. La toute première livraison constitue nécessairement une baseline et son delta contient l'ensemble des fichiers initiaux. + +## Deltas + +Les documents sont rangés sous : + +```text +deltas/X.Y.Z/.md +``` + +Exemples : + +```text +deltas/0.1.0/0-pre.1.md +deltas/0.1.0/0-pre.1.fix.1.md +deltas/0.1.0/1-alpha.1.md +deltas/0.1.0/3-rc.2.md +deltas/0.1.0/rel.md +``` diff --git a/docs/validation/001-VALIDATION_GATES.md b/docs/validation/001-VALIDATION_GATES.md new file mode 100644 index 0000000..9e62972 --- /dev/null +++ b/docs/validation/001-VALIDATION_GATES.md @@ -0,0 +1,17 @@ + + + +# Gates de validation + +Baseline Rust et documentation : + +```bash +cargo fmt --all -- --check +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas prompts +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace +``` + +Le build Android n'est pas une gate de `0.1.0-0-pre.1` : le projet Gradle exécutable et l'intégration SDL3/NDK sont planifiés pour une prerelease dédiée. diff --git a/prompts/000-V0_1_0_START_PROMPT.md b/prompts/000-V0_1_0_START_PROMPT.md new file mode 100644 index 0000000..dcbf27a --- /dev/null +++ b/prompts/000-V0_1_0_START_PROMPT.md @@ -0,0 +1,6 @@ + + + +# Prompt de reprise 0.1.0 + +Partir de la dernière livraison `0.1.0-*`, lire `RULES.md`, `ROADMAP.md`, `CHANGELOG.md`, le dernier delta et les documents d'architecture concernés. Vérifier l'état réel du workspace avant toute modification. Respecter le format de livraison delta et le cycle SemVer défini dans `docs/rules/VERSION_WORKFLOW.md`. diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..ecbd44e --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,23 @@ +# file: rustfmt.toml +# version: 1 + +edition = "2024" +newline_style = "Unix" +use_small_heuristics = "Default" +hard_tabs = false +tab_spaces = 4 +max_width = 160 +chain_width = 140 +fn_call_width = 140 +attr_fn_like_width = 140 +struct_lit_width = 100 +struct_variant_width = 100 +array_width = 140 +single_line_if_else_max_width = 120 +single_line_let_else_max_width = 120 +reorder_imports = true +reorder_modules = true +match_block_trailing_comma = true +use_field_init_shorthand = true +use_try_shorthand = false +force_explicit_abi = true diff --git a/scripts/audit_markdown_tables.py b/scripts/audit_markdown_tables.py new file mode 100755 index 0000000..760a541 --- /dev/null +++ b/scripts/audit_markdown_tables.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +# file: scripts/audit_markdown_tables.py +# version: 1 + +"""Validate games.sasedev Markdown tables and vertical spacing for explicitly supplied files or directories.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +_SEPARATOR_CELL = re.compile(r"^:?-{3,}:?$") +_IGNORED_DIRECTORY_NAMES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target"}) + + +def _markdown_files(paths: list[str]) -> list[pathlib.Path]: + files: list[pathlib.Path] = [] + for raw_path in paths: + path = pathlib.Path(raw_path) + if path.is_dir(): + files.extend( + sorted( + candidate + for candidate in path.rglob("*.md") + if candidate.is_file() and not any(part in _IGNORED_DIRECTORY_NAMES for part in candidate.parts) + ) + ) + elif path.is_file() and path.suffix.lower() == ".md": + files.append(path) + else: + print(f"Markdown table audit: unsupported or missing path: {path}", file=sys.stderr) + return sorted(set(files)) + + +def _is_table_row(line: str) -> bool: + return line.startswith("|") and line.endswith("|") + + +def _cells(line: str) -> list[str]: + return line.split("|")[1:-1] + + +def _is_separator_row(line: str) -> bool: + if not _is_table_row(line): + return False + cells = _cells(line) + return bool(cells) and all(_separator_marker(cell) is not None for cell in cells) + + +def _separator_marker(cell: str) -> str | None: + marker = cell.strip() + if _SEPARATOR_CELL.fullmatch(marker) is None: + return None + return marker + + +def _separator_alignment(marker: str) -> str: + if marker.startswith(":") and marker.endswith(":"): + return "center" + if marker.endswith(":"): + return "right" + return "left" + + +def _space_padding(cell: str) -> tuple[int, int]: + left = len(cell) - len(cell.lstrip(" ")) + right = len(cell) - len(cell.rstrip(" ")) + return left, right + + +def _validate_table(path: pathlib.Path, start_line: int, rows: list[str]) -> list[str]: + errors: list[str] = [] + if any("\\|" in row for row in rows): + errors.append(f"{path}:{start_line}: escaped pipe is forbidden inside Markdown table cells") + + header_cells = _cells(rows[0]) + column_count = len(header_cells) + parsed_rows = [_cells(row) for row in rows] + for offset, row_cells in enumerate(parsed_rows): + if len(row_cells) != column_count: + errors.append( + f"{path}:{start_line + offset}: table row has {len(row_cells)} columns; expected {column_count}; " + "a literal pipe inside a cell is forbidden" + ) + return errors + + for column_index in range(column_count): + raw_cells = [row[column_index] for row in parsed_rows] + widths = [len(cell) for cell in raw_cells] + expected_width = widths[0] + if any(width != expected_width for width in widths): + errors.append( + f"{path}:{start_line}: column {column_index + 1} is not vertically aligned; raw widths are {widths}" + ) + continue + + content_cells = [cell for row_index, cell in enumerate(raw_cells) if row_index != 1] + if not content_cells: + errors.append(f"{path}:{start_line}: column {column_index + 1} has no header/data content") + continue + max_content_width = max(len(cell.strip()) for cell in content_cells) + marker = _separator_marker(raw_cells[1]) + if marker is None: + errors.append(f"{path}:{start_line + 1}: separator for column {column_index + 1} is malformed") + continue + minimum_separator_width = 3 + marker.count(":") + required_width = max(max_content_width + 2, minimum_separator_width) + if expected_width != required_width: + errors.append( + f"{path}:{start_line}: column {column_index + 1} width is {expected_width}; expected {required_width} " + "(longest content plus outer padding, or the minimum Markdown separator width)" + ) + + alignment = _separator_alignment(marker) + for cell in content_cells: + content_width = len(cell.strip()) + left_padding, right_padding = _space_padding(cell) + if content_width + left_padding + right_padding != len(cell) or left_padding < 1 or right_padding < 1: + errors.append( + f"{path}:{start_line}: column {column_index + 1} content cells must use spaces only for outer alignment padding" + ) + break + expected_padding = expected_width - content_width + if alignment == "left" and (left_padding != 1 or right_padding != expected_padding - 1): + errors.append( + f"{path}:{start_line}: column {column_index + 1} is left-aligned; content must use one leading space and right padding only" + ) + break + if alignment == "right" and (right_padding != 1 or left_padding != expected_padding - 1): + errors.append( + f"{path}:{start_line}: column {column_index + 1} is right-aligned; content must use left padding and one trailing space" + ) + break + if alignment == "center" and abs(left_padding - right_padding) > 1: + errors.append( + f"{path}:{start_line}: column {column_index + 1} is centered; left and right padding may differ by at most one space" + ) + break + + separator = raw_cells[1] + if len(separator) != expected_width or _SEPARATOR_CELL.fullmatch(separator) is None: + errors.append( + f"{path}:{start_line + 1}: separator for column {column_index + 1} must fill the exact column width with hyphens and optional alignment colons" + ) + + return errors + + +def _validate_blank_lines(path: pathlib.Path, lines: list[str]) -> list[str]: + errors: list[str] = [] + fence_marker: str | None = None + blank_run_start: int | None = None + blank_run_length = 0 + + def flush_blank_run() -> None: + nonlocal blank_run_start, blank_run_length + if blank_run_start is not None and blank_run_length >= 2: + errors.append( + f"{path}:{blank_run_start}: {blank_run_length} consecutive blank lines are forbidden outside fenced code blocks; keep at most one" + ) + blank_run_start = None + blank_run_length = 0 + + for line_number, line in enumerate(lines, start=1): + stripped = line.lstrip() + if stripped.startswith("```") or stripped.startswith("~~~"): + flush_blank_run() + marker = stripped[:3] + if fence_marker is None: + fence_marker = marker + elif marker == fence_marker: + fence_marker = None + continue + if fence_marker is not None: + continue + if line.strip() == "": + if blank_run_start is None: + blank_run_start = line_number + blank_run_length += 1 + continue + flush_blank_run() + + flush_blank_run() + return errors + + +def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]: + lines = path.read_text(encoding="utf-8").splitlines() + errors = _validate_blank_lines(path, lines) + table_count = 0 + fence_marker: str | None = None + index = 0 + while index < len(lines): + stripped = lines[index].lstrip() + if stripped.startswith("```") or stripped.startswith("~~~"): + marker = stripped[:3] + if fence_marker is None: + fence_marker = marker + elif marker == fence_marker: + fence_marker = None + index += 1 + continue + if fence_marker is not None: + index += 1 + continue + if index + 1 < len(lines) and _is_table_row(lines[index]) and _is_separator_row(lines[index + 1]): + start = index + rows = [lines[index], lines[index + 1]] + index += 2 + while index < len(lines) and _is_table_row(lines[index]): + rows.append(lines[index]) + index += 1 + table_count += 1 + errors.extend(_validate_table(path, start + 1, rows)) + continue + index += 1 + return table_count, errors + + +def main() -> int: + """Audit games.sasedev Markdown formatting in the explicitly selected scope.""" + + parser = argparse.ArgumentParser() + parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit") + arguments = parser.parse_args() + files = _markdown_files(arguments.paths) + if not files: + print("Markdown table audit: no Markdown files selected", file=sys.stderr) + return 2 + + total_tables = 0 + all_errors: list[str] = [] + for path in files: + table_count, errors = _audit_file(path) + total_tables += table_count + all_errors.extend(errors) + + if all_errors: + for error in all_errors: + print(error, file=sys.stderr) + print(f"Markdown table audit: {len(all_errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + + print(f"Markdown table audit: clean ({total_tables} table(s), {len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_project_workspace_rules.py b/scripts/audit_project_workspace_rules.py new file mode 100755 index 0000000..bef4628 --- /dev/null +++ b/scripts/audit_project_workspace_rules.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# file: scripts/audit_project_workspace_rules.py +# version: 2 + +"""Audit mechanically verifiable games.sasedev workspace boundaries.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys +import tomllib + +SEMVER = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0-pre|1-alpha|2-beta|3-rc)\.[1-9][0-9]*(?:\.fix\.[1-9][0-9]*)?)?$", re.ASCII) + + +def main() -> int: + """Run project-specific workspace audits.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".", help="workspace root") + arguments = parser.parse_args() + root = pathlib.Path(arguments.root).resolve() + errors: list[str] = [] + manifest = tomllib.loads((root / "Cargo.toml").read_text(encoding="utf-8")) + workspace_version = manifest.get("workspace", {}).get("package", {}).get("version") + if not isinstance(workspace_version, str) or SEMVER.fullmatch(workspace_version) is None: + errors.append("VERSION-001: workspace.package.version does not follow the canonical games.sasedev SemVer scheme") + members = manifest.get("workspace", {}).get("members", []) + for member in members: + if not isinstance(member, str) or not member.startswith("crates/"): + errors.append(f"GAME-WS-002: workspace member must be under crates/: {member!r}") + for manifest_path in sorted((root / "crates").rglob("Cargo.toml")): + relative = manifest_path.relative_to(root).as_posix() + data = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + package = data.get("package", {}) + version = package.get("version") + if isinstance(version, dict): + if version.get("workspace") is not True: + errors.append(f"GAME-WS-004: {relative}: table version must use workspace = true") + elif isinstance(version, str): + if SEMVER.fullmatch(version) is None: + errors.append(f"GAME-WS-005: {relative}: explicit crate version does not follow the canonical games.sasedev SemVer scheme") + else: + errors.append(f"GAME-WS-004: {relative}: crate must inherit or explicitly own a version") + forbidden_assets = [path for path in (root / "crates").rglob("assets") if path.is_dir()] + for path in forbidden_assets: + errors.append(f"GAME-ASSET-001: assets directory forbidden inside crates: {path.relative_to(root).as_posix()}") + for java_path in sorted(root.rglob("*.java")): + if not java_path.is_relative_to(root / "Android"): + errors.append(f"GAME-ANDROID-001: Java source outside Android/: {java_path.relative_to(root).as_posix()}") + if errors: + for error in errors: + print(error, file=sys.stderr) + print(f"games.sasedev workspace audit: {len(errors)} violation(s)", file=sys.stderr) + return 1 + print("games.sasedev workspace audit: clean") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_export_completeness.py b/scripts/audit_rust_export_completeness.py new file mode 100755 index 0000000..b127dd1 --- /dev/null +++ b/scripts/audit_rust_export_completeness.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_export_completeness.py +# version: 1 + +"""Audit crate-root export completeness and canonical same-crate paths.""" + +from __future__ import annotations + +import argparse +import dataclasses +import pathlib +import re +import sys + +from audit_rust_general_rules import line_depths, mask_rust_source + + +@dataclasses.dataclass(frozen=True) +class Candidate: + """One export/path normalization candidate.""" + + code: str + path: str + line: int + message: str + + +@dataclasses.dataclass(frozen=True) +class Declaration: + """One module-level public or crate-public declaration.""" + + module: str + name: str + visibility: str + kind: str + path: pathlib.Path + line: int + + +def crate_roots(root: pathlib.Path) -> list[pathlib.Path]: + """Return Rust workspace crate directories under crates/.""" + + crates: list[pathlib.Path] = [] + for manifest in (root / "crates").glob("*/Cargo.toml"): + crate = manifest.parent + if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file(): + crates.append(crate) + return sorted(crates) + + +def module_path(crate: pathlib.Path, path: pathlib.Path) -> str: + """Return the Rust module path represented by a source file.""" + + relative = path.relative_to(crate / "src").with_suffix("") + return "::".join(relative.parts) + + +def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]: + """Return module-level public declarations from one source module.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + depths = line_depths(mask_rust_source(text)) + found: list[Declaration] = [] + pattern = re.compile( + r"^\s*(pub(?:\(crate\))?)\s+(?:(?:async|unsafe|const)\s+)*(const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)" + ) + for idx, line in enumerate(lines, 1): + if depths[idx - 1] != 0: + continue + match = pattern.match(line) + if match is not None: + found.append(Declaration(module_path(crate, path), match.group(3), match.group(1), match.group(2), path, idx)) + return found + + +def private_declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]: + """Return module-level strictly private declarations from one source module.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + depths = line_depths(mask_rust_source(text)) + found: list[Declaration] = [] + pattern = re.compile(r"^\s*(?:(?:async|unsafe|const)\s+)*(const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)") + for idx, line in enumerate(lines, 1): + if depths[idx - 1] != 0: + continue + if line.lstrip().startswith(("pub ", "pub(crate) ")): + continue + match = pattern.match(line) + if match is not None: + found.append(Declaration(module_path(crate, path), match.group(2), "private", match.group(1), path, idx)) + return found + + +def unqualified_test_reference_pattern(declaration: Declaration) -> re.Pattern[str]: + """Return a conservative pattern for one unqualified parent-item reference in a separated unit test.""" + + name = re.escape(declaration.name) + if declaration.kind == "fn": + return re.compile(rf"(? dict[tuple[str, str], str]: + """Return explicit crate-root re-exports keyed by source module and symbol.""" + + crate_root = crate / "src/lib.rs" + if not crate_root.is_file(): + crate_root = crate / "src/main.rs" + exports: dict[tuple[str, str], str] = {} + if not crate_root.is_file(): + return exports + pattern = re.compile( + r"^pub(?:\(crate\))?\s+use\s+self::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)\s*;", + re.MULTILINE, + ) + for match in pattern.finditer(crate_root.read_text(encoding="utf-8")): + exports[(match.group(1), match.group(2))] = match.group(2) + return exports + + +def unit_test_parents(crate: pathlib.Path) -> dict[pathlib.Path, pathlib.Path]: + """Map separated unit-test files to the production module that owns them.""" + + mapping: dict[pathlib.Path, pathlib.Path] = {} + pattern = re.compile(r'#\[path\s*=\s*"\.\./unit_tests/([^\"]+)"\]') + for source in sorted((crate / "src").rglob("*.rs")): + for match in pattern.finditer(source.read_text(encoding="utf-8")): + test = crate / "unit_tests" / match.group(1) + if test.is_file(): + mapping[test.resolve()] = source.resolve() + return mapping + + +def external_usage(crate: pathlib.Path, declaration: Declaration, test_parents: dict[pathlib.Path, pathlib.Path]) -> bool: + """Return whether a crate-public item is referenced outside its declaration module.""" + + direct = f"crate::{declaration.module}::{declaration.name}" + root_direct = f"crate::{declaration.name}" + super_ref = f"super::{declaration.name}" + for path in sorted(crate.rglob("*.rs")): + if "target" in path.parts: + continue + resolved = path.resolve() + if resolved == declaration.path.resolve(): + continue + text = path.read_text(encoding="utf-8") + if direct in text or root_direct in text: + return True + if test_parents.get(resolved) == declaration.path.resolve() and super_ref in text: + return True + return False + + + + +def crate_root_symbols(crate: pathlib.Path) -> set[str]: + """Return names that can resolve directly after `crate::`.""" + + crate_root = crate / "src/lib.rs" + if not crate_root.is_file(): + crate_root = crate / "src/main.rs" + symbols: set[str] = set() + if crate_root.is_file(): + text = crate_root.read_text(encoding="utf-8") + for match in re.finditer(r"^\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", text, re.MULTILINE): + symbols.add(match.group(1)) + for match in re.finditer(r"^\s*(?:(?:pub|pub\(crate\))\s+)?(?:(?:async|const|unsafe)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)", text, re.MULTILINE): + symbols.add(match.group(1)) + for match in re.finditer(r"^\s*pub(?:\(crate\))?\s+use\s+[^;]*::([A-Za-z_][A-Za-z0-9_]*)\s*;", text, re.MULTILINE): + symbols.add(match.group(1)) + for match in re.finditer(r"^\s*pub\s+extern\s+crate\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", text, re.MULTILINE): + symbols.add(match.group(1)) + for path in sorted((crate / "src").rglob("*.rs")): + text = path.read_text(encoding="utf-8") + pattern = re.compile(r"#\[macro_export\]\s*\n\s*macro_rules!\s+([A-Za-z_][A-Za-z0-9_]*)") + for match in pattern.finditer(text): + symbols.add(match.group(1)) + return symbols + + +def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]: + """Audit one crate for export completeness and canonical paths.""" + + crate_root = crate / "src/lib.rs" + if not crate_root.is_file(): + crate_root = crate / "src/main.rs" + exports = root_exports(crate) + tests = unit_test_parents(crate) + declarations: dict[tuple[str, str], Declaration] = {} + private_declarations_by_source: dict[pathlib.Path, dict[str, Declaration]] = {} + candidates: list[Candidate] = [] + for path in sorted((crate / "src").rglob("*.rs")): + if path == crate_root: + continue + private_declarations_by_source[path.resolve()] = {item.name: item for item in private_declaration_candidates(crate, path)} + for declaration in declaration_candidates(crate, path): + key = (declaration.module, declaration.name) + declarations[key] = declaration + required = declaration.visibility == "pub" or external_usage(crate, declaration, tests) + if required and key not in exports: + relative = path.relative_to(workspace).as_posix() + reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module" + candidates.append(Candidate("RUST-API-201", relative, declaration.line, f"{reason} `{declaration.name}` requires a crate-root re-export")) + # Canonical crate::Item paths for exported items. + long_path = re.compile(r"\bcrate::([A-Za-z_][A-Za-z0-9_:]*)::([A-Za-z_][A-Za-z0-9_]*)") + for path in sorted(crate.rglob("*.rs")): + if "target" in path.parts or path == crate_root: + continue + relative = path.relative_to(workspace).as_posix() + for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for match in long_path.finditer(line): + alias = exports.get((match.group(1), match.group(2))) + if alias is not None: + candidates.append(Candidate("RUST-IMPORT-201", relative, idx, f"long internal path `crate::{match.group(1)}::{match.group(2)}` must use `crate::{alias}`")) + # Simple `crate::Item` references must resolve at the crate root. + root_symbols = crate_root_symbols(crate) + simple_root = re.compile(r"\bcrate::([A-Za-z_][A-Za-z0-9_]*)\b(?!::)") + for path in sorted(crate.rglob("*.rs")): + if "target" in path.parts: + continue + relative = path.relative_to(workspace).as_posix() + text = path.read_text(encoding="utf-8") + masked = mask_rust_source(text) + for idx, line in enumerate(masked.splitlines(), 1): + for match in simple_root.finditer(line): + symbol = match.group(1) + if symbol not in root_symbols: + candidates.append(Candidate("RUST-IMPORT-203", relative, idx, f"`crate::{symbol}` does not resolve to a declared/re-exported crate-root symbol")) + + # Separated unit tests use `super::Item` only for strictly private parent items; visible items use the crate-root façade. + declarations_by_source: dict[pathlib.Path, dict[str, Declaration]] = {} + for declaration in declarations.values(): + declarations_by_source.setdefault(declaration.path.resolve(), {})[declaration.name] = declaration + super_pattern = re.compile(r"\bsuper::([A-Za-z_][A-Za-z0-9_]*)") + for test, parent in tests.items(): + parent_declarations = declarations_by_source.get(parent, {}) + parent_private_declarations = private_declarations_by_source.get(parent, {}) + relative = test.relative_to(workspace).as_posix() + text = test.read_text(encoding="utf-8") + masked_lines = mask_rust_source(text).splitlines() + for idx, line in enumerate(masked_lines, 1): + for match in super_pattern.finditer(line): + declaration = parent_declarations.get(match.group(1)) + if declaration is not None and declaration.visibility in {"pub", "pub(crate)"}: + candidates.append(Candidate("RUST-IMPORT-202", relative, idx, f"`super::{declaration.name}` targets {declaration.visibility}; use crate-root `crate::{declaration.name}`")) + for declaration in parent_private_declarations.values(): + if unqualified_test_reference_pattern(declaration).search(line) is not None: + candidates.append(Candidate("RUST-IMPORT-204", relative, idx, f"strictly private parent item `{declaration.name}` must be accessed as `super::{declaration.name}` in separated unit tests")) + for declaration in parent_declarations.values(): + if unqualified_test_reference_pattern(declaration).search(line) is not None: + candidates.append(Candidate("RUST-IMPORT-205", relative, idx, f"{declaration.visibility} parent item `{declaration.name}` must be accessed through crate-root `crate::{declaration.name}` in separated unit tests")) + return candidates + + +def main() -> int: + """Run the export-completeness audit.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--report-only", action="store_true") + parser.add_argument("--summary-only", action="store_true") + arguments = parser.parse_args() + workspace = pathlib.Path(arguments.root).resolve() + candidates = [item for crate in crate_roots(workspace) for item in audit_crate(workspace, crate)] + candidates.sort(key=lambda item: (item.code, item.path, item.line, item.message)) + counts: dict[str, int] = {} + for item in candidates: + counts[item.code] = counts.get(item.code, 0) + 1 + sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s)\n") + for code in sorted(counts): + sys.stdout.write(f"{code}: {counts[code]}\n") + if not arguments.summary_only: + for item in candidates: + sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n") + return 0 if arguments.report_only or not candidates else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_general_rules.py b/scripts/audit_rust_general_rules.py new file mode 100755 index 0000000..04ef3fe --- /dev/null +++ b/scripts/audit_rust_general_rules.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_general_rules.py +# version: 1 + +"""Audit mechanically verifiable Rust normalization rules used by games.sasedev.""" + +from __future__ import annotations + +import argparse +import dataclasses +import pathlib +import re +import sys + + +@dataclasses.dataclass(frozen=True) +class Violation: + """One mechanically detected general Rust rule violation.""" + + code: str + path: str + line: int + message: str + + +def rust_files(root: pathlib.Path) -> list[pathlib.Path]: + """Return tracked-style Rust sources outside generated directories.""" + + crates = root / "crates" + return sorted(path for path in crates.rglob("*.rs") if "target" not in path.parts and ".git" not in path.parts) + + +def mask_rust_source(text: str) -> str: + """Mask comments and literals while preserving braces, newlines and offsets.""" + + output = list(text) + index = 0 + state = "code" + block_depth = 0 + raw_hashes = 0 + while index < len(text): + char = text[index] + nxt = text[index + 1] if index + 1 < len(text) else "" + if state == "code": + if char == "/" and nxt == "/": + output[index] = " " + output[index + 1] = " " + index += 2 + state = "line_comment" + continue + if char == "/" and nxt == "*": + output[index] = " " + output[index + 1] = " " + index += 2 + state = "block_comment" + block_depth = 1 + continue + if char == '"': + output[index] = " " + index += 1 + state = "string" + continue + if char == "'": + # Treat a quote as a char literal only when it can close shortly; + # lifetimes such as `'a` remain code. + probe = index + 1 + escaped = False + found = False + while probe < min(len(text), index + 8) and text[probe] != "\n": + if not escaped and text[probe] == "'": + found = True + break + if not escaped and text[probe] == "\\": + escaped = True + else: + escaped = False + probe += 1 + if found: + output[index] = " " + index += 1 + state = "char" + continue + if char == "r": + probe = index + 1 + hashes = 0 + while probe < len(text) and text[probe] == "#": + hashes += 1 + probe += 1 + if probe < len(text) and text[probe] == '"': + for masked in range(index, probe + 1): + output[masked] = " " + index = probe + 1 + raw_hashes = hashes + state = "raw_string" + continue + index += 1 + continue + if state == "line_comment": + if char == "\n": + state = "code" + else: + output[index] = " " + index += 1 + continue + if state == "block_comment": + if char == "/" and nxt == "*": + output[index] = " " + output[index + 1] = " " + block_depth += 1 + index += 2 + continue + if char == "*" and nxt == "/": + output[index] = " " + output[index + 1] = " " + block_depth -= 1 + index += 2 + if block_depth == 0: + state = "code" + continue + if char != "\n": + output[index] = " " + index += 1 + continue + if state == "string": + if char == "\\" and index + 1 < len(text): + output[index] = " " + if text[index + 1] != "\n": + output[index + 1] = " " + index += 2 + continue + if char == '"': + output[index] = " " + state = "code" + elif char != "\n": + output[index] = " " + index += 1 + continue + if state == "char": + if char == "\\" and index + 1 < len(text): + output[index] = " " + if text[index + 1] != "\n": + output[index + 1] = " " + index += 2 + continue + if char == "'": + output[index] = " " + state = "code" + elif char != "\n": + output[index] = " " + index += 1 + continue + if state == "raw_string": + if char == '"' and text.startswith("#" * raw_hashes, index + 1): + output[index] = " " + for masked in range(index + 1, index + 1 + raw_hashes): + output[masked] = " " + index += 1 + raw_hashes + state = "code" + continue + if char != "\n": + output[index] = " " + index += 1 + continue + return "".join(output) + + +def line_depths(masked: str) -> list[int]: + """Return brace depth at the start of every line.""" + + depths: list[int] = [] + depth = 0 + for line in masked.splitlines(): + depths.append(depth) + depth += line.count("{") - line.count("}") + if depth < 0: + depth = 0 + return depths + + +def preceding_doc_line(lines: list[str], index: int) -> bool: + """Return whether one declaration has adjacent useful line rustdoc.""" + + cursor = index - 2 + while cursor >= 0 and lines[cursor].strip().startswith("#["): + cursor -= 1 + return cursor >= 0 and lines[cursor].lstrip().startswith("///") + + +def natural_key(value: str) -> tuple[tuple[int, object], ...]: + """Return a natural case-sensitive ordering key.""" + + tokens = re.findall(r"[A-Za-z_]+|[0-9]+", value) + return tuple((1, int(token)) if token.isdigit() else (0, token) for token in tokens) + + +def public_declaration(stripped: str) -> re.Match[str] | None: + """Match a public or crate-public item or associated item declaration.""" + + return re.match( + r"^pub(?:\(crate\))?\s+(?:(?:async|const|unsafe)\s+)*(?:const|static|type|struct|enum|trait|union|fn)\s+([A-Za-z_][A-Za-z0-9_]*)", + stripped, + ) + + +def public_field(stripped: str) -> re.Match[str] | None: + """Match a public or crate-public named field declaration.""" + + return re.match(r"^pub(?:\(crate\))?\s+([A-Za-z_][A-Za-z0-9_]*)\s*:", stripped) + + +def declaration_start(stripped: str, kind: str) -> bool: + """Return whether a line starts a declaration of one requested kind.""" + + prefixes = r"(?:(?:pub|pub\(crate\))\s+)?(?:(?:async|const|unsafe)\s+)*" + return re.match(rf"^{prefixes}{kind}\b", stripped) is not None + + + + +@dataclasses.dataclass(frozen=True) +class RustItem: + """One mechanically detected Rust item used for spacing checks.""" + + kind: str + visibility: str + start: int + declaration: int + end: int + depth: int + braced: bool + + +def item_visibility(stripped: str) -> str: + """Return normalized visibility for one item declaration.""" + + if stripped.startswith("pub(crate) "): + return "pub(crate)" + if stripped.startswith("pub "): + return "pub" + return "private" + + +def item_kind(stripped: str) -> str | None: + """Return the normalized declaration kind for spacing checks.""" + + prefix = r"(?:(?:pub|pub\(crate\))\s+)?" + qualifiers = r"(?:(?:async|const|unsafe)\s+)*" + if re.match(rf"^{prefix}{qualifiers}fn\b", stripped): + return "fn" + for kind in ("struct", "enum", "union", "trait", "impl", "const", "static", "type"): + if re.match(rf"^{prefix}{qualifiers}{kind}\b", stripped): + return kind + return None + + +def item_leading_line(lines: list[str], declaration_index: int) -> int: + """Return the first rustdoc/attribute line attached to an item.""" + + cursor = declaration_index - 2 + while cursor >= 0: + stripped = lines[cursor].strip() + if stripped.startswith("///") or stripped.startswith("#["): + cursor -= 1 + continue + break + return cursor + 2 + + +def item_end_line(masked_lines: list[str], depths: list[int], declaration_index: int, kind: str) -> tuple[int, bool]: + """Return the end line and whether the item owns a braced body.""" + + start_depth = depths[declaration_index - 1] + if kind in {"const", "static", "type"}: + for line_index in range(declaration_index - 1, len(masked_lines)): + if ";" in masked_lines[line_index]: + return line_index + 1, False + return declaration_index, False + body_started = False + for line_index in range(declaration_index - 1, len(masked_lines)): + masked_line = masked_lines[line_index] + if not body_started and ";" in masked_line and "{" not in masked_line: + return line_index + 1, False + if "{" in masked_line: + body_started = True + if body_started: + end_depth = depths[line_index] + masked_line.count("{") - masked_line.count("}") + if end_depth == start_depth: + return line_index + 1, True + return declaration_index, body_started + + +def rust_items(lines: list[str], masked_lines: list[str], depths: list[int]) -> list[RustItem]: + """Return mechanically detected Rust items with source spans.""" + + result: list[RustItem] = [] + for idx, line in enumerate(lines, 1): + stripped = line.strip() + kind = item_kind(stripped) + if kind is None: + continue + end, braced = item_end_line(masked_lines, depths, idx, kind) + result.append(RustItem(kind, item_visibility(stripped), item_leading_line(lines, idx), idx, end, depths[idx - 1], braced)) + return result + + +def item_parent(item: RustItem, items: list[RustItem]) -> RustItem | None: + """Return the smallest braced item that contains another item.""" + + parents = [candidate for candidate in items if candidate.braced and candidate.declaration < item.declaration <= candidate.end and candidate.depth < item.depth] + if not parents: + return None + return max(parents, key=lambda candidate: candidate.depth) + + +def audit_item_spacing_and_nesting(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]: + """Enforce exact item separation and reject declarations nested in functions.""" + + violations: list[Violation] = [] + items = rust_items(lines, masked_lines, depths) + for item in items: + parent = item_parent(item, items) + if parent is not None and parent.kind == "fn": + body_depth = parent.depth + 1 + preceding = lines[parent.declaration:item.declaration - 1] + preceding_depths = depths[parent.declaration:item.declaration - 1] + returned = any(depth == body_depth and re.match(r"^\s*return\b.*;\s*$", line) is not None for line, depth in zip(preceding, preceding_depths, strict=True)) + if returned: + violations.append(Violation("RUST-FMT-110", relative, item.declaration, f"item `{item.kind}` follows an unconditional function-level return; probable misplaced closing brace")) + children: dict[tuple[int, int] | None, list[RustItem]] = {} + for item in items: + parent = item_parent(item, items) + key = None if parent is None else (parent.declaration, parent.end) + children.setdefault(key, []).append(item) + for siblings in children.values(): + siblings.sort(key=lambda item: item.declaration) + for previous, current in zip(siblings, siblings[1:]): + # Do not infer spacing across unparsed syntax such as macro invocations. + between = lines[previous.end:current.start - 1] + if any(candidate.strip() for candidate in between): + continue + blank_count = sum(1 for candidate in between if not candidate.strip()) + same_homogeneous_block = previous.kind == current.kind and previous.kind in {"const", "static", "type"} and previous.visibility == current.visibility + expected = 0 if same_homogeneous_block else 1 + if blank_count != expected: + rule = "RUST-FMT-111" if expected == 1 else "RUST-FMT-112" + expectation = "exactly one blank line between Rust items" if expected == 1 else "no blank line inside one homogeneous declaration block" + violations.append(Violation(rule, relative, current.start, f"{expectation}; found {blank_count}")) + return violations + + +def audit_blank_lines_in_bodies(relative: str, lines: list[str], masked_lines: list[str], depths: list[int]) -> list[Violation]: + """Reject blank lines inside functions/methods and struct/enum bodies.""" + + violations: list[Violation] = [] + pending_kind: str | None = None + pending_depth = 0 + body_kind: str | None = None + body_depth = 0 + for idx, (line, masked_line) in enumerate(zip(lines, masked_lines, strict=True), 1): + stripped = line.strip() + if body_kind is not None: + if not stripped and depths[idx - 1] >= body_depth: + violations.append(Violation("RUST-FMT-101", relative, idx, f"blank line inside {body_kind} body")) + if depths[idx - 1] < body_depth: + body_kind = None + if body_kind is None and pending_kind is None: + if declaration_start(stripped, "fn"): + pending_kind = "function/method" + pending_depth = depths[idx - 1] + elif declaration_start(stripped, "struct"): + pending_kind = "struct" + pending_depth = depths[idx - 1] + elif declaration_start(stripped, "enum"): + pending_kind = "enum" + pending_depth = depths[idx - 1] + if pending_kind is not None and "{" in masked_line: + body_kind = pending_kind + body_depth = pending_depth + 1 + pending_kind = None + elif pending_kind is not None and ";" in masked_line: + pending_kind = None + return violations + + +def audit_top_level_const_blocks(relative: str, lines: list[str], depths: list[int]) -> list[Violation]: + """Audit visibility/order/spacing for mechanically homogeneous const blocks.""" + + violations: list[Violation] = [] + const_pattern = re.compile(r"^(pub\s+|pub\(crate\)\s+)?const\s+([A-Za-z_][A-Za-z0-9_]*)") + previous: tuple[int, int, str] | None = None + # rank: public, crate-public, private + for idx, line in enumerate(lines, 1): + if depths[idx - 1] != 0: + continue + match = const_pattern.match(line.strip()) + if match is None: + # Docs and attributes belong to the surrounding block and do not break it. + if line.strip() and not line.lstrip().startswith(("///", "#[")): + previous = None + continue + visibility = match.group(1) or "" + rank = 0 if visibility == "pub " else 1 if visibility == "pub(crate) " else 2 + name = match.group(2) + if previous is not None: + previous_line, previous_rank, previous_name = previous + if rank < previous_rank: + violations.append(Violation("RUST-FMT-103", relative, idx, "const visibility order must be pub, pub(crate), then private")) + if rank == previous_rank and natural_key(name) < natural_key(previous_name): + violations.append(Violation("RUST-FMT-104", relative, idx, "const block is not alphabetically ordered")) + previous = (idx, rank, name) + return violations + + + + +def audit_module_order(relative: str, lines: list[str], depths: list[int]) -> list[Violation]: + """Audit top-level module declaration ordering with test modules last.""" + + violations: list[Violation] = [] + modules: list[tuple[int, bool, str]] = [] + for idx, line in enumerate(lines, 1): + if depths[idx - 1] != 0: + continue + match = re.match(r"^mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", line.strip()) + if match is None: + continue + cursor = idx - 2 + is_test = False + while cursor >= 0 and lines[cursor].strip().startswith("#["): + if "cfg(test)" in lines[cursor].replace(" ", ""): + is_test = True + cursor -= 1 + modules.append((idx, is_test, match.group(1))) + production = [(idx, name) for idx, is_test, name in modules if not is_test] + tests = [(idx, name) for idx, is_test, name in modules if is_test] + if tests and production and min(idx for idx, _ in tests) < max(idx for idx, _ in production): + first_test = min(idx for idx, _ in tests) + violations.append(Violation("RUST-FMT-108", relative, first_test, "test modules must follow production modules")) + for group, label in ((production, "production"), (tests, "test")): + names = [name for _, name in group] + if names != sorted(names, key=natural_key): + for idx, name in group: + expected = sorted(names, key=natural_key) + if names.index(name) != expected.index(name): + violations.append(Violation("RUST-FMT-109", relative, idx, f"{label} module declarations are not alphabetically ordered")) + break + return violations + +def audit_file(root: pathlib.Path, path: pathlib.Path) -> list[Violation]: + """Audit one Rust source against general normalization rules.""" + + relative = path.relative_to(root).as_posix() + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked = mask_rust_source(text) + masked_lines = masked.splitlines() + depths = line_depths(masked) + violations: list[Violation] = [] + expected_header = f"// file: {relative}" + if not lines or lines[0] != expected_header: + violations.append(Violation("RUST-BASE-101", relative, 1, f"expected `{expected_header}`")) + if len(lines) < 2 or re.fullmatch(r"// version: [1-9][0-9]*", lines[1]) is None: + violations.append(Violation("RUST-BASE-102", relative, 2, "missing positive file version")) + if not text.endswith("\n") or text.endswith("\n\n"): + violations.append(Violation("RUST-FMT-100", relative, max(len(lines), 1), "file must end with exactly one newline")) + local_modules = {match.group(1) for line in lines if (match := re.fullmatch(r"\s*mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;", line)) is not None} + seen_declaration_before_use = False + use_rows: list[tuple[int, str]] = [] + for idx, line in enumerate(lines, 1): + stripped = line.strip() + depth = depths[idx - 1] if idx - 1 < len(depths) else 0 + if re.match(r"^(?:pub\s+)?extern\s+crate\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s+", stripped): + violations.append(Violation("RUST-IMPORT-104", relative, idx, "extern crate aliases are forbidden")) + if "pub(in " in stripped or "pub(super)" in stripped: + violations.append(Violation("RUST-API-102", relative, idx, "restricted visibility is forbidden; use private or pub(crate) with crate-root re-export")) + if stripped.startswith("pub mod "): + violations.append(Violation("RUST-API-101", relative, idx, "modules must remain private and APIs use explicit re-exports")) + if depth == 0 and re.match(r"^(?:pub(?:\(crate\))?\s+)?(?:const|static|type|struct|enum|trait|union|fn|impl)\b", stripped): + seen_declaration_before_use = True + use_match = re.match(r"^(pub(?:\(crate\))?\s+)?use\s+", stripped) + if use_match is not None: + is_export = stripped.startswith("pub use ") or stripped.startswith("pub(crate) use ") + if depth != 0: + violations.append(Violation("RUST-IMPORT-111", relative, idx, "use declarations must be at module scope, never inside a function/method/block")) + if not is_export: + use_rows.append((idx, stripped)) + if seen_declaration_before_use: + violations.append(Violation("RUST-IMPORT-112", relative, idx, "trait imports must remain at the beginning of the module before declarations")) + if "rust-rules: trait-import" not in stripped: + violations.append(Violation("RUST-IMPORT-101", relative, idx, "ordinary use requires an explicit trait import justification")) + if "::*" in stripped: + violations.append(Violation("RUST-IMPORT-102", relative, idx, "glob imports are forbidden")) + if "{" in stripped or "}" in stripped: + violations.append(Violation("RUST-IMPORT-103", relative, idx, "grouped use/re-export declarations are forbidden")) + if re.search(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*", stripped): + violations.append(Violation("RUST-IMPORT-104", relative, idx, "use/re-export aliases are forbidden")) + if is_export and re.match(r"^pub(?:\(crate\))?\s+use\s+crate::", stripped): + violations.append(Violation("RUST-IMPORT-105", relative, idx, "internal re-export must start with self::")) + declaration = public_declaration(stripped) + field = public_field(stripped) + if declaration is not None or field is not None: + if not preceding_doc_line(lines, idx): + name = (declaration or field).group(1) + violations.append(Violation("RUST-DOC-101", relative, idx, f"public/crate-public `{name}` requires adjacent rustdoc")) + # The ordinary module import block is contiguous and alphabetic. + for (previous_idx, previous_line), (current_idx, current_line) in zip(use_rows, use_rows[1:]): + between = lines[previous_idx:current_idx - 1] + if any(not candidate.strip() for candidate in between): + violations.append(Violation("RUST-IMPORT-113", relative, current_idx, "module use block contains an empty line")) + previous_path = previous_line.split("use ", 1)[1].split(";", 1)[0] + current_path = current_line.split("use ", 1)[1].split(";", 1)[0] + if natural_key(current_path) < natural_key(previous_path): + violations.append(Violation("RUST-IMPORT-114", relative, current_idx, "module use block is not alphabetically ordered")) + # Crate façade constraints. + if path.name in {"lib.rs", "main.rs"}: + for attribute in ("#![warn(missing_docs)]", "#![deny(unreachable_pub)]", "#![forbid(unsafe_code)]"): + if attribute not in lines[:24]: + violations.append(Violation("RUST-BASE-103", relative, 1, f"missing `{attribute}`")) + exports: list[tuple[int, str]] = [] + for idx, line in enumerate(lines, 1): + stripped = line.strip() + if stripped.startswith("pub use ") or stripped.startswith("pub(crate) use "): + visibility = "pub(crate)" if stripped.startswith("pub(crate) use ") else "pub" + exports.append((idx, visibility)) + if not preceding_doc_line(lines, idx): + violations.append(Violation("RUST-DOC-102", relative, idx, "crate-root re-export requires adjacent rustdoc")) + source_match = re.match(r"^pub(?:\(crate\))?\s+use\s+(?:self::)?([A-Za-z_][A-Za-z0-9_]*)::", stripped) + if source_match is not None and source_match.group(1) in local_modules and not re.match(r"^pub(?:\(crate\))?\s+use\s+self::", stripped): + violations.append(Violation("RUST-IMPORT-106", relative, idx, "crate-root internal re-export must start with self::")) + public_exports = [item for item in exports if item[1] == "pub"] + crate_exports = [item for item in exports if item[1] == "pub(crate)"] + if public_exports and crate_exports: + public_end = public_exports[-1][0] + crate_start = crate_exports[0][0] + transition = lines[public_end:crate_start - 1] + blank_count = sum(1 for candidate in transition if not candidate.strip()) + if blank_count != 1: + violations.append(Violation("RUST-FMT-113", relative, crate_start, f"pub use and pub(crate) use blocks require exactly one blank line; found {blank_count}")) + crate_seen = False + previous_by_visibility: dict[str, int] = {} + for idx, visibility in exports: + if visibility == "pub(crate)": + crate_seen = True + elif crate_seen: + violations.append(Violation("RUST-FMT-105", relative, idx, "pub use block must precede pub(crate) use block")) + previous_idx = previous_by_visibility.get(visibility) + if previous_idx is not None: + # One homogeneous block may contain rustdocs but no blank line. + # rustfmt is authoritative for intra-block re-export ordering; + # this audit must not impose a competing exported-symbol sort. + between = lines[previous_idx:idx - 1] + if any(not candidate.strip() for candidate in between): + violations.append(Violation("RUST-FMT-106", relative, idx, f"{visibility} use block contains an empty line")) + previous_by_visibility[visibility] = idx + violations.extend(audit_blank_lines_in_bodies(relative, lines, masked_lines, depths)) + violations.extend(audit_item_spacing_and_nesting(relative, lines, masked_lines, depths)) + violations.extend(audit_module_order(relative, lines, depths)) + violations.extend(audit_top_level_const_blocks(relative, lines, depths)) + return violations + + +def main() -> int: + """Run the general Rust normalization audit.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--report-only", action="store_true") + arguments = parser.parse_args() + root = pathlib.Path(arguments.root).resolve() + violations = [item for path in rust_files(root) for item in audit_file(root, path)] + violations.sort(key=lambda item: (item.code, item.path, item.line, item.message)) + if not violations: + sys.stdout.write("General Rust rule audit: clean\n") + return 0 + sys.stdout.write(f"General Rust rule audit: {len(violations)} violation(s)\n") + counts: dict[str, int] = {} + for item in violations: + counts[item.code] = counts.get(item.code, 0) + 1 + for code in sorted(counts): + sys.stdout.write(f"{code}: {counts[code]}\n") + for item in violations: + sys.stdout.write(f"{item.code} {item.path}:{item.line}: {item.message}\n") + return 0 if arguments.report_only else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_workspace_rules.py b/scripts/audit_rust_workspace_rules.py new file mode 100755 index 0000000..91fa7fb --- /dev/null +++ b/scripts/audit_rust_workspace_rules.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_workspace_rules.py +# version: 1 + +"""Run all Rust normalization and games.sasedev workspace audits.""" + +from __future__ import annotations + +import argparse +import pathlib +import subprocess +import sys + + +def main() -> int: + """Run general, export-completeness and project-specific audits.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".", help="workspace root") + arguments = parser.parse_args() + script_dir = pathlib.Path(__file__).resolve().parent + commands = [ + ["python3", str(script_dir / "audit_rust_general_rules.py"), "--root", arguments.root], + ["python3", str(script_dir / "audit_rust_export_completeness.py"), "--root", arguments.root], + ["python3", str(script_dir / "audit_project_workspace_rules.py"), "--root", arguments.root], + ] + for command in commands: + completed = subprocess.run(command, check=False) + if completed.returncode != 0: + return completed.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())