From 81c7a720f5f3dc576f4ccb415c155938d709c446 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Fri, 25 Sep 2026 08:14:57 +0200 Subject: [PATCH] 0.0.5-alpha.5.fix.1 --- VERSION | 2 +- deltas/0.0.5/alpha.5.fix.1.md | 217 ++++++++++ docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md | 10 +- docs/rules/510-RUST_CARGO.md | 12 +- docs/studies/001-KSP_GAMES_RECONCILIATION.md | 6 +- .../003-RUST_EXPORT_COMPLETENESS.md | 122 ++++-- history/0.0.5/alpha.5.fix.1.md | 110 +++++ scripts/audit_rust_export_completeness.py | 380 ++++++++++++++---- tests/test_rust_export_completeness.py | 129 +++++- 9 files changed, 864 insertions(+), 124 deletions(-) create mode 100644 deltas/0.0.5/alpha.5.fix.1.md create mode 100644 history/0.0.5/alpha.5.fix.1.md diff --git a/VERSION b/VERSION index 061b399..29adec6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5-alpha.5 +0.0.5-alpha.5.fix.1 diff --git a/deltas/0.0.5/alpha.5.fix.1.md b/deltas/0.0.5/alpha.5.fix.1.md new file mode 100644 index 0000000..01f734b --- /dev/null +++ b/deltas/0.0.5/alpha.5.fix.1.md @@ -0,0 +1,217 @@ + + + +# 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 '' docs/validation/003-RUST_EXPORT_COMPLETENESS.md +grep -Fx '' 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é. diff --git a/docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md b/docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md index 94d674c..7ad8dd9 100644 --- a/docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md +++ b/docs/plans/005-V0_0_5_RECONCILIATION_PLAN.md @@ -1,5 +1,5 @@ - + # 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 diff --git a/docs/rules/510-RUST_CARGO.md b/docs/rules/510-RUST_CARGO.md index 9d06fdf..3dd4b51 100644 --- a/docs/rules/510-RUST_CARGO.md +++ b/docs/rules/510-RUST_CARGO.md @@ -1,5 +1,5 @@ - + # 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. diff --git a/docs/studies/001-KSP_GAMES_RECONCILIATION.md b/docs/studies/001-KSP_GAMES_RECONCILIATION.md index 84de778..d59c202 100644 --- a/docs/studies/001-KSP_GAMES_RECONCILIATION.md +++ b/docs/studies/001-KSP_GAMES_RECONCILIATION.md @@ -1,5 +1,5 @@ - + # É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. diff --git a/docs/validation/003-RUST_EXPORT_COMPLETENESS.md b/docs/validation/003-RUST_EXPORT_COMPLETENESS.md index 6f75fcf..b412217 100644 --- a/docs/validation/003-RUST_EXPORT_COMPLETENESS.md +++ b/docs/validation/003-RUST_EXPORT_COMPLETENESS.md @@ -1,51 +1,111 @@ - + -# 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. diff --git a/history/0.0.5/alpha.5.fix.1.md b/history/0.0.5/alpha.5.fix.1.md new file mode 100644 index 0000000..6c3e23c --- /dev/null +++ b/history/0.0.5/alpha.5.fix.1.md @@ -0,0 +1,110 @@ + + + +# 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. diff --git a/scripts/audit_rust_export_completeness.py b/scripts/audit_rust_export_completeness.py index 0dfef90..71e042f 100644 --- a/scripts/audit_rust_export_completeness.py +++ b/scripts/audit_rust_export_completeness.py @@ -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 diff --git a/tests/test_rust_export_completeness.py b/tests/test_rust_export_completeness.py index a0aeed7..0a48503 100644 --- a/tests/test_rust_export_completeness.py +++ b/tests/test_rust_export_completeness.py @@ -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"