0.0.5-alpha.5.fix.1

This commit is contained in:
2026-09-25 08:14:57 +02:00
parent f03f8640d4
commit 81c7a720f5
9 changed files with 864 additions and 124 deletions

View File

@@ -1 +1 @@
0.0.5-alpha.5
0.0.5-alpha.5.fix.1

View File

@@ -0,0 +1,217 @@
<!-- file: deltas/0.0.5/alpha.5.fix.1.md -->
<!-- version: 1 -->
# Delta 0.0.5-alpha.5.fix.1 — canonicalisation réelle des références crate-root
## Baseline et version
Baseline attendue : commit opérateur complet de `0.0.5-alpha.5`, réalisé après application de `history/0.0.5/alpha.5.md` avec statut `FAIL`.
Version avant application :
```text
0.0.5-alpha.5
```
Version après application :
```text
0.0.5-alpha.5.fix.1
```
## Cause du fix
La gate mécanique de `alpha.5` est passée, mais la revue fonctionnelle opérateur a identifié un blind spot dans `RUST-IMPORT-201` : la `version: 1` détecte `crate::module::Item` lorsque `crate::Item` existe, mais ne détecte pas un usage nu ou autrement non canonique de ce même item dans son module propriétaire ou un autre module.
Or le contrat recherché est bien :
```text
item partagé pub/pub(crate)
↓
façade crate-root
↓
toutes les références intra-crate détectées utilisent crate::Item
```
Le compilateur et Clippy ne possèdent pas cette convention. Une seule référence correcte à `crate::Item` peut rendre le réexport utilisé tout en laissant subsister d'autres `Item`, `self::Item`, `super::Item` ou chemins internes.
## Scope
REPLACE :
```text
VERSION
docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md
docs/rules/510-RUST_CARGO.md
docs/studies/001-KSP_GAMES_RECONCILIATION.md
docs/validation/003-RUST_EXPORT_COMPLETENESS.md
scripts/audit_rust_export_completeness.py
tests/test_rust_export_completeness.py
```
ADD :
```text
deltas/0.0.5/alpha.5.fix.1.md
```
DELETE : aucun.
Le history `alpha.5` appartient au commit baseline et n'est pas inclus dans l'archive du présent delta.
## Décisions matérialisées
### RUST-IMPORT-201 couvre toute référence non canonique détectable
Pour un item possédant une identité crate-root, la seule forme intra-crate canonique est :
```text
crate::Item
```
Le diagnostic couvre désormais les formes suivantes lorsqu'elles désignent cet item partagé :
```text
Item
self::Item
super::Item
module::Item
crate::module::Item
```
La règle s'applique au module propriétaire lui-même : signatures, `impl`, constructeurs, appels et références de types doivent employer la façade crate-root lorsqu'ils portent l'item partagé.
Les déclarations qui créent l'item et les lignes de réexport qui construisent la façade sont exemptées. Les éléments strictement privés restent hors de cette règle ; leurs tests attachés peuvent utiliser `super::PrivateItem`.
### Targets Cargo distinctes
Le fix évite également de confondre bibliothèque et binaire d'un même package. `src/lib.rs`, `src/main.rs`, les bins standard et les targets explicites sont traités comme des crates compilées distinctes. Le graphe de modules de chaque target est suivi via `mod name;` et `#[path = "..."]`.
Cette correction est nécessaire pour ne pas qualifier à tort `owner_crate::Item` dans un binaire comme référence intra-crate de la bibliothèque du même package.
### Analyse lexicale bornée
L'audit reste read-only et standard library. Il ne prétend pas remplacer un parseur Rust :
- les commentaires et littéraux sont masqués ;
- les chemins qualifiés par une crate externe ne sont pas assimilés à l'item local homonyme ;
- un appel de méthode `.name()` n'est pas assimilé à une fonction libre locale de même nom ;
- les fonctions libres sont contrôlées dans les contextes d'appel directs ; leur usage comme valeur de premier ordre peut nécessiter une revue humaine.
### Réconciliation corrigée
En `--report-only --summary-only`, la `version: 2` observe sur les baselines de `alpha.1` :
```text
KSP 0.3.16
22 package(s)
1525 candidate(s)
RUST-IMPORT-201: 1525
Games 0.3.5
23 package(s)
181 candidate(s)
RUST-API-201: 2
RUST-IMPORT-201: 179
```
Ces nombres sont des lignes candidates à revoir, pas des déclarations distinctes. Ils ne constituent pas une non-conformité rétroactive : KSP et Games ne sont pas encore gouvernés par ce PGR. Ils montrent pourquoi une future adoption doit commencer par un inventaire `--report-only` et une migration proportionnée.
## Validation opérateur
Après application du history `FAIL` de `alpha.5`, commit/push de cette tranche, puis application du présent fix :
```bash
test "$(cat VERSION)" = '0.0.5-alpha.5.fix.1'
test -f history/0.0.5/alpha.5.md
test -f deltas/0.0.5/alpha.5.fix.1.md
test ! -e history/0.0.5/alpha.5.fix.1.md
grep -A2 '^## Statut$' history/0.0.5/alpha.5.md | grep -Fx 'FAIL'
grep -Fx '# version: 2' scripts/audit_rust_export_completeness.py
grep -Fx '# version: 2' tests/test_rust_export_completeness.py
grep -Fx '<!-- version: 2 -->' docs/validation/003-RUST_EXPORT_COMPLETENESS.md
grep -Fx '<!-- version: 4 -->' docs/rules/510-RUST_CARGO.md
python3 -B -m unittest discover -s tests -p 'test_rust_export_completeness.py' -v
python3 -B -m unittest discover -s tests -v
python3 -B scripts/audit_markdown_headers.py \
README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts
python3 -B scripts/audit_markdown_tables.py \
README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts
python3 -B scripts/audit_markdown_links.py \
README.md RULES.md ROADMAP.md CHANGELOG.md docs deltas history prompts
python3 -B - <<'PY'
from pathlib import Path
import re
history = Path('history/0.0.5/alpha.5.md').read_text(encoding='utf-8')
assert re.search(r'## Statut\s+FAIL\s', history)
script = Path('scripts/audit_rust_export_completeness.py').read_text(encoding='utf-8')
for phrase in (
'def target_roots(',
'def target_modules(',
'def _canonical_reference_candidates(',
'RUST-API-201',
'RUST-IMPORT-201',
'non-canonical same-crate reference',
):
assert phrase in script, phrase
validation = Path('docs/validation/003-RUST_EXPORT_COMPLETENESS.md').read_text(encoding='utf-8')
for phrase in (
'Item\nself::Item\nsuper::Item\nmodule::Item\ncrate::module::Item',
'dans le module qui déclare l\'item',
'1525 candidate(s)',
'181 candidate(s)',
'pas des déclarations distinctes',
):
assert phrase in validation, phrase
rules = Path('docs/rules/510-RUST_CARGO.md').read_text(encoding='utf-8')
assert 'toute référence dans la même crate MUST utiliser `crate::Item`' in rules
assert 'y compris depuis son module de déclaration' in rules
print('0.0.5 alpha.5.fix.1 canonical Rust path audit: clean (owner module, cross-module, attached tests, distinct Cargo targets)')
PY
git diff --check
git add VERSION \
docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md \
docs/rules/510-RUST_CARGO.md \
docs/studies/001-KSP_GAMES_RECONCILIATION.md \
docs/validation/003-RUST_EXPORT_COMPLETENESS.md \
scripts/audit_rust_export_completeness.py \
tests/test_rust_export_completeness.py \
deltas/0.0.5/alpha.5.fix.1.md
git diff --cached --check
git status --short
```
La sortie Git attendue avant ajout du history du fix est exactement :
```text
M VERSION
A deltas/0.0.5/alpha.5.fix.1.md
M docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md
M docs/rules/510-RUST_CARGO.md
M docs/studies/001-KSP_GAMES_RECONCILIATION.md
M docs/validation/003-RUST_EXPORT_COMPLETENESS.md
M scripts/audit_rust_export_completeness.py
M tests/test_rust_export_completeness.py
```
## Transition
### PASS
Créer/appliquer `history/0.0.5/alpha.5.fix.1.md`, committer/pousser `0.0.5-alpha.5.fix.1`, puis poursuivre à `0.0.5-alpha.6` pour le profil Tauri prévu par le plan.
### FAIL
Rester dans `0.0.5-alpha.5.fix.M`. Aucun travail Tauri ne doit être absorbé tant que le contrat de façade Rust n'est pas correctement fermé.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Plan 0.0.5 — réconciliation réelle avec KSP et sasedev-games
@@ -84,11 +84,13 @@ Cette tranche ne commence pas l'audit Rust ni le profil Tauri.
Extraire le noyau réellement commun de `audit_rust_export_completeness.py`, l'aligner avec `510-RUST_CARGO.md`, documenter son caractère facultatif et ses limites, et ajouter des tests positifs/négatifs.
La réconciliation a révélé que le script historique ne découvrait que `crates/*/Cargo.toml` : il couvrait les `22` crates KSP mais aucune des `23` crates Games imbriquées sous des sous-familles. La variante PGR doit donc corriger la découverte récursive avant toute généralisation. Elle doit également abandonner les sous-règles historiques de tests séparés et la duplication de résolution de noms déjà possédée par le compilateur.
La réconciliation a révélé que le script historique ne découvrait que `crates/*/Cargo.toml` : il couvrait les `22` packages KSP mais aucun des `23` packages Games imbriqués sous des sous-familles. La variante PGR corrige donc la découverte récursive et distingue aussi les cibles `lib`/`bin` d'un même package pour ne pas confondre plusieurs crates Rust compilées séparément.
Mesure de référence avec le scope PGR retenu : `KSP = 0 candidate(s) / 22 crates`, `Games = 2 candidate(s) / 23 crates`. Les deux signaux Games sont une observation de réconciliation, pas une non-conformité rétroactive.
La première livraison `alpha.5` a ensuite révélé un second défaut de scope pendant la revue opérateur : elle ne détectait que les chemins internes longs tels que `crate::module::Item`, alors que le contrat attendu impose `crate::Item` pour **toute référence intra-crate à un item partagé**, y compris un nom nu dans son module propriétaire. La gate mécanique était verte mais l'acceptation fonctionnelle est donc `FAIL`; `alpha.5.fix.1` corrige ce blind spot sans commencer Tauri.
Cette tranche ne réimporte pas `audit_rust_general_rules.py` en bloc. Toute règle déjà possédée par Rustfmt, Clippy, le compilateur ou les audits PGR existants reste chez son owner actuel.
Mesure `alpha.5.fix.1` en `--report-only --summary-only` : KSP `0.3.16` = `1525` lignes candidates `RUST-IMPORT-201` sur `22` packages ; Games `0.3.5` = `181` lignes candidates sur `23` packages (`2 RUST-API-201`, `179 RUST-IMPORT-201`). Ces nombres forment un inventaire d'adoption future, pas une non-conformité rétroactive des baselines.
Cette tranche ne réimporte pas `audit_rust_general_rules.py` en bloc et ne réintroduit pas le diagnostic historique de résolution `crate::X` déjà possédé par le compilateur. Toute autre règle déjà possédée par Rustfmt, Clippy, le compilateur ou les audits PGR existants reste chez son owner actuel.
### alpha.6 — premier profil framework : Tauri

