0.0.5-alpha.5.fix.1
This commit is contained in:
217
deltas/0.0.5/alpha.5.fix.1.md
Normal file
217
deltas/0.0.5/alpha.5.fix.1.md
Normal 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é.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
110
history/0.0.5/alpha.5.fix.1.md
Normal file
110
history/0.0.5/alpha.5.fix.1.md
Normal 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.
|
||||
@@ -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:
|
||||
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}`"
|
||||
),
|
||||
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"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
|
||||
|
||||
crates, candidates = audit_workspace(workspace)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user