0.1.0-0-pre.1
This commit is contained in:
6
.cargo/config.toml
Normal file
6
.cargo/config.toml
Normal 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
65
.gitignore
vendored
Normal 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
|
||||
2
.settings/org.eclipse.core.resources.prefs
Normal file
2
.settings/org.eclipse.core.resources.prefs
Normal file
@@ -0,0 +1,2 @@
|
||||
eclipse.preferences.version=1
|
||||
encoding/<project>=UTF-8
|
||||
19
Android/README.md
Normal file
19
Android/README.md
Normal 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
4
Android/build.gradle
Normal 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.
|
||||
4
Android/common/build.gradle
Normal file
4
Android/common/build.gradle
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
4
Android/game-reflex-poc/build.gradle
Normal file
4
Android/game-reflex-poc/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
||||
// file: Android/game-reflex-poc/build.gradle
|
||||
// version: 1
|
||||
|
||||
// This module will become the Reflex POC Android application.
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
5
Android/game-reflex-poc/src/main/res/values/strings.xml
Normal file
5
Android/game-reflex-poc/src/main/res/values/strings.xml
Normal 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>
|
||||
4
Android/game-snake-poc/build.gradle
Normal file
4
Android/game-snake-poc/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
||||
// file: Android/game-snake-poc/build.gradle
|
||||
// version: 1
|
||||
|
||||
// This module will become the Snake POC Android application.
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
5
Android/game-snake-poc/src/main/res/values/strings.xml
Normal file
5
Android/game-snake-poc/src/main/res/values/strings.xml
Normal 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
8
Android/settings.gradle
Normal 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
15
CHANGELOG.md
Normal 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
47
Cargo.toml
Normal 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
34
README.md
Normal 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
19
ROADMAP.md
Normal 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
23
RULES.md
Normal 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
6
assets/common/README.md
Normal 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.
|
||||
6
assets/game-reflex-poc/README.md
Normal file
6
assets/game-reflex-poc/README.md
Normal 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.
|
||||
6
assets/game-snake-poc/README.md
Normal file
6
assets/game-snake-poc/README.md
Normal 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
28
clippy.toml
Normal 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"]
|
||||
14
crates/engines/engine-v1-common/Cargo.toml
Normal file
14
crates/engines/engine-v1-common/Cargo.toml
Normal 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
|
||||
21
crates/engines/engine-v1-common/src/action.rs
Normal file
21
crates/engines/engine-v1-common/src/action.rs
Normal 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,
|
||||
}
|
||||
13
crates/engines/engine-v1-common/src/lib.rs
Normal file
13
crates/engines/engine-v1-common/src/lib.rs
Normal 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;
|
||||
14
crates/engines/engine-v1-platform-api/Cargo.toml
Normal file
14
crates/engines/engine-v1-platform-api/Cargo.toml
Normal 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
|
||||
13
crates/engines/engine-v1-platform-api/src/lib.rs
Normal file
13
crates/engines/engine-v1-platform-api/src/lib.rs
Normal 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;
|
||||
17
crates/engines/engine-v1-platform-api/src/service.rs
Normal file
17
crates/engines/engine-v1-platform-api/src/service.rs
Normal 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,
|
||||
}
|
||||
17
crates/engines/engine-v1-sdl/Cargo.toml
Normal file
17
crates/engines/engine-v1-sdl/Cargo.toml
Normal 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
|
||||
15
crates/engines/engine-v1-sdl/src/lib.rs
Normal file
15
crates/engines/engine-v1-sdl/src/lib.rs
Normal 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;
|
||||
14
crates/engines/engine-v1-sdl/src/runtime.rs
Normal file
14
crates/engines/engine-v1-sdl/src/runtime.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
19
crates/games/game-reflex-poc/Cargo.toml
Normal file
19
crates/games/game-reflex-poc/Cargo.toml
Normal 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
|
||||
13
crates/games/game-reflex-poc/src/lib.rs
Normal file
13
crates/games/game-reflex-poc/src/lib.rs
Normal 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;
|
||||
37
crates/games/game-reflex-poc/src/state.rs
Normal file
37
crates/games/game-reflex-poc/src/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
19
crates/games/game-snake-poc/Cargo.toml
Normal file
19
crates/games/game-snake-poc/Cargo.toml
Normal 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
|
||||
13
crates/games/game-snake-poc/src/lib.rs
Normal file
13
crates/games/game-snake-poc/src/lib.rs
Normal 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;
|
||||
43
crates/games/game-snake-poc/src/state.rs
Normal file
43
crates/games/game-snake-poc/src/state.rs
Normal 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
36
deltas/0.1.0/0-pre.1.md
Normal 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 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.
|
||||
17
docs/000-README.md
Normal file
17
docs/000-README.md
Normal 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.
|
||||
56
docs/architecture/001-WORKSPACE_ARCHITECTURE.md
Normal file
56
docs/architecture/001-WORKSPACE_ARCHITECTURE.md
Normal 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.
|
||||
42
docs/architecture/002-ANDROID_ARCHITECTURE.md
Normal file
42
docs/architecture/002-ANDROID_ARCHITECTURE.md
Normal 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.
|
||||
17
docs/rules/FILE_CONTRACTS.md
Normal file
17
docs/rules/FILE_CONTRACTS.md
Normal 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.
|
||||
14
docs/rules/RULES_DOCUMENTATION.md
Normal file
14
docs/rules/RULES_DOCUMENTATION.md
Normal 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.
|
||||
37
docs/rules/RULES_GENERAL.md
Normal file
37
docs/rules/RULES_GENERAL.md
Normal 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.
|
||||
51
docs/rules/RULES_PROJECT.md
Normal file
51
docs/rules/RULES_PROJECT.md
Normal 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
45
docs/rules/RULES_RUST.md
Normal 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.
|
||||
75
docs/rules/VERSION_WORKFLOW.md
Normal file
75
docs/rules/VERSION_WORKFLOW.md
Normal 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
|
||||
```
|
||||
17
docs/validation/001-VALIDATION_GATES.md
Normal file
17
docs/validation/001-VALIDATION_GATES.md
Normal 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.
|
||||
6
prompts/000-V0_1_0_START_PROMPT.md
Normal file
6
prompts/000-V0_1_0_START_PROMPT.md
Normal 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
23
rustfmt.toml
Normal 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
251
scripts/audit_markdown_tables.py
Executable 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())
|
||||
63
scripts/audit_project_workspace_rules.py
Executable file
63
scripts/audit_project_workspace_rules.py
Executable 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())
|
||||
283
scripts/audit_rust_export_completeness.py
Executable file
283
scripts/audit_rust_export_completeness.py
Executable 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())
|
||||
587
scripts/audit_rust_general_rules.py
Executable file
587
scripts/audit_rust_general_rules.py
Executable 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())
|
||||
35
scripts/audit_rust_workspace_rules.py
Executable file
35
scripts/audit_rust_workspace_rules.py
Executable 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())
|
||||
Reference in New Issue
Block a user