View File

@@ -1,5 +1,5 @@
<!-- file: docs/rules/510-RUST_CARGO.md -->
<!-- version: 3 -->
<!-- version: 4 -->
# Profil Rust / Cargo
@@ -90,9 +90,11 @@ Tout élément `pub` ou `pub(crate)` SHOULD posséder une rustdoc utile au point
Une crate réutilisable SHOULD exposer son API durable depuis son crate-root par des réexports explicites. Les modules d'implémentation SHOULD rester privés ; `pub mod` ne doit pas être utilisé uniquement pour éviter de concevoir une façade stable.
Un élément partagé à l'échelle de sa propre crate SHOULD être consommable via `crate::Item` après réexport approprié. Les autres crates consomment l'API publique du crate propriétaire plutôt que ses chemins internes.
Lorsqu'un projet adopte le **contrat strict de façade crate-root**, un élément `pub` d'un module fichier MUST être réexporté explicitement au crate-root. Un élément `pub(crate)` consommé hors de son module MUST de même posséder un réexport `pub(crate)` au crate-root. Un helper réellement local SHOULD rester privé plutôt qu'élargir artificiellement sa visibilité.
Les glob imports SHOULD être évités. Un alias de réexport ne doit pas servir à masquer durablement une collision de noms dans l'API ; lorsqu'un nom fait partie du contrat public, le propriétaire SHOULD choisir un nom canonique non ambigu.
Dès qu'un item possède cette identité crate-root, toute référence dans la même crate MUST utiliser `crate::Item`, y compris depuis son module de déclaration et depuis ses tests unitaires attachés. Les formes `Item`, `self::Item`, `super::Item`, `module::Item` et `crate::module::Item` ne sont pas des chemins de consommation canoniques pour cet item partagé. Un élément strictement privé reste local au module ; un test unitaire attaché peut alors utiliser `super::PrivateItem`.
Les autres crates consomment l'API publique du crate propriétaire via `owner_crate::Item` plutôt que ses chemins internes. Les glob imports SHOULD être évités. Un alias de réexport ne doit pas servir à masquer durablement une collision de noms dans l'API ; lorsqu'un nom fait partie du contrat public, le propriétaire SHOULD choisir un nom canonique non ambigu.
PGR ne généralise pas l'interdiction KSP plus stricte de tous les `use` non-traits : cette convention appartient à un profil ou projet plus spécifique tant qu'elle n'est pas nécessaire au contrat Rust général.
@@ -194,6 +196,6 @@ Un éventuel wrapper d'orchestration MAY appeler plusieurs audits, mais chaque a
Les deux audits de style disponibles sont documentés dans [`../validation/002-RUST_STYLE_AUDITS.md`](../validation/002-RUST_STYLE_AUDITS.md). Ils restent optionnels et prennent un corpus Rust explicitement sélectionné.
[`../validation/003-RUST_EXPORT_COMPLETENESS.md`](../validation/003-RUST_EXPORT_COMPLETENESS.md) documente séparément un audit facultatif de façade crate-root. Lorsqu'un projet choisit ce contrat strict, les éléments `pub` des modules fichiers et les éléments `pub(crate)` clairement partagés hors de leur module SHOULD être réexportés explicitement au crate-root ; lorsqu'un tel réexport existe, les chemins internes SHOULD employer `crate::Item` plutôt que `crate::module::Item`.
[`../validation/003-RUST_EXPORT_COMPLETENESS.md`](../validation/003-RUST_EXPORT_COMPLETENESS.md) documente séparément l'audit facultatif qui matérialise ce contrat strict. `RUST-API-201` vérifie la présence de la façade pour les items partagés ; `RUST-IMPORT-201` vérifie que les références intra-crate détectées passent réellement par `crate::Item`, y compris dans le module propriétaire, et pas seulement qu'un chemin interne long a été évité.
Cet audit de façade ne rend pas la règle obligatoire pour tous les projets Rust et ne remplace pas le compilateur. Le dépôt PGR lui-même ne contient pas de workspace Rust sur lequel une telle gate fournirait une preuve runtime ou Cargo réelle.
Cet audit ne rend pas le contrat strict obligatoire pour tous les projets Rust et ne remplace pas le compilateur. Il contrôle au contraire une convention architecturale que `rustc` et Clippy ne possèdent pas. Le dépôt PGR lui-même ne contient pas de workspace Rust sur lequel une telle gate fournirait une preuve runtime ou Cargo réelle.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/studies/001-KSP_GAMES_RECONCILIATION.md -->
<!-- version: 2 -->
<!-- version: 3 -->
# Étude de réconciliation PGR / KSP / sasedev-games
@@ -137,7 +137,9 @@ Décision : classer ce contrôle `COMMON`, mais ne pas copier le script tel quel
La relecture du script historique révèle une limite importante : sa découverte `crates/*/Cargo.toml` couvre les `22` crates KSP placées directement sous `crates/`, mais aucune des `23` crates Games imbriquées sous `crates/apps`, `crates/common`, `crates/engines`, etc. Le code des deux scripts est bien commun, mais le résultat historique Games `0 candidate(s)` n'était pas une preuve d'audit des crates réelles.
La variante PGR corrige la découverte récursive et réduit le scope aux deux propriétés effectivement possédées par `510-RUST_CARGO.md` : complétude de la façade crate-root et canonicalisation d'un chemin interne lorsqu'un réexport explicite existe. Avec cette variante, KSP donne `0 candidate(s)` sur `22` crates et Games `2 candidate(s)` sur `23` crates. Ces deux signaux Games ne sont pas des non-conformités rétroactives ; ils deviennent des points de décision lors d'une future adoption PGR.
La variante PGR corrige d'abord la découverte récursive. La revue opérateur de `alpha.5` montre ensuite que limiter la canonicalisation à `crate::module::Item` est insuffisant : les corpus contiennent de nombreuses références nues à des items pourtant réexportés, notamment dans leur module propriétaire, et ni `rustc` ni Clippy ne possèdent la convention « référence partagée = `crate::Item` partout dans la crate ». `alpha.5` est donc conservé `FAIL` et `alpha.5.fix.1` étend `RUST-IMPORT-201` à ce contrat réel.
La mesure corrigée, avec découverte récursive et séparation des cibles Cargo, donne `1525` lignes candidates `RUST-IMPORT-201` dans KSP sur `22` packages et `181` lignes candidates dans Games sur `23` packages (`2 RUST-API-201`, `179 RUST-IMPORT-201`). Ces volumes ne sont pas interprétés comme des défauts rétroactifs : ils montrent précisément pourquoi une adoption PGR d'un projet existant doit commencer en `--report-only`, puis migrer progressivement la convention avant d'en faire une gate bloquante.
Le grand `audit_rust_general_rules.py` des deux corpus reste `DEFER` comme source d'extraction, pas comme script à importer. Il mélange plusieurs responsabilités : headers, rustdoc, imports, structure de façade, blancs, constantes et règles locales ; Games y ajoute une exception FFI Android étroite. PGR doit continuer à extraire des audits monotâches seulement lorsqu'une propriété commune apporte une preuve non redondante.

