0.1.0-0-pre.1

This commit is contained in:
2026-09-15 23:26:37 +02:00
commit 8b4c79c431
57 changed files with 2314 additions and 0 deletions

6
.cargo/config.toml Normal file
View File

@@ -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

65
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,2 @@
eclipse.preferences.version=1
encoding/<project>=UTF-8

19
Android/README.md Normal file
View File

@@ -0,0 +1,19 @@
<!-- file: Android/README.md -->
<!-- version: 1 -->
# 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.

4
Android/build.gradle Normal file
View File

@@ -0,0 +1,4 @@
// file: Android/build.gradle
// version: 1
// Android Gradle Plugin and repositories are intentionally introduced in the dedicated executable Android prerelease.

View File

@@ -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.

View File

@@ -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;
}
}

View File

@@ -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.
*
* <p>The executable Android delta will make this class extend SDLActivity once the SDL3 AAR is introduced.</p>
*/
public abstract class SaseGameActivity {
/**
* Returns the stable identifier of the game hosted by the activity.
*
* @return game identifier
*/
public abstract String gameId();
}

View File

@@ -0,0 +1,4 @@
// file: Android/game-reflex-poc/build.gradle
// version: 1
// This module will become the Reflex POC Android application.

View File

@@ -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";
}
}

View File

@@ -0,0 +1,5 @@
<!-- file: Android/game-reflex-poc/src/main/res/values/strings.xml -->
<!-- version: 1 -->
<resources>
<string name="app_name">Reflex POC</string>
</resources>

View File

@@ -0,0 +1,4 @@
// file: Android/game-snake-poc/build.gradle
// version: 1
// This module will become the Snake POC Android application.

View File

@@ -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";
}
}

View File

@@ -0,0 +1,5 @@
<!-- file: Android/game-snake-poc/src/main/res/values/strings.xml -->
<!-- version: 1 -->
<resources>
<string name="app_name">Snake POC</string>
</resources>

8
Android/settings.gradle Normal file
View File

@@ -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")

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
<!-- file: CHANGELOG.md -->
<!-- version: 1 -->
# 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.

47
Cargo.toml Normal file
View File

@@ -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 <games@sasedev.com>"]
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"

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
<!-- file: README.md -->
<!-- version: 1 -->
# 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)

19
ROADMAP.md Normal file
View File

@@ -0,0 +1,19 @@
<!-- file: ROADMAP.md -->
<!-- version: 1 -->
# 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.

23
RULES.md Normal file
View File

@@ -0,0 +1,23 @@
<!-- file: RULES.md -->
<!-- version: 1 -->
# 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.

6
assets/common/README.md Normal file
View File

@@ -0,0 +1,6 @@
<!-- file: assets/common/README.md -->
<!-- version: 1 -->
# Assets common
Ce répertoire est réservé aux assets runtime communs. Les assets restent hors des crates Rust.

View File

@@ -0,0 +1,6 @@
<!-- file: assets/game-reflex-poc/README.md -->
<!-- version: 1 -->
# Assets game-reflex-poc
Ce répertoire est réservé aux assets runtime game-reflex-poc. Les assets restent hors des crates Rust.

View File

@@ -0,0 +1,6 @@
<!-- file: assets/game-snake-poc/README.md -->
<!-- version: 1 -->
# Assets game-snake-poc
Ce répertoire est réservé aux assets runtime game-snake-poc. Les assets restent hors des crates Rust.

28
clippy.toml Normal file
View File

@@ -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"]

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}
}

36
deltas/0.1.0/0-pre.1.md Normal file
View File

@@ -0,0 +1,36 @@
<!-- file: deltas/0.1.0/0-pre.1.md -->
<!-- version: 2 -->
# 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 lenvironnement 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.

17
docs/000-README.md Normal file
View File

@@ -0,0 +1,17 @@
<!-- file: docs/000-README.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,56 @@
<!-- file: docs/architecture/001-WORKSPACE_ARCHITECTURE.md -->
<!-- version: 1 -->
# 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/<game>/
```
Le runtime devra conserver une distinction logique entre ressources communes et ressources spécifiques afin d'éviter les collisions silencieuses.

View File

@@ -0,0 +1,42 @@
<!-- file: docs/architecture/002-ANDROID_ARCHITECTURE.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,17 @@
<!-- file: docs/rules/FILE_CONTRACTS.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,14 @@
<!-- file: docs/rules/RULES_DOCUMENTATION.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,37 @@
<!-- file: docs/rules/RULES_GENERAL.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,51 @@
<!-- file: docs/rules/RULES_PROJECT.md -->
<!-- version: 1 -->
# 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/<game>/` 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/<game>/` 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.

45
docs/rules/RULES_RUST.md Normal file
View File

@@ -0,0 +1,45 @@
<!-- file: docs/rules/RULES_RUST.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,75 @@
<!-- file: docs/rules/VERSION_WORKFLOW.md -->
<!-- version: 1 -->
# 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-<semver>-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/<prerelease-or-rel>.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
```

View File

@@ -0,0 +1,17 @@
<!-- file: docs/validation/001-VALIDATION_GATES.md -->
<!-- version: 1 -->
# 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.

View File

@@ -0,0 +1,6 @@
<!-- file: prompts/000-V0_1_0_START_PROMPT.md -->
<!-- version: 1 -->
# 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`.

23
rustfmt.toml Normal file
View File

@@ -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

251
scripts/audit_markdown_tables.py Executable file
View File

@@ -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())

View File

@@ -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())

View File

@@ -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"(?<![\w:.]){name}\s*\(")
if declaration.kind in {"const", "static", "type", "struct", "enum", "trait", "union"}:
return re.compile(rf"(?<![\w:]){name}\b")
return re.compile(rf"(?<![\w:]){name}\b")
def root_exports(crate: pathlib.Path) -> 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())

View File

@@ -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())

View File

@@ -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())