View File

@@ -1,51 +1,111 @@
<!-- file: docs/validation/003-RUST_EXPORT_COMPLETENESS.md -->
<!-- version: 1 -->
<!-- version: 2 -->
# Audit facultatif de façade Rust et export completeness
# Audit facultatif de façade Rust et canonicalisation intra-crate
## Responsabilité
`scripts/audit_rust_export_completeness.py` contrôle un contrat Rust optionnel plus strict que les garanties du compilateur : les éléments publics durables d'un module fichier doivent être exposés par une façade explicite au crate-root, et le code de la même crate doit employer cette façade lorsqu'un réexport correspondant existe.
`scripts/audit_rust_export_completeness.py` contrôle un contrat Rust optionnel plus strict que les garanties du compilateur et de Clippy : une crate qui adopte cette convention possède une façade crate-root explicite pour ses items partagés, et toute consommation intra-crate d'un item possédant cette identité canonique passe par `crate::Item`.
Le propriétaire normatif reste [`../rules/510-RUST_CARGO.md`](../rules/510-RUST_CARGO.md). Le script ne définit pas une architecture à lui seul et ne doit être adopté que par un projet qui choisit ce contrat de façade.
Le propriétaire normatif reste [`../rules/510-RUST_CARGO.md`](../rules/510-RUST_CARGO.md). Le script ne définit pas une architecture à lui seul ; il matérialise uniquement le contrat strict lorsqu'un projet choisit de l'adopter.
L'audit émet seulement deux familles de diagnostics :
L'audit émet deux familles de diagnostics :
```text
RUST-API-201
élément `pub`, ou élément `pub(crate)` clairement consommé hors de son module fichier,
sans réexport explicite correspondant au crate-root
item `pub` d'un module fichier sans réexport explicite au crate-root,
ou item `pub(crate)` clairement consommé hors de son module sans réexport crate-root
RUST-IMPORT-201
chemin interne long `crate::module::Item` alors que le crate-root expose déjà `crate::Item`
référence intra-crate non canonique à un item possédant une identité crate-root ;
la forme attendue est `crate::Item`
```
Il ne vérifie pas les règles générales de style, les lignes vides, l'ordre des constantes, la rustdoc, les lints Cargo/Clippy, la sûreté FFI ou le contenu métier.
`RUST-IMPORT-201` ne se limite donc pas à `crate::module::Item`. Il couvre notamment :
```text
Item
self::Item
super::Item
module::Item
crate::module::Item
```
lorsque ces formes désignent un item partagé déjà exposé comme `crate::Item`.
La règle s'applique également **dans le module qui déclare l'item**. Par exemple :
```rust
pub struct Runtime;
impl Runtime {
pub fn new() -> Runtime {
return Runtime;
}
}
```
n'est pas canonique après réexport de `Runtime`. Le contrat strict attend :
```rust
pub struct Runtime;
impl crate::Runtime {
pub fn new() -> crate::Runtime {
return crate::Runtime;
}
}
```
Le même principe vaut pour une fonction partagée appelée depuis son module propriétaire : `crate::build()` est canonique, `build()` ne l'est pas.
Les lignes de déclaration de l'item et les lignes de réexport qui construisent la façade sont naturellement exemptées. Un item strictement privé n'appartient pas à ce contrat ; dans un test unitaire attaché à son module parent, `super::PrivateItem` reste une forme locale légitime.
## Pourquoi Cargo/Clippy ne suffisent pas
Le compilateur vérifie la résolution et la visibilité des noms ; il ne possède pas la convention « tout accès partagé passe par `crate::Item` ». Clippy peut signaler certains imports ou réexports inutilisés, mais une seule consommation correcte de `crate::Item` peut suffire à rendre le réexport utilisé tandis que d'autres références continuent d'employer un nom nu ou un chemin interne.
L'audit fournit donc une preuve distincte : **uniformité de la façade et des chemins intra-crate**, pas simple compilabilité.
## Cibles Cargo et graphes de modules
La `version: 2` ne confond plus automatiquement tous les fichiers d'un package Cargo dans une seule crate. Un package peut contenir simultanément une bibliothèque `src/lib.rs` et un binaire `src/main.rs` ; ces deux cibles sont compilées comme des crates distinctes et `crate::Item` n'y désigne pas la même racine.
L'audit découvre les packages récursivement puis traite séparément les cibles standard/explicites `lib` et `bin`. Pour chaque cible, il suit les modules fichiers déclarés par `mod name;` et les modules `#[path = "..."]`, ce qui couvre notamment les `unit_tests/` attachés utilisés dans le corpus KSP/Games sans englober les tests d'intégration Cargo sous `tests/`, qui sont des crates externes.
Cette distinction évite de considérer à tort `owner_crate::Item` utilisé dans un binaire comme une violation de la bibliothèque du même package.
## Provenance et correction du corpus
KSP `0.3.16` et Games `0.3.5` contiennent un `audit_rust_export_completeness.py` dont le corps est identique à l'exception de sa révision de fichier. Cette convergence justifie l'extraction du concept, mais pas une copie littérale.
KSP `0.3.16` et Games `0.3.5` contiennent un `audit_rust_export_completeness.py` historiquement identique à l'exception de sa révision. Cette convergence justifie l'extraction du concept, mais pas une copie littérale.
Le script historique découvre uniquement `crates/*/Cargo.toml`. Cette hypothèse couvre les `22` crates KSP situées directement sous `crates/`, mais elle ne couvre aucune des `23` crates Games organisées sous des sous-familles telles que `crates/apps/`, `crates/common/` ou `crates/engines/`. Un résultat historique `0 candidate(s)` dans Games ne constitue donc pas une preuve que ses crates avaient réellement été inspectées par cet audit.
Deux limites historiques ont été identifiées :
La variante PGR corrige ce point en découvrant récursivement les packages Cargo possédant `src/lib.rs` ou `src/main.rs`, tout en ignorant les arbres générés usuels. Elle gère également correctement un module fichier `mod.rs` et les réexports explicites simples, groupés ou aliasés.
1. la découverte `crates/*/Cargo.toml` couvrait les `22` packages KSP directement sous `crates/`, mais aucune des `23` crates Games imbriquées sous `crates/apps/`, `crates/common/`, `crates/engines/`, etc. ;
2. la canonicalisation détectait surtout `crate::module::Item` et quelques cas particuliers de tests, mais pas les références ordinaires par nom nu dans les modules de production, y compris le module propriétaire.
La réconciliation générateur réalisée pendant `0.0.5-alpha.5` donne avec la nouvelle découverte :
La première version PGR de `alpha.5` corrigeait seulement la découverte récursive et le chemin interne long. La revue opérateur après sa gate a explicitement révélé la seconde lacune ; `alpha.5` est donc conservé `FAIL` et `alpha.5.fix.1` étend `RUST-IMPORT-201` au contrat réellement attendu.
## Mesure de réconciliation après le fix
Avec la `version: 2`, en mode `--report-only --summary-only`, les baselines autoritaires de la réconciliation donnent :
```text
KSP 0.3.16 22 crate(s) 0 candidate(s)
Games 0.3.5 23 crate(s) 2 candidate(s)
KSP 0.3.16
22 package(s)
1525 candidate(s)
RUST-IMPORT-201: 1525
Games 0.3.5
23 package(s)
181 candidate(s)
RUST-API-201: 2
RUST-IMPORT-201: 179
```
Les deux candidats Games sont des constantes `pub(crate)` du smoke WebTransport navigateur consommées depuis d'autres modules sans façade crate-root. Games n'étant pas encore gouverné par PGR, ces signaux ne sont pas qualifiés de défauts rétroactifs ; ils montrent simplement ce qu'une adoption future de ce contrat devrait décider ou corriger.
Ces nombres représentent des **lignes candidates**, pas des déclarations distinctes. Ils sont beaucoup plus élevés que la première mesure `0 / 2` parce que le contrat corrige précisément le blind spot signalé : les références nues déjà répandues dans les modules propriétaires et consumers deviennent enfin visibles.
## Ce qui n'est pas repris du script historique
PGR n'importe pas les diagnostics historiques liés aux tests unitaires séparés (`super::...`, références non qualifiées) : leur validité dépend du layout et du contrat local des modules de test, et la baseline Games ne prouve pas une convergence opérationnelle sur ce point.
PGR n'importe pas non plus le contrôle d'un `crate::Symbol` non résolu. Le compilateur Rust est déjà propriétaire de la résolution de noms ; dupliquer cette erreur dans un audit Python n'apporte pas une preuve supplémentaire proportionnée.
L'audit PGR reste donc monotâche : façade crate-root et canonicalisation des chemins pour les seuls symboles qu'il sait relier explicitement.
KSP et Games n'étant pas encore gouvernés par cette version de PGR, ces résultats ne sont pas qualifiés de défauts rétroactifs et ne déclenchent aucune migration dans `0.0.5`. Ils constituent un inventaire d'adoption future. Pour un projet existant, l'usage initial en `--report-only` est donc particulièrement important.
## Adoption
@@ -64,20 +124,22 @@ Commande canonique :
python3 -B scripts/audit_rust_export_completeness.py --root .
```
Le code de sortie est `0` lorsque le corpus est propre, `1` lorsqu'au moins un candidat est trouvé et `2` lorsque la racine est invalide, illisible ou ne contient aucune crate Rust détectable. `--report-only` permet une phase d'inventaire sans bloquer la gate ; `--summary-only` réduit la sortie aux comptes.
Le code de sortie est `0` lorsque le corpus est propre, `1` lorsqu'au moins un candidat est trouvé et `2` lorsque la racine est invalide, illisible ou ne contient aucun package Rust détectable. `--report-only` permet un inventaire sans bloquer la gate ; `--summary-only` réduit la sortie aux comptes.
Une adoption progressive peut donc commencer par :
Une adoption progressive d'un projet existant devrait commencer par :
```bash
python3 -B scripts/audit_rust_export_completeness.py --root . --report-only
python3 -B scripts/audit_rust_export_completeness.py --root . --report-only --summary-only
```
puis devenir bloquante une fois les exceptions ou écarts locaux explicitement résolus.
puis inventorier/corriger les familles d'écarts avant de rendre l'audit bloquant. Une nouvelle crate créée sous le contrat peut au contraire l'activer immédiatement.
## Limites
L'analyse est lexicale et non un parseur Rust complet. Elle vise les déclarations `pub`/`pub(crate)` au niveau racine de modules fichiers et les réexports explicites du crate-root. Les macros générant des items, attributs élaborés, modules inline complexes, `#[path]` non standard et formes de réexport imbriquées au-delà du contrat testé peuvent nécessiter une revue humaine ou les outils Rust du projet.
L'analyse reste lexicale et ne remplace pas un parseur Rust complet. Elle vise les déclarations module-level `pub`/`pub(crate)`, les réexports explicites et les références dans le graphe de modules d'une cible. Les macros générant des items, modules inline complexes, `include!`, formes de `#[path]` atypiques et réexports élaborés peuvent nécessiter une revue humaine.
Un élément `pub(crate)` n'est exigé à la façade que lorsqu'une consommation externe au fichier est clairement détectée par un chemin `crate::module::Item` ou `crate::Item`. L'absence de diagnostic ne prouve donc pas toute la sémantique de visibilité de la crate.
Pour réduire les faux positifs, une fonction partagée est reconnue comme référence lorsqu'elle apparaît sous une forme d'appel directe ; l'usage d'une fonction comme valeur de premier ordre peut donc échapper à cette version. Les chemins explicitement qualifiés par une crate externe et les appels de méthodes `.name()` ne sont pas assimilés à un item local de même nom.
L'audit ne remplace ni `cargo check`, ni Clippy, ni rustdoc. Il complète uniquement une convention architecturale explicitement choisie.
Un item `pub(crate)` qui n'est pas clairement consommé hors de son module n'est pas forcé au crate-root par `RUST-API-201`; sa visibilité peut nécessiter une revue séparée. Dès qu'un réexport crate-root existe, ses références détectées sont toutefois soumises à `RUST-IMPORT-201`, y compris dans son module de déclaration.
L'audit ne remplace ni `cargo check`, ni Clippy, ni rustdoc. Il complète ces outils avec une convention architecturale qu'ils ne possèdent pas.

View File

@@ -0,0 +1,110 @@
<!-- file: history/0.0.5/alpha.5.fix.1.md -->
<!-- version: 1 -->
# History 0.0.5-alpha.5.fix.1 — canonicalisation réelle des références crate-root
## Statut
PASS
La gate opérateur de `0.0.5-alpha.5.fix.1` exécutée le `2026-09-25` valide la correction du contrat Rust de façade : un item partagé exposé au crate-root doit être référencé via `crate::Item` dans toute la crate, y compris depuis son module de déclaration. Les tests ciblés/globaux, audits Markdown, contrôle structurel et contrôles Git sont propres ; le scope indexé correspond exactement au fix.
## Baseline et version
La tranche part du commit opérateur de `0.0.5-alpha.5` :
```text
f03f864 0.0.5-alpha.5
```
Le push vers `origin/master` a réussi avant l'application de `alpha.5.fix.1`.
La version contrôlée après application du fix est :
```text
0.0.5-alpha.5.fix.1
```
`history/0.0.5/alpha.5.md` est présent avec statut `FAIL`, `deltas/0.0.5/alpha.5.fix.1.md` est présent et `history/0.0.5/alpha.5.fix.1.md` était absent pendant la gate, conformément au workflow.
## Validation opérateur observée
L'opérateur a fourni les sorties du `2026-09-25` depuis `~/Projects/pgr`, à `00:36:32`–`00:37:30`.
Les contrôles de révision ont confirmé :
```text
scripts/audit_rust_export_completeness.py version 2
tests/test_rust_export_completeness.py version 2
docs/validation/003-RUST_EXPORT_COMPLETENESS.md version 2
docs/rules/510-RUST_CARGO.md version 4
```
La suite ciblée a exécuté `18` tests, tous réussis :
```text
Ran 18 tests in 0.161s
OK
```
La suite Python globale a exécuté `37` tests, tous réussis :
```text
Ran 37 tests in 0.514s
OK
```
Les trois audits documentaires affichent :
```text
Markdown header audit: clean (142 file(s))
Markdown table audit: clean (6 table(s), 142 file(s))
Markdown link audit: clean (137 local link(s), 142 file(s))
```
Le contrôle structurel spécifique affiche :
```text
0.0.5 alpha.5.fix.1 canonical Rust path audit: clean (owner module, cross-module, attached tests, distinct Cargo targets)
```
Il confirme notamment que l'audit possède les cibles Cargo distinctes, suit les modules associés et couvre `RUST-API-201` ainsi que `RUST-IMPORT-201` pour les références intra-crate non canoniques. La documentation normative impose `crate::Item` y compris depuis le module propriétaire.
`git diff --check` et `git diff --cached --check` n'ont produit aucun diagnostic.
Le scope Git observé avant ajout du présent history est exactement :
```text
M VERSION
A deltas/0.0.5/alpha.5.fix.1.md
M docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md
M docs/rules/510-RUST_CARGO.md
M docs/studies/001-KSP_GAMES_RECONCILIATION.md
M docs/validation/003-RUST_EXPORT_COMPLETENESS.md
M scripts/audit_rust_export_completeness.py
M tests/test_rust_export_completeness.py
```
La transcription du terminal intercale quelques fragments du heredoc de contrôle avec la sortie de `unittest`. Les dix-huit tests ciblés, les trente-sept tests globaux, les trois audits Markdown, le contrôle structurel final et le scope Git restent identifiables et ne signalent aucun échec.
## Résultat
Le blind spot fonctionnel de `alpha.5` est fermé. L'audit PGR distingue les targets Cargo et impose la façade crate-root sur les références intra-crate détectables sans prétendre remplacer un parseur Rust.
Les mesures KSP/Games consignées par le fix restent des inventaires d'adoption future en `--report-only`, pas des non-conformités rétroactives.
Aucun nouveau fix n'est requis.
## Transition
Appliquer le présent history puis committer et pousser `0.0.5-alpha.5.fix.1`.
La tranche suivante est :
```text
0.0.5-alpha.6
```
Elle doit rester limitée à l'activation de la famille `700 — frameworks` et au premier profil Tauri réellement commun aux baselines KSP/Games. Elle ne doit pas créer prématurément un skeleton Tauri ni imposer Vite, WASM, npm, des ports, fenêtres, plugins ou layouts applicatifs particuliers.

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# file: scripts/audit_rust_export_completeness.py
# version: 1
# version: 2
"""Audit crate-root façade completeness and canonical same-crate paths."""
"""Audit Rust crate-root façade completeness and canonical same-crate references."""
from __future__ import annotations
@@ -11,6 +11,7 @@ import dataclasses
import pathlib
import re
import sys
import tomllib
from _rust_audit_common import line_depths, mask_source
@@ -28,7 +29,7 @@ _IGNORED_DIRECTORY_NAMES = frozenset({
@dataclasses.dataclass(frozen=True)
class Candidate:
"""One façade or same-crate path violation candidate."""
"""One façade or canonical-reference violation candidate."""
code: str
path: str
@@ -46,6 +47,24 @@ class Declaration:
kind: str
path: pathlib.Path
line: int
column: int
@dataclasses.dataclass(frozen=True)
class Target:
"""One Rust crate target and the module files reachable from its root."""
package: pathlib.Path
root: pathlib.Path
modules: tuple[tuple[pathlib.Path, str], ...]
@dataclasses.dataclass(frozen=True)
class CanonicalItem:
"""One visible item with its canonical crate-root name."""
declaration: Declaration
canonical_name: str
def _is_ignored(path: pathlib.Path, root: pathlib.Path) -> bool:
@@ -66,35 +85,147 @@ def crate_roots(root: pathlib.Path) -> list[pathlib.Path]:
if _is_ignored(manifest, root):
continue
crate = manifest.parent
if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file():
if (crate / "src/lib.rs").is_file() or (crate / "src/main.rs").is_file() or _explicit_target_paths(crate):
crates.add(crate)
return sorted(crates)
def crate_root_source(crate: pathlib.Path) -> pathlib.Path | None:
"""Return the library or binary crate-root source when present."""
def _explicit_target_paths(crate: pathlib.Path) -> tuple[pathlib.Path, ...]:
"""Return existing explicit lib/bin target paths declared in Cargo.toml."""
manifest = crate / "Cargo.toml"
if not manifest.is_file():
return ()
try:
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return ()
paths: set[pathlib.Path] = set()
lib = data.get("lib")
if isinstance(lib, dict) and isinstance(lib.get("path"), str):
candidate = crate / lib["path"]
if candidate.is_file():
paths.add(candidate)
bins = data.get("bin", [])
if isinstance(bins, list):
for entry in bins:
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
candidate = crate / entry["path"]
if candidate.is_file():
paths.add(candidate)
return tuple(sorted(paths))
def target_roots(crate: pathlib.Path) -> list[pathlib.Path]:
"""Return standard and explicitly configured Rust crate target roots."""
roots: set[pathlib.Path] = set(_explicit_target_paths(crate))
manifest = crate / "Cargo.toml"
package: dict[str, object] = {}
if manifest.is_file():
try:
parsed = tomllib.loads(manifest.read_text(encoding="utf-8"))
raw_package = parsed.get("package", {})
if isinstance(raw_package, dict):
package = raw_package
except (OSError, tomllib.TOMLDecodeError):
package = {}
library = crate / "src/lib.rs"
if library.is_file():
return library
binary = crate / "src/main.rs"
if binary.is_file():
return binary
roots.add(library)
if package.get("autobins", True) is not False:
main = crate / "src/main.rs"
if main.is_file():
roots.add(main)
bin_dir = crate / "src/bin"
if bin_dir.is_dir():
roots.update(path for path in bin_dir.glob("*.rs") if path.is_file())
roots.update(path for path in bin_dir.glob("*/main.rs") if path.is_file())
return sorted(roots)
def _standard_child_module_path(current: pathlib.Path, target_root: pathlib.Path, name: str) -> pathlib.Path | None:
"""Resolve a standard `mod name;` file relative to one source module."""
if current == target_root or current.name == "mod.rs":
base = current.parent
else:
base = current.parent / current.stem
direct = base / f"{name}.rs"
nested = base / name / "mod.rs"
if direct.is_file():
return direct
if nested.is_file():
return nested
return None
def module_path(crate: pathlib.Path, path: pathlib.Path) -> str:
"""Return the file-based module path represented by one Rust source file."""
def _module_children(path: pathlib.Path, target_root: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
"""Return top-level external module declarations from one Rust source file."""
relative = path.relative_to(crate / "src").with_suffix("")
parts = list(relative.parts)
if parts and parts[-1] == "mod":
parts.pop()
return "::".join(parts)
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
masked, _ = mask_source(text)
masked_lines = masked.splitlines()
depths = line_depths(masked)
module_pattern = re.compile(r"^\s*(?:(?:pub|pub\([^)]*\))\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;")
path_pattern = re.compile(r'^\s*#\[path\s*=\s*"([^"]+)"\]\s*$')
found: list[tuple[pathlib.Path, str]] = []
for index, masked_line in enumerate(masked_lines):
if index >= len(depths) or depths[index] != 0:
continue
match = module_pattern.match(masked_line)
if match is None:
continue
module_name = match.group(1)
explicit: pathlib.Path | None = None
probe = index - 1
while probe >= 0 and lines[probe].lstrip().startswith("#["):
path_match = path_pattern.match(lines[probe])
if path_match is not None:
candidate = (path.parent / path_match.group(1)).resolve()
if candidate.is_file():
explicit = candidate
break
probe -= 1
child = explicit or _standard_child_module_path(path, target_root, module_name)
if child is not None:
found.append((child.resolve(), module_name))
return found
def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Declaration]:
"""Return root-level public or crate-public declarations from one module file."""
def target_modules(crate: pathlib.Path, target_root: pathlib.Path) -> dict[pathlib.Path, str]:
"""Return module files reachable from one target root and their logical paths."""
root = target_root.resolve()
modules: dict[pathlib.Path, str] = {root: ""}
pending: list[pathlib.Path] = [root]
while pending:
current = pending.pop()
parent_module = modules[current]
for child, name in _module_children(current, root):
if _is_ignored(child, crate) or child in modules:
continue
module = f"{parent_module}::{name}" if parent_module else name
modules[child] = module
pending.append(child)
return modules
def targets(crate: pathlib.Path) -> list[Target]:
"""Return independently compiled crate targets for one Cargo package."""
found: list[Target] = []
for root in target_roots(crate):
modules = target_modules(crate, root)
found.append(Target(crate, root.resolve(), tuple(sorted(modules.items(), key=lambda item: item[0].as_posix()))))
return found
def declaration_candidates(path: pathlib.Path, module: str) -> list[Declaration]:
"""Return top-level public or crate-public declarations from one module file."""
text = path.read_text(encoding="utf-8")
lines = text.splitlines()
@@ -115,12 +246,13 @@ def declaration_candidates(crate: pathlib.Path, path: pathlib.Path) -> list[Decl
continue
found.append(
Declaration(
module=module_path(crate, path),
module=module,
name=match.group(3),
visibility=match.group(1),
kind=match.group(2),
path=path,
line=line_number,
column=match.start(3),
)
)
return found
@@ -143,15 +275,11 @@ def _parse_export_item(module: str, item: str) -> tuple[tuple[str, str], str] |
return (module, name), alias
def root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]:
def root_exports(root_source: pathlib.Path) -> dict[tuple[str, str], str]:
"""Return explicit crate-root re-exports keyed by source module and symbol."""
crate_root = crate_root_source(crate)
exports: dict[tuple[str, str], str] = {}
if crate_root is None:
return exports
text = crate_root.read_text(encoding="utf-8")
text = root_source.read_text(encoding="utf-8")
pattern = re.compile(
r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::(.*?);",
re.MULTILINE | re.DOTALL,
@@ -177,75 +305,171 @@ def root_exports(crate: pathlib.Path) -> dict[tuple[str, str], str]:
return exports
def externally_used(crate: pathlib.Path, declaration: Declaration) -> bool:
"""Return whether a crate-public item is clearly used outside its module file."""
def _reexport_lines(root_source: pathlib.Path) -> set[int]:
"""Return crate-root line numbers occupied by explicit re-export statements."""
text = root_source.read_text(encoding="utf-8")
pattern = re.compile(
r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::.*?;",
re.MULTILINE | re.DOTALL,
)
lines: set[int] = set()
for match in pattern.finditer(text):
start = text.count("\n", 0, match.start()) + 1
end = text.count("\n", 0, match.end()) + 1
lines.update(range(start, end + 1))
return lines
def _target_declarations(target: Target) -> list[Declaration]:
"""Return visible module-level declarations for one crate target."""
return [
declaration
for path, module in target.modules
for declaration in declaration_candidates(path, module)
]
def _external_usage(target: Target, declaration: Declaration) -> bool:
"""Return whether a crate-public item is clearly referenced from another module file."""
if not declaration.module:
return True
direct = f"crate::{declaration.module}::{declaration.name}"
root_direct = f"crate::{declaration.name}"
for path in sorted(crate.rglob("*.rs")):
if _is_ignored(path, crate) or path.resolve() == declaration.path.resolve():
super_direct = f"super::{declaration.name}"
for path, _ in target.modules:
if path.resolve() == declaration.path.resolve():
continue
masked, _ = mask_source(path.read_text(encoding="utf-8"))
if direct in masked or root_direct in masked:
if direct in masked or root_direct in masked or super_direct in masked:
return True
return False
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
"""Audit one Rust package for the selected strict façade contract."""
def _declaration_lookup(declarations: list[Declaration]) -> dict[tuple[pathlib.Path, int, str], Declaration]:
"""Index declaration-name occurrences that must not be treated as references."""
crate_root = crate_root_source(crate)
if crate_root is None:
return {(item.path.resolve(), item.line, item.name): item for item in declarations}
def _canonical_reference_candidates(
workspace: pathlib.Path,
target: Target,
declarations: list[Declaration],
exports: dict[tuple[str, str], str],
) -> list[Candidate]:
"""Return non-canonical references to items that have a crate-root identity."""
items: list[CanonicalItem] = []
for declaration in declarations:
if declaration.path.resolve() == target.root and not declaration.module:
items.append(CanonicalItem(declaration, declaration.name))
continue
alias = exports.get((declaration.module, declaration.name))
if alias is not None:
items.append(CanonicalItem(declaration, alias))
by_token: dict[str, list[CanonicalItem]] = {}
for item in items:
by_token.setdefault(item.declaration.name, []).append(item)
if item.canonical_name != item.declaration.name:
by_token.setdefault(item.canonical_name, []).append(item)
if not by_token:
return []
exports = root_exports(crate)
token_pattern = re.compile(r"\b(?:" + "|".join(re.escape(name) for name in sorted(by_token, key=len, reverse=True)) + r")\b")
declaration_lookup = _declaration_lookup(declarations)
root_reexport_lines = _reexport_lines(target.root)
internal_roots = {segment for _, module in target.modules if module for segment in module.split("::")}
candidates: list[Candidate] = []
for path in sorted((crate / "src").rglob("*.rs")):
if path == crate_root or _is_ignored(path, crate):
continue
for declaration in declaration_candidates(crate, path):
required = declaration.visibility == "pub" or externally_used(crate, declaration)
if required and (declaration.module, declaration.name) not in exports:
reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module"
candidates.append(
Candidate(
code="RUST-API-201",
path=path.relative_to(workspace).as_posix(),
line=declaration.line,
message=f"{reason} `{declaration.name}` requires an explicit crate-root re-export",
)
)
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 path == crate_root or _is_ignored(path, crate):
continue
for path, _ in target.modules:
relative = path.relative_to(workspace).as_posix()
masked, _ = mask_source(path.read_text(encoding="utf-8"))
for line_number, line in enumerate(masked.splitlines(), 1):
for match in long_path.finditer(line):
alias = exports.get((match.group(1), match.group(2)))
if alias is None:
for match in token_pattern.finditer(line):
token = match.group(0)
for item in by_token[token]:
declaration = item.declaration
if declaration.kind == "fn" and re.match(r"\s*(?:\(|::<)", line[match.end():]) is None:
continue
prefix = line[:match.start()]
if re.search(r"(?:const|static|type|struct|enum|trait|union|fn)\s+$", prefix) is not None:
continue
if (path.resolve(), line_number, token) in declaration_lookup and token == declaration.name:
continue
if path.resolve() == target.root and line_number in root_reexport_lines:
continue
if token == item.canonical_name and re.search(r"\bcrate::\s*$", prefix) is not None:
continue
if match.start() > 0 and line[match.start() - 1] == ".":
continue
qualified = re.search(
r"([A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)*)::\s*$",
prefix,
)
if qualified is not None:
first = qualified.group(1).split("::", 1)[0]
if first not in internal_roots and first not in {"crate", "self", "super"}:
continue
candidates.append(
Candidate(
code="RUST-IMPORT-201",
path=relative,
line=line_number,
message=(
f"long same-crate path `crate::{match.group(1)}::{match.group(2)}` "
f"must use crate-root `crate::{alias}`"
f"non-canonical same-crate reference `{token}` for shared "
f"{declaration.visibility} `{declaration.name}`; use `crate::{item.canonical_name}`"
),
)
)
return candidates
def audit_target(workspace: pathlib.Path, target: Target) -> list[Candidate]:
"""Audit one independently compiled Rust crate target."""
declarations = _target_declarations(target)
exports = root_exports(target.root)
candidates: list[Candidate] = []
for declaration in declarations:
if declaration.path.resolve() == target.root and not declaration.module:
continue
required = declaration.visibility == "pub" or _external_usage(target, declaration)
if required and (declaration.module, declaration.name) not in exports:
reason = "public item" if declaration.visibility == "pub" else "crate-public item used outside its module"
candidates.append(
Candidate(
code="RUST-API-201",
path=declaration.path.relative_to(workspace).as_posix(),
line=declaration.line,
message=f"{reason} `{declaration.name}` requires an explicit crate-root re-export",
)
)
candidates.extend(_canonical_reference_candidates(workspace, target, declarations, exports))
return candidates
def audit_crate(workspace: pathlib.Path, crate: pathlib.Path) -> list[Candidate]:
"""Audit every independently compiled target in one Cargo package."""
return [candidate for target in targets(crate) for candidate in audit_target(workspace, target)]
def audit_workspace(root: pathlib.Path) -> tuple[list[pathlib.Path], list[Candidate]]:
"""Return discovered crates and all façade candidates below one project root."""
"""Return discovered packages and all façade candidates below one project root."""
crates = crate_roots(root)
candidates = [candidate for crate in crates for candidate in audit_crate(root, crate)]
candidates = list({candidate for crate in crates for candidate in audit_crate(root, crate)})
candidates.sort(key=lambda candidate: (candidate.code, candidate.path, candidate.line, candidate.message))
return crates, candidates
@@ -264,27 +488,21 @@ def main() -> int:
sys.stderr.write(f"Rust export completeness audit: invalid root: {workspace}\n")
return 2
try:
crates, candidates = audit_workspace(workspace)
except (OSError, UnicodeError) as error:
sys.stderr.write(f"Rust export completeness audit: unable to read selected workspace: {error}\n")
return 2
if not crates:
sys.stderr.write("Rust export completeness audit: no Rust crate root discovered\n")
sys.stderr.write(f"Rust export completeness audit: no Rust package found below {workspace}\n")
return 2
counts: dict[str, int] = {}
for candidate in candidates:
counts[candidate.code] = counts.get(candidate.code, 0) + 1
sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s) across {len(crates)} crate(s)\n")
sys.stdout.write(f"Rust export completeness audit: {len(candidates)} candidate(s) across {len(crates)} package(s)\n")
for code in sorted(counts):
sys.stdout.write(f"{code}: {counts[code]}\n")
if not arguments.summary_only:
for candidate in candidates:
sys.stdout.write(f"{candidate.code} {candidate.path}:{candidate.line}: {candidate.message}\n")
return 0 if arguments.report_only or not candidates else 1

View File

@@ -1,5 +1,5 @@
# file: tests/test_rust_export_completeness.py
# version: 1
# version: 2
"""Regression tests for the optional Rust crate-root façade audit."""
@@ -105,6 +105,133 @@ class RustExportCompletenessTests(unittest.TestCase):
)
self.assertEqual(self.candidates(), [])
def test_bare_reference_in_declaration_module_is_rejected(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\npub use self::api::Thing;\n",
{
"src/api.rs": (
"pub struct Thing;\n"
"impl Thing {\n"
" pub fn make() -> Thing { return Thing; }\n"
"}\n"
),
},
)
candidates = [candidate for candidate in self.candidates() if candidate.code == "RUST-IMPORT-201"]
self.assertEqual(len(candidates), 2)
self.assertTrue(all("crate::Thing" in candidate.message for candidate in candidates))
def test_crate_root_reference_in_declaration_module_is_clean(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\npub use self::api::Thing;\n",
{
"src/api.rs": (
"pub struct Thing;\n"
"impl crate::Thing {\n"
" pub fn make() -> crate::Thing { return crate::Thing; }\n"
"}\n"
),
},
)
self.assertEqual(self.candidates(), [])
def test_import_then_bare_reference_is_rejected(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\nmod consumer;\npub use self::api::Thing;\n",
{
"src/api.rs": "pub struct Thing;\n",
"src/consumer.rs": "use crate::Thing;\npub(crate) fn make() -> Thing { return Thing; }\n",
},
)
candidates = [candidate for candidate in self.candidates() if candidate.code == "RUST-IMPORT-201"]
self.assertEqual(len(candidates), 1)
def test_self_super_and_internal_module_paths_are_rejected(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\nmod consumer;\npub use self::api::Thing;\n",
{
"src/api.rs": "pub struct Thing;\n",
"src/consumer.rs": (
"pub(crate) fn a() -> crate::api::Thing { return crate::api::Thing; }\n"
"pub(crate) fn b() -> api::Thing { todo!() }\n"
),
},
)
candidates = [candidate for candidate in self.candidates() if candidate.code == "RUST-IMPORT-201"]
self.assertEqual(len(candidates), 2)
def test_attached_unit_test_visible_item_uses_crate_root(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\npub use self::api::Thing;\n",
{
"src/api.rs": (
"pub struct Thing;\n"
"#[cfg(test)]\n"
"#[path = \"../unit_tests/api_tests.rs\"]\n"
"mod tests;\n"
),
"unit_tests/api_tests.rs": "fn make() -> super::Thing { return super::Thing; }\n",
},
)
candidates = [candidate for candidate in self.candidates() if candidate.code == "RUST-IMPORT-201"]
self.assertEqual(len(candidates), 1)
def test_private_parent_item_in_attached_test_is_not_owned_by_this_audit(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\n",
{
"src/api.rs": (
"fn helper() -> u8 { return 1; }\n"
"#[cfg(test)]\n"
"#[path = \"../unit_tests/api_tests.rs\"]\n"
"mod tests;\n"
),
"unit_tests/api_tests.rs": "fn value() -> u8 { return super::helper(); }\n",
},
)
self.assertEqual(self.candidates(), [])
def test_external_qualified_same_named_item_is_not_misclassified(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\nmod consumer;\npub use self::api::Result;\n",
{
"src/api.rs": "pub struct Result;\n",
"src/consumer.rs": "pub(crate) fn value() -> external_crate::Result { todo!() }\n",
},
)
self.assertEqual(self.candidates(), [])
def test_method_with_same_name_as_public_function_is_not_misclassified(self) -> None:
self.crate(
"crates/example-lib",
"mod api;\nmod consumer;\npub use self::api::run;\n",
{
"src/api.rs": "pub fn run() -> u8 { return 1; }\n",
"src/consumer.rs": "pub(crate) fn value(service: &Service) -> u8 { return service.run(); }\nstruct Service;\nimpl Service { fn run(&self) -> u8 { return 2; } }\n",
},
)
self.assertEqual(self.candidates(), [])
def test_lib_and_main_targets_are_not_conflated(self) -> None:
crate = self.crate(
"crates/example-app",
"mod api;\npub use self::api::Thing;\n",
{"src/api.rs": "pub struct Thing;\n"},
)
(crate / "src/main.rs").write_text(
"fn main() { let _ = fixture::Thing; }\n",
encoding="utf-8",
)
self.assertEqual(self.candidates(), [])
def test_cli_status_for_clean_violation_report_only_and_missing_crates(self) -> None:
script = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "audit_rust_export_completeness.py"