From 6b6bdcfc71f8e5f09ae1b5d8e38a2d4973f5c7f3 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 27 Sep 2026 22:23:43 +0200 Subject: [PATCH] 0.0.1-alpha.3 --- CHANGELOG.md | 4 +- Cargo.lock | 16 +- Cargo.toml | 11 +- README.md | 4 +- RULES.md | 10 +- VERSION | 2 +- .../src/content_family.rs | 13 + crates/knowledge-domain-lib/src/lib.rs | 18 +- crates/knowledge-domain-lib/src/source.rs | 68 +++ crates/knowledge-domain-lib/src/unit_tests.rs | 20 + crates/knowledge-git-api/Cargo.toml | 15 + crates/knowledge-git-api/src/lib.rs | 57 ++ crates/knowledge-git-api/src/unit_tests.rs | 35 ++ crates/knowledge-sync-api/Cargo.toml | 15 + crates/knowledge-sync-api/src/lib.rs | 57 ++ crates/knowledge-sync-api/src/unit_tests.rs | 45 ++ deltas/0.0.1/alpha.3.md | 232 ++++++++ docs/architecture/001-PROJECT_PROFILE.md | 21 +- .../002-KNOWLEDGE_ARCHITECTURE.md | 16 +- docs/plans/001-V0_0_1_FOUNDATION_PLAN.md | 14 +- docs/validation/001-VALIDATION_MATRIX.md | 21 +- history/0.0.1/alpha.3.md | 157 ++++++ scripts/_rust_audit_common.py | 144 +++++ scripts/audit_rust_blank_lines.py | 83 +++ scripts/audit_rust_export_completeness.py | 510 ++++++++++++++++++ scripts/audit_rust_item_spacing.py | 168 ++++++ scripts/audit_rust_module_order.py | 87 +++ scripts/audit_rust_reexport_blocks.py | 88 +++ scripts/audit_rust_trait_import_blocks.py | 74 +++ 29 files changed, 1973 insertions(+), 32 deletions(-) create mode 100644 crates/knowledge-domain-lib/src/content_family.rs create mode 100644 crates/knowledge-domain-lib/src/source.rs create mode 100644 crates/knowledge-domain-lib/src/unit_tests.rs create mode 100644 crates/knowledge-git-api/Cargo.toml create mode 100644 crates/knowledge-git-api/src/lib.rs create mode 100644 crates/knowledge-git-api/src/unit_tests.rs create mode 100644 crates/knowledge-sync-api/Cargo.toml create mode 100644 crates/knowledge-sync-api/src/lib.rs create mode 100644 crates/knowledge-sync-api/src/unit_tests.rs create mode 100644 deltas/0.0.1/alpha.3.md create mode 100644 history/0.0.1/alpha.3.md create mode 100644 scripts/_rust_audit_common.py create mode 100644 scripts/audit_rust_blank_lines.py create mode 100644 scripts/audit_rust_export_completeness.py create mode 100644 scripts/audit_rust_item_spacing.py create mode 100644 scripts/audit_rust_module_order.py create mode 100644 scripts/audit_rust_reexport_blocks.py create mode 100644 scripts/audit_rust_trait_import_blocks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17fb475..f1e7960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ - + # Changelog SaseDev Knowledge @@ -15,5 +15,7 @@ Ce fichier décrit les changements utiles des releases publiées. Les détails d - adopte la préférence Eclipse UTF-8 versionnée prévue par PGR ; - cadre une surface d'administration protégée propre à chaque application Web sans figer encore ses URL ni son modèle détaillé d'autorisation ; - instancie le workspace Rust/Cargo strict avec une première crate de domaine et une politique explicite de `Cargo.lock` versionné ; +- introduit les premiers contrats provider-neutral de source, Git et synchronisation sans dépendance à une forge ni à PostgreSQL ; +- active les audits Rust PGR de structure et de façade au moment où des API publiques réelles apparaissent ; - conserve comme corpus d'inspiration séparé les archives historiques `actix-auth-app` et `amahil-project`, sans importer leurs fonctionnalités inachevées comme exigences ; - prépare un workspace Rust et une baseline Web composables sans construire prématurément la plateforme complète. diff --git a/Cargo.lock b/Cargo.lock index 7f2f6cc..068c24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,18 @@ version = 4 [[package]] name = "knowledge-domain-lib" -version = "0.0.1-alpha.2" +version = "0.0.1-alpha.3" + +[[package]] +name = "knowledge-git-api" +version = "0.0.1-alpha.3" +dependencies = [ + "knowledge-domain-lib", +] + +[[package]] +name = "knowledge-sync-api" +version = "0.0.1-alpha.3" +dependencies = [ + "knowledge-domain-lib", +] diff --git a/Cargo.toml b/Cargo.toml index 7fb70fd..a5fc25e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,22 @@ # file: Cargo.toml -# version: 1 +# version: 2 [workspace] resolver = "3" -members = ["crates/knowledge-domain-lib"] +members = [ + "crates/knowledge-domain-lib", + "crates/knowledge-git-api", + "crates/knowledge-sync-api", +] [workspace.package] -version = "0.0.1-alpha.2" +version = "0.0.1-alpha.3" edition = "2024" publish = false repository = "https://git.sasedev.com/Sasedev/sasedev-knowledge" [workspace.dependencies] +knowledge-domain-lib = { path = "crates/knowledge-domain-lib" } [workspace.lints.rust] missing_docs = "warn" diff --git a/README.md b/README.md index 7f342d1..ef66d86 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - + # SaseDev Knowledge @@ -31,6 +31,8 @@ PostgreSQL est mutualisable et constitue la baseline retenue pour l'état transv Chaque application Web doit pouvoir exposer sa propre surface d'administration protégée pour son domaine. Les chemins HTTP exacts ne sont pas encore figés. Une éventuelle quatrième application d'administration pourra agréger des fonctions transversales, mais elle ne remplacera pas les contrôles d'autorisation propres aux trois serveurs. +Le workspace Rust matérialise désormais trois frontières passives avant toute implémentation technique : `knowledge-domain-lib` pour les identités de famille/source, `knowledge-git-api` pour le contrat Git générique et `knowledge-sync-api` pour les signaux de synchronisation. Ces deux crates `-api` sont justifiées par des implémentations remplaçables ou multiples ; elles ne dépendent ni d'Actix, ni de Gitea, ni de PostgreSQL. + ## Invariants de fondation - Git est la source autoritaire du contenu versionné. diff --git a/RULES.md b/RULES.md index dea4e0a..327842b 100644 --- a/RULES.md +++ b/RULES.md @@ -1,5 +1,5 @@ - + # Règles SaseDev Knowledge @@ -161,9 +161,13 @@ Ces choix ne sont pas présentés comme des règles PGR universelles. Le préfixe de crate retenu est `knowledge-`. -Les responsabilités candidates sont documentées dans l'architecture. Une crate n'est matérialisée que lorsqu'elle possède une responsabilité réelle. Le suffixe `-api` n'est pas utilisé par anticipation. +Les responsabilités candidates sont documentées dans l'architecture. Une crate n'est matérialisée que lorsqu'elle possède une responsabilité réelle. Le suffixe `-api` n'est utilisé que pour une frontière passive dont des implémentations distinctes/remplaçables sont réellement prévues. -Le profil Rust strict PGR s'appliquera aux crates matérialisées, notamment Rust 2024, `unsafe` interdit par défaut, lints stricts, exports publics maîtrisés et chemins explicites. +`alpha.3` matérialise ainsi `knowledge-git-api` pour la frontière `GitRepository` et `knowledge-sync-api` pour la frontière `RepositoryEventSource`. Ces crates restent passives, sans runtime ni logging propre, et dépendent seulement des types de domaine nécessaires. Les implémentations Git, hooks, polling, webhooks ou forge restent hors de ces crates de contrat. + +Le profil Rust strict PGR s'applique aux crates matérialisées, notamment Rust 2024, `unsafe` interdit par défaut, lints stricts, exports publics maîtrisés et chemins intra-crate explicites via la façade crate-root. + +Les audits read-only PGR sélectionnés avec les premiers contrats publics sont `audit_rust_blank_lines.py`, `audit_rust_item_spacing.py`, `audit_rust_module_order.py`, `audit_rust_trait_import_blocks.py`, `audit_rust_reexport_blocks.py` et `audit_rust_export_completeness.py`. L'audit `const` n'est pas sélectionné globalement : il reste opt-in par fichier lorsqu'un ordre mécanique de constantes est réellement approprié. ## Secrets et configuration locale diff --git a/VERSION b/VERSION index 3fc3728..4450b18 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1-alpha.2 +0.0.1-alpha.3 diff --git a/crates/knowledge-domain-lib/src/content_family.rs b/crates/knowledge-domain-lib/src/content_family.rs new file mode 100644 index 0000000..0ce531c --- /dev/null +++ b/crates/knowledge-domain-lib/src/content_family.rs @@ -0,0 +1,13 @@ +// file: crates/knowledge-domain-lib/src/content_family.rs +// version: 1 + +//! Content-family identities owned by the Knowledge domain. + +/// Semantic family of knowledge content. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum ContentFamily { + Pgr, + Howto, + Snippet, + ProjectLocal, +} diff --git a/crates/knowledge-domain-lib/src/lib.rs b/crates/knowledge-domain-lib/src/lib.rs index b5bdbe2..c73dace 100644 --- a/crates/knowledge-domain-lib/src/lib.rs +++ b/crates/knowledge-domain-lib/src/lib.rs @@ -1,8 +1,16 @@ // file: crates/knowledge-domain-lib/src/lib.rs -// version: 1 +// version: 2 -//! Domain boundary for SaseDev Knowledge. +//! Shared domain contracts for SaseDev Knowledge. //! -//! The crate is intentionally minimal in `0.0.1-alpha.2`: it establishes ownership of shared -//! domain contracts without introducing the Git/synchronization contracts reserved for the next -//! tranche. +//! The crate owns content-family and source identities that are independent of HTTP, PostgreSQL, +//! forge providers and concrete Git implementations. + +mod content_family; +mod source; + +pub use crate::content_family::ContentFamily; +pub use crate::source::{ContentSource, SourceId, SourceIdError}; + +#[cfg(test)] +mod unit_tests; diff --git a/crates/knowledge-domain-lib/src/source.rs b/crates/knowledge-domain-lib/src/source.rs new file mode 100644 index 0000000..ba8879f --- /dev/null +++ b/crates/knowledge-domain-lib/src/source.rs @@ -0,0 +1,68 @@ +// file: crates/knowledge-domain-lib/src/source.rs +// version: 1 + +//! Source identities independent of repository and forge providers. + +/// Error returned when constructing an invalid source identifier. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceIdError { + /// The identifier is empty or contains only whitespace. + Empty, +} + +impl std::fmt::Display for crate::SourceIdError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + crate::SourceIdError::Empty => return formatter.write_str("source identifier must not be empty"), + } + } +} + +impl std::error::Error for crate::SourceIdError {} + +/// Stable logical identifier of one configured content source. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct SourceId(String); + +impl crate::SourceId { + /// Build a source identifier without imposing a fixed repository count or provider syntax. + pub fn new(value: &str) -> Result { + if value.trim().is_empty() { + return Err(crate::SourceIdError::Empty); + } + return Ok(crate::SourceId(value.to_owned())); + } + + /// Return the identifier as text. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// One logical content source associated with a semantic family. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContentSource { + id: crate::SourceId, + family: crate::ContentFamily, +} + +impl crate::ContentSource { + /// Build a content source from a textual source identifier. + pub fn new(id: &str, family: crate::ContentFamily) -> Result { + let source_id = match crate::SourceId::new(id) { + Ok(value) => value, + Err(error) => return Err(error), + }; + return Ok(crate::ContentSource { id: source_id, family }); + } + + /// Return the logical source identifier. + pub fn id(&self) -> &crate::SourceId { + return &self.id; + } + + /// Return the semantic content family. + pub fn family(&self) -> crate::ContentFamily { + return self.family; + } +} diff --git a/crates/knowledge-domain-lib/src/unit_tests.rs b/crates/knowledge-domain-lib/src/unit_tests.rs new file mode 100644 index 0000000..e4e9d10 --- /dev/null +++ b/crates/knowledge-domain-lib/src/unit_tests.rs @@ -0,0 +1,20 @@ +// file: crates/knowledge-domain-lib/src/unit_tests.rs +// version: 1 + +#[test] +fn source_id_rejects_empty_values() { + assert_eq!(crate::SourceId::new(" "), Err(crate::SourceIdError::Empty)); + return; +} + +#[test] +fn content_sources_are_not_limited_to_three_repositories() { + let sources = [ + crate::ContentSource::new("pgr", crate::ContentFamily::Pgr), + crate::ContentSource::new("howtos", crate::ContentFamily::Howto), + crate::ContentSource::new("snippets", crate::ContentFamily::Snippet), + crate::ContentSource::new("project-extra", crate::ContentFamily::ProjectLocal), + ]; + assert!(sources.iter().all(Result::is_ok)); + return; +} diff --git a/crates/knowledge-git-api/Cargo.toml b/crates/knowledge-git-api/Cargo.toml new file mode 100644 index 0000000..41f5adb --- /dev/null +++ b/crates/knowledge-git-api/Cargo.toml @@ -0,0 +1,15 @@ +# file: crates/knowledge-git-api/Cargo.toml +# version: 1 + +[package] +name = "knowledge-git-api" +version.workspace = true +edition.workspace = true +publish.workspace = true +repository.workspace = true + +[dependencies] +knowledge-domain-lib.workspace = true + +[lints] +workspace = true diff --git a/crates/knowledge-git-api/src/lib.rs b/crates/knowledge-git-api/src/lib.rs new file mode 100644 index 0000000..2d38875 --- /dev/null +++ b/crates/knowledge-git-api/src/lib.rs @@ -0,0 +1,57 @@ +// file: crates/knowledge-git-api/src/lib.rs +// version: 1 + +//! Provider-neutral contracts for Git-backed Knowledge sources. +//! +//! This crate deliberately contains no forge API, process runner, filesystem checkout or database +//! implementation. Concrete Git backends depend on this contract, not the reverse. + +/// Opaque Git reference requested by a caller, such as a branch, tag or object expression. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct GitReference(String); + +impl crate::GitReference { + /// Create an opaque Git reference without imposing one provider's reference grammar. + pub fn new(value: &str) -> crate::GitReference { + return crate::GitReference(value.to_owned()); + } + + /// Return the reference text. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// Resolved authoritative Git revision identity. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct GitRevision(String); + +impl crate::GitRevision { + /// Create a revision identity returned by a concrete Git implementation. + pub fn new(value: &str) -> crate::GitRevision { + return crate::GitRevision(value.to_owned()); + } + + /// Return the revision identity as text. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// Provider-neutral operations required from a Git repository backend. +/// +/// Implementations may use a local Git executable, a Rust Git library or another generic Git +/// mechanism. Forge-only capabilities do not belong to this trait. +pub trait GitRepository { + /// Implementation-specific recoverable error. + type Error; + + /// Refresh the local view of one configured source when the backend requires it. + fn refresh(&mut self, source_id: &knowledge_domain_lib::SourceId) -> Result<(), Self::Error>; + + /// Resolve one opaque reference to the authoritative revision used by synchronization. + fn resolve_revision(&self, source_id: &knowledge_domain_lib::SourceId, reference: &crate::GitReference) -> Result; +} + +#[cfg(test)] +mod unit_tests; diff --git a/crates/knowledge-git-api/src/unit_tests.rs b/crates/knowledge-git-api/src/unit_tests.rs new file mode 100644 index 0000000..76a5c9c --- /dev/null +++ b/crates/knowledge-git-api/src/unit_tests.rs @@ -0,0 +1,35 @@ +// file: crates/knowledge-git-api/src/unit_tests.rs +// version: 1 + +#[derive(Debug, Eq, PartialEq)] +struct FakeGitError; + +struct FakeGitRepository; + +impl crate::GitRepository for FakeGitRepository { + type Error = FakeGitError; + + fn refresh(&mut self, _source_id: &knowledge_domain_lib::SourceId) -> Result<(), Self::Error> { + return Ok(()); + } + + fn resolve_revision(&self, source_id: &knowledge_domain_lib::SourceId, reference: &crate::GitReference) -> Result { + return Ok(crate::GitRevision::new(&format!("{}:{}", source_id.as_str(), reference.as_str()))); + } +} + +#[test] +fn generic_repository_contract_does_not_require_a_forge() { + let source_result = knowledge_domain_lib::SourceId::new("local-pgr"); + assert!(source_result.is_ok()); + let source_id = match source_result { + Ok(value) => value, + Err(_) => return, + }; + let reference = crate::GitReference::new("refs/heads/master"); + let mut repository = FakeGitRepository; + assert!(crate::GitRepository::refresh(&mut repository, &source_id).is_ok()); + let revision_result = crate::GitRepository::resolve_revision(&repository, &source_id, &reference); + assert_eq!(revision_result, Ok(crate::GitRevision::new("local-pgr:refs/heads/master"))); + return; +} diff --git a/crates/knowledge-sync-api/Cargo.toml b/crates/knowledge-sync-api/Cargo.toml new file mode 100644 index 0000000..7b41137 --- /dev/null +++ b/crates/knowledge-sync-api/Cargo.toml @@ -0,0 +1,15 @@ +# file: crates/knowledge-sync-api/Cargo.toml +# version: 1 + +[package] +name = "knowledge-sync-api" +version.workspace = true +edition.workspace = true +publish.workspace = true +repository.workspace = true + +[dependencies] +knowledge-domain-lib.workspace = true + +[lints] +workspace = true diff --git a/crates/knowledge-sync-api/src/lib.rs b/crates/knowledge-sync-api/src/lib.rs new file mode 100644 index 0000000..6cdef12 --- /dev/null +++ b/crates/knowledge-sync-api/src/lib.rs @@ -0,0 +1,57 @@ +// file: crates/knowledge-sync-api/src/lib.rs +// version: 1 + +//! Contracts for provider-neutral repository synchronization signals. +//! +//! A signal requests reconciliation of a source. It is not authoritative content and does not +//! replace resolution of the target Git revision by the Git repository contract. + +/// Coarse origin of a synchronization request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RepositorySyncCause { + /// An explicit request from an operator or application use case. + Explicit, + /// A periodic reconciliation independent of external hooks. + Periodic, + /// A non-authoritative external signal such as a Git hook, webhook or filesystem watcher. + ExternalSignal, +} + +/// Request to reconcile one logical content source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RepositorySyncSignal { + source_id: knowledge_domain_lib::SourceId, + cause: crate::RepositorySyncCause, +} + +impl crate::RepositorySyncSignal { + /// Build a synchronization signal for one source. + pub fn new(source_id: knowledge_domain_lib::SourceId, cause: crate::RepositorySyncCause) -> crate::RepositorySyncSignal { + return crate::RepositorySyncSignal { source_id, cause }; + } + + /// Return the source that must be reconciled. + pub fn source_id(&self) -> &knowledge_domain_lib::SourceId { + return &self.source_id; + } + + /// Return the coarse trigger origin. + pub fn cause(&self) -> crate::RepositorySyncCause { + return self.cause; + } +} + +/// Producer of synchronization signals. +/// +/// Concrete implementations may represent explicit queues, polling, hooks, webhooks or other +/// event mechanisms. The scheduling/runtime model remains outside this passive contract. +pub trait RepositoryEventSource { + /// Implementation-specific recoverable error. + type Error; + + /// Return the next available signal, or `None` when no signal is currently available. + fn next_signal(&mut self) -> Result, Self::Error>; +} + +#[cfg(test)] +mod unit_tests; diff --git a/crates/knowledge-sync-api/src/unit_tests.rs b/crates/knowledge-sync-api/src/unit_tests.rs new file mode 100644 index 0000000..96c9d2c --- /dev/null +++ b/crates/knowledge-sync-api/src/unit_tests.rs @@ -0,0 +1,45 @@ +// file: crates/knowledge-sync-api/src/unit_tests.rs +// version: 1 + +#[derive(Debug, Eq, PartialEq)] +struct FakeEventError; + +struct OneShotEventSource { + signal: Option, +} + +impl crate::RepositoryEventSource for OneShotEventSource { + type Error = FakeEventError; + + fn next_signal(&mut self) -> Result, Self::Error> { + return Ok(self.signal.take()); + } +} + +#[test] +fn external_signal_is_only_a_request_to_reconcile_a_source() { + let source_result = knowledge_domain_lib::SourceId::new("snippets-extra"); + assert!(source_result.is_ok()); + let source_id = match source_result { + Ok(value) => value, + Err(_) => return, + }; + let signal = crate::RepositorySyncSignal::new(source_id, crate::RepositorySyncCause::ExternalSignal); + let mut event_source = OneShotEventSource { signal: Some(signal) }; + let first_result = crate::RepositoryEventSource::next_signal(&mut event_source); + assert!(first_result.is_ok()); + let first = match first_result { + Ok(value) => value, + Err(_) => return, + }; + assert!(first.is_some()); + let signal = match first { + Some(value) => value, + None => return, + }; + assert_eq!(signal.source_id().as_str(), "snippets-extra"); + assert_eq!(signal.cause(), crate::RepositorySyncCause::ExternalSignal); + let second_result = crate::RepositoryEventSource::next_signal(&mut event_source); + assert_eq!(second_result, Ok(None)); + return; +} diff --git a/deltas/0.0.1/alpha.3.md b/deltas/0.0.1/alpha.3.md new file mode 100644 index 0000000..e5ff51b --- /dev/null +++ b/deltas/0.0.1/alpha.3.md @@ -0,0 +1,232 @@ + + + +# Delta 0.0.1-alpha.3 — contrats domaine, Git et synchronisation + +## Base requise + +État `0.0.1-alpha.2` committé et poussé avec : + +```text +history/0.0.1/alpha.2.md = PASS +``` + +La version devient : + +```text +0.0.1-alpha.3 +``` + +## Objet + +Matérialiser les premiers contrats passifs réellement nécessaires aux frontières domaine/Git/synchronisation sans démarrer l'implémentation technique. + +La tranche : + +- étend `knowledge-domain-lib` avec `ContentFamily`, `SourceId` et `ContentSource` ; +- prouve qu'aucune limite à trois sources n'est codée ; +- crée `knowledge-git-api` pour la frontière remplaçable `GitRepository` ; +- crée `knowledge-sync-api` pour la frontière multi-producteurs `RepositoryEventSource` ; +- maintient Git indépendant d'une forge et le signal de synchronisation non autoritaire ; +- adopte les audits Rust PGR de structure/façade désormais pertinents ; +- garde `Cargo.lock` versionné mais laisse Cargo le régénérer dans l'environnement opérateur. + +Aucun `knowledge-git-lib`, adapter Gitea, serveur Actix, schéma PostgreSQL, worker ou frontend n'est ajouté. + +## Crates matérialisées + +```text +knowledge-domain-lib + identités sémantiques de famille/source + +knowledge-git-api + GitRepository + GitReference + GitRevision + aucune forge obligatoire + +knowledge-sync-api + RepositoryEventSource + RepositorySyncSignal + signal de réconciliation, pas vérité de contenu +``` + +Les suffixes `-api` sont justifiés ici par des implémentations réellement substituables ou multiples ; ils ne sont pas créés par symétrie. + +## Audits Rust adoptés + +Les scripts read-only suivants sont copiés depuis la baseline PGR `v0.1.1` et deviennent des owners locaux de preuve : + +```text +scripts/_rust_audit_common.py +scripts/audit_rust_blank_lines.py +scripts/audit_rust_item_spacing.py +scripts/audit_rust_module_order.py +scripts/audit_rust_trait_import_blocks.py +scripts/audit_rust_reexport_blocks.py +scripts/audit_rust_export_completeness.py +``` + +`audit_rust_const_blocks.py` n'est pas adopté comme gate globale. Son usage reste opt-in sur un fichier dont l'ordre des constantes est réellement mécanique. + +## `Cargo.lock` + +Le delta ne livre pas un lockfile fabriqué. L'ajout des deux packages modifie la résolution workspace ; `cargo metadata/check` de la gate opérateur MUST régénérer le `Cargo.lock` réel, qui reste versionné et doit apparaître modifié au staging si Cargo l'actualise. + +## Fichiers ajoutés + +```text +crates/knowledge-domain-lib/src/content_family.rs +crates/knowledge-domain-lib/src/source.rs +crates/knowledge-domain-lib/src/unit_tests.rs +crates/knowledge-git-api/Cargo.toml +crates/knowledge-git-api/src/lib.rs +crates/knowledge-git-api/src/unit_tests.rs +crates/knowledge-sync-api/Cargo.toml +crates/knowledge-sync-api/src/lib.rs +crates/knowledge-sync-api/src/unit_tests.rs +deltas/0.0.1/alpha.3.md +scripts/_rust_audit_common.py +scripts/audit_rust_blank_lines.py +scripts/audit_rust_export_completeness.py +scripts/audit_rust_item_spacing.py +scripts/audit_rust_module_order.py +scripts/audit_rust_reexport_blocks.py +scripts/audit_rust_trait_import_blocks.py +``` + +## Fichiers remplacés + +```text +CHANGELOG.md +Cargo.toml +README.md +RULES.md +VERSION +crates/knowledge-domain-lib/src/lib.rs +docs/architecture/001-PROJECT_PROFILE.md +docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +docs/plans/001-V0_0_1_FOUNDATION_PLAN.md +docs/validation/001-VALIDATION_MATRIX.md +``` + +Aucun fichier n'est supprimé. + +## Validation attendue + +Après application du history `alpha.2`, commit/push de `alpha.2`, puis extraction du présent delta depuis la racine : + +```bash +test "$(cat VERSION)" = "0.0.1-alpha.3" + +git diff --check + +python3 - <<'PY' +from pathlib import Path +import tomllib + +with Path("Cargo.toml").open("rb") as handle: + root_manifest = tomllib.load(handle) + +expected_members = { + "crates/knowledge-domain-lib", + "crates/knowledge-git-api", + "crates/knowledge-sync-api", +} +assert set(root_manifest["workspace"]["members"]) == expected_members +assert root_manifest["workspace"]["package"]["version"] == "0.0.1-alpha.3" +assert "knowledge-domain-lib" in root_manifest["workspace"]["dependencies"] + +for path in ( + "crates/knowledge-domain-lib/src/content_family.rs", + "crates/knowledge-domain-lib/src/source.rs", + "crates/knowledge-domain-lib/src/unit_tests.rs", + "crates/knowledge-git-api/Cargo.toml", + "crates/knowledge-git-api/src/lib.rs", + "crates/knowledge-git-api/src/unit_tests.rs", + "crates/knowledge-sync-api/Cargo.toml", + "crates/knowledge-sync-api/src/lib.rs", + "crates/knowledge-sync-api/src/unit_tests.rs", +): + assert Path(path).is_file(), path + +for forbidden in ( + "crates/knowledge-git-lib", + "crates/knowledge-forge-gitea-lib", + "crates/knowledge-pgr-server", + "crates/knowledge-howtos-server", + "crates/knowledge-snippets-server", + "migrations", + "package.json", +): + assert not Path(forbidden).exists(), forbidden + +for manifest in ( + "crates/knowledge-git-api/Cargo.toml", + "crates/knowledge-sync-api/Cargo.toml", +): + text = Path(manifest).read_text(encoding="utf-8") + for forbidden_dependency in ("actix", "gitea", "postgres", "sqlx", "tokio", "tracing"): + assert forbidden_dependency not in text.lower(), (manifest, forbidden_dependency) + +architecture = Path("docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md").read_text(encoding="utf-8") +for phrase in ( + "knowledge-git-api", + "knowledge-sync-api", + "GitRepository", + "RepositoryEventSource", + "Explicit", + "Periodic", + "ExternalSignal", +): + assert phrase in architecture, phrase + +print("Knowledge 0.0.1-alpha.3 structural audit: clean") +PY + +cargo metadata --format-version 1 --no-deps > /tmp/sasedev-knowledge-alpha3-metadata.json +cargo fmt --all +cargo fmt --all -- --check + +python3 -B scripts/audit_rust_blank_lines.py crates +python3 -B scripts/audit_rust_item_spacing.py crates +python3 -B scripts/audit_rust_module_order.py crates +python3 -B scripts/audit_rust_trait_import_blocks.py crates +python3 -B scripts/audit_rust_reexport_blocks.py crates +python3 -B scripts/audit_rust_export_completeness.py --root . + +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings + +cargo test -p knowledge-domain-lib --all-targets --all-features +cargo test -p knowledge-git-api --all-targets --all-features +cargo test -p knowledge-sync-api --all-targets --all-features + +test -f Cargo.lock +if git check-ignore -q Cargo.lock; then + echo 'Cargo.lock must remain versioned' >&2 + exit 1 +fi + +python3 - <<'PY' +import json +from pathlib import Path + +metadata = json.loads(Path("/tmp/sasedev-knowledge-alpha3-metadata.json").read_text(encoding="utf-8")) +packages = {package["name"] for package in metadata["packages"]} +assert packages == {"knowledge-domain-lib", "knowledge-git-api", "knowledge-sync-api"}, packages + +dependencies = { + package["name"]: {dependency["name"] for dependency in package["dependencies"]} + for package in metadata["packages"] +} +assert dependencies["knowledge-domain-lib"] == set() +assert dependencies["knowledge-git-api"] == {"knowledge-domain-lib"} +assert dependencies["knowledge-sync-api"] == {"knowledge-domain-lib"} + +print("Knowledge 0.0.1-alpha.3 Cargo boundary audit: clean") +PY + +git add -A +git diff --cached --check +git status --short +``` + +Ne pas committe `alpha.3` avant production de son history à partir des résultats opérateur. diff --git a/docs/architecture/001-PROJECT_PROFILE.md b/docs/architecture/001-PROJECT_PROFILE.md index 9453874..e31e93b 100644 --- a/docs/architecture/001-PROJECT_PROFILE.md +++ b/docs/architecture/001-PROJECT_PROFILE.md @@ -1,5 +1,5 @@ - + # Profil du projet Knowledge @@ -38,7 +38,7 @@ Le prompt de démarrage exige néanmoins une matrice `ADOPT / KEEP-LOCAL / OVERR | `220-SKELETONS` | `ADOPT` pour l'instanciation | `alpha.1` | composition explicite sans copie mécanique | | `300-DOCUMENTATION` | `ADOPT` | `alpha.1` | rôles documentaires et taxonomie utile | | `400-VALIDATION` | `ADOPT` | `alpha.1` | preuves proportionnées et autorité opérateur | -| `500-LANGUAGES` + `510-RUST_CARGO` | `ADOPT` | `alpha.2` | backend Rust/workspace Cargo contrôlé | +| `500-LANGUAGES` + `510-RUST_CARGO` | `ADOPT` | `alpha.2+` | backend Rust/workspace Cargo contrôlé | | `530-TYPESCRIPT` | `ADOPT` | tranche Web | frontend durable TypeScript | | `540-JAVASCRIPT_PACKAGE_ECOSYSTEM` | `ADOPT` | tranche Web | package manager/lockfile/scripts explicites | | `550-VITE` | `ADOPT` puisque Vite est retenu | tranche Web | dev server/build/cache/outputs | @@ -68,7 +68,7 @@ Les profils Java/Maven/Gradle, Android et autres technologies absentes ne sont p | Synchronisation sans webhook | `KEEP-LOCAL` | explicite/polling constituent le socle ; hooks/webhooks sont des accélérateurs | | PostgreSQL | `KEEP-LOCAL` | une base logique partagée pour l'état transversal et les projections ; jamais vérité du contenu Git publié | | Rust stable / édition 2024 | `ADOPT` | profil Rust/Cargo PGR | -| Workspace Cargo | `ADOPT` | skeleton Rust/Cargo ; matérialisation `alpha.2` | +| Workspace Cargo | `ADOPT` | skeleton Rust/Cargo ; actif depuis `alpha.2` | | `Cargo.lock` | `KEEP-LOCAL` | versionné : workspace applicatif déployable, résolution reproductible entre opérateur et livraisons | | Actix Web | `KEEP-LOCAL` | trois launchers serveur distincts ; PGR ne possède pas de profil Actix dédié | | Maud | `KEEP-LOCAL` | rendu SSR initial lorsque pertinent | @@ -100,14 +100,21 @@ knowledge-howtos-server launcher Actix Web de la façade HOWTOs knowledge-snippets-server launcher Actix Web de la façade snippets ``` -Ils ne sont pas encore matérialisés en `alpha.2`. Lorsqu'ils le seront, leurs `main.rs` resteront minces et composeront les bibliothèques réutilisables. Chacun doit pouvoir protéger sa propre surface d'administration métier. +Ils ne sont pas encore matérialisés en `alpha.3`. Lorsqu'ils le seront, leurs `main.rs` resteront minces et composeront les bibliothèques réutilisables. Chacun doit pouvoir protéger sa propre surface d'administration métier. -Les responsabilités de bibliothèques suivantes restent candidates et ne deviennent des packages que lorsqu'elles possèdent un ownership réel : +Les responsabilités matérialisées sont : + +```text +knowledge-domain-lib types métier stables de famille/source ; aucune dépendance Actix/Git-provider/forge/PostgreSQL +knowledge-git-api contrat passif GitRepository ; implémentations génériques remplaçables derrière cette frontière +knowledge-sync-api contrat passif RepositoryEventSource et signaux de réconciliation ; plusieurs mécanismes producteurs prévus +``` + +Les responsabilités suivantes restent candidates et ne deviennent des packages que lorsqu'elles possèdent un ownership réel : ```text -knowledge-domain-lib ownership des contrats et types métier stables ; crate matérialisée en alpha.2 sans contrats Git prématurés knowledge-app-lib orchestration des use cases transversaux, si la séparation apporte une valeur réelle -knowledge-git-lib implémentation Git générique +knowledge-git-lib implémentation Git générique de knowledge-git-api knowledge-store-postgres-lib état applicatif PostgreSQL partagé knowledge-forge-gitea-lib adapter Gitea optionnel knowledge-web-lib composition Web réellement partagée, seulement si les trois serveurs dupliqueraient sinon du code diff --git a/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md b/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md index d9fd673..05c45a2 100644 --- a/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +++ b/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md @@ -1,5 +1,5 @@ - + # Architecture de fondation Knowledge @@ -15,7 +15,15 @@ Le principe directeur reste : ## Workspace Rust matérialisé -`alpha.2` instancie le skeleton PGR `002-RUST_CARGO_WORKSPACE` avec une première crate `knowledge-domain-lib`. Cette crate possède la frontière de domaine commune mais n'introduit pas encore les contrats Git/synchronisation réservés à `alpha.3`. +`alpha.2` instancie le skeleton PGR `002-RUST_CARGO_WORKSPACE`. `alpha.3` matérialise les trois premières frontières passives : + +```text +knowledge-domain-lib famille/source de contenu +knowledge-git-api GitRepository provider-neutral +knowledge-sync-api RepositoryEventSource et signal de réconciliation +``` + +Les deux crates `-api` sont justifiées par des implémentations réellement remplaçables ou multiples. Elles restent sans runtime, sans logging propre et sans dépendance à Actix, Gitea ou PostgreSQL. Le workspace est une application déployable à train de release unique ; `Cargo.lock` est donc versionné pour conserver la résolution effectivement vérifiée. Les artefacts Cargo sont dirigés hors du dépôt vers `../builds/sasedev-knowledge/target/`. @@ -50,6 +58,10 @@ knowledge-pgr-server knowledge-howtos-server knowledge-snippets-server La dépendance pointe vers les contrats métier. Les détails Actix, processus Git, API Gitea ou SQL ne remontent pas dans le domaine. +`GitRepository` ne porte que les opérations génériques actuellement nécessaires : rafraîchir la vue locale d'une source et résoudre une référence opaque vers une révision Git autoritaire. Le contrat ne suppose ni API de forge, ni type de remote particulier, ni format SHA figé. + +`RepositoryEventSource` ne produit qu'un `RepositorySyncSignal` associé à un `source_id`. La cause est volontairement grossière (`Explicit`, `Periodic`, `ExternalSignal`) afin que hook Git, webhook de forge ou watcher ne deviennent pas des concepts autoritaires du domaine. + ## 1. Applications publiques Les trois façades publiques sont des unités de déploiement distinctes : diff --git a/docs/plans/001-V0_0_1_FOUNDATION_PLAN.md b/docs/plans/001-V0_0_1_FOUNDATION_PLAN.md index 348e212..66f7b9a 100644 --- a/docs/plans/001-V0_0_1_FOUNDATION_PLAN.md +++ b/docs/plans/001-V0_0_1_FOUNDATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan 0.0.1 — fondation SaseDev Knowledge @@ -120,18 +120,18 @@ Aucun code fonctionnel n'est ajouté dans `alpha.1` ou son fix. ### alpha.2 — workspace Rust/Cargo minimal -Instancier le skeleton `002-RUST_CARGO_WORKSPACE` sur la fondation : manifest workspace, outillage durable, artefacts Cargo hors dépôt et première crate `knowledge-domain-lib` possédant la frontière de domaine commune. +Statut opérateur : **PASS**. -Décision `Cargo.lock` : le versionner, car Knowledge est un workspace applicatif déployable à train de release unique et doit conserver la résolution effectivement validée. - -Étudier les deux corpus historiques auth/admin fournis et formaliser uniquement les enseignements durables : identité partageable, autorisation serveur et surface admin propre à chaque application. Ne pas importer les URL, rôles, OAuth/OIDC, MFA ou écrans inachevés comme exigences. - -Les trois packages serveur restent connus architecturalement mais ne sont pas créés dans cette tranche : sans routes/cas d'usage réels, ils seraient encore des placeholders. +Le skeleton `002-RUST_CARGO_WORKSPACE` est matérialisé avec `knowledge-domain-lib`, `Cargo.lock` versionné, artefacts Cargo hors dépôt et étude auth/admin séparant fonctionnalités réelles et intentions historiques. Les gates `metadata`, `fmt`, `check`, Clippy et tests workspace sont propres. ### alpha.3 — contrats domaine/Git/synchronisation Introduire uniquement les premiers contrats/types nécessaires aux frontières `GitRepository`, source/famille de contenu et synchronisation, avec tests ciblés. Le modèle doit supporter au moins les trois sources initiales sans coder une limite à trois. +Matérialiser `knowledge-git-api` et `knowledge-sync-api` parce que ces frontières ont des implémentations réellement remplaçables/multiples. Garder ces crates passives ; ne pas créer `knowledge-git-lib`, worker, Gitea ou persistence par anticipation. + +Adopter les audits Rust PGR de structure/façade désormais utiles avec les premiers items publics partagés. Ne pas activer globalement l'audit `const`, qui reste sélection explicite par fichier. + Ne pas ajouter d'API Gitea ni de persistance PostgreSQL par anticipation. ### alpha.4 — baseline Web indépendante diff --git a/docs/validation/001-VALIDATION_MATRIX.md b/docs/validation/001-VALIDATION_MATRIX.md index ec39df2..257934f 100644 --- a/docs/validation/001-VALIDATION_MATRIX.md +++ b/docs/validation/001-VALIDATION_MATRIX.md @@ -1,5 +1,5 @@ - + # Matrice de validation Knowledge @@ -161,3 +161,22 @@ La tranche doit prouver : - étude auth/admin présente avec distinction entre fonctionnalités câblées, préparatoires et seulement annoncées ; - surfaces admin locales documentées sans figer `/admin`, `/login` ou un modèle de rôles ; - hygiène Git et headers documentaires applicables. + +## Gate de `0.0.1-alpha.3` + +La tranche doit prouver : + +- `VERSION = 0.0.1-alpha.3` et `[workspace.package].version` identique ; +- trois packages seulement : `knowledge-domain-lib`, `knowledge-git-api`, `knowledge-sync-api` ; +- `knowledge-git-api` et `knowledge-sync-api` comme crates passives sans dépendance Actix, Gitea, PostgreSQL ou runtime ; +- source/famille de contenu capables de représenter plus de trois sources configurées ; +- `GitRepository` indépendant d'une forge et sans format de hash Git figé ; +- `RepositoryEventSource` produisant un signal non autoritaire lié à un `source_id` ; +- `Cargo.lock` régénéré par Cargo et versionné après ajout des deux packages ; +- `cargo fmt --all -- --check`, `cargo check --workspace` et Clippy strict ; +- tests ciblés des trois crates ; +- audits Rust PGR sélectionnés propres, y compris la façade crate-root ; +- absence de `knowledge-git-lib`, adapter Gitea, schéma PostgreSQL, Actix Web et frontend ; +- hygiène Git et headers documentaires/code applicables. + +L'audit `audit_rust_const_blocks.py` n'appartient pas à la gate globale de cette tranche. diff --git a/history/0.0.1/alpha.3.md b/history/0.0.1/alpha.3.md new file mode 100644 index 0000000..dfdfdc6 --- /dev/null +++ b/history/0.0.1/alpha.3.md @@ -0,0 +1,157 @@ + + + +# History 0.0.1-alpha.3 — contrats domaine, Git et synchronisation + +## Statut + +FAIL + +La gate opérateur de `0.0.1-alpha.3` exécutée le `2026-09-27` valide le cadrage structurel et les audits, puis échoue sur une erreur de compilation Rust dans `knowledge-domain-lib`. + +## Baseline opérateur + +La tranche précédente a été clôturée et publiée avant application de `alpha.3` : + +```text +history/0.0.1/alpha.2.md = PASS +commit 8cd187b +message 0.0.1-alpha.2 +branch master +push origin/master PASS +status clean après push +``` + +Le push observé a avancé `origin/master` de `d632afe` à `8cd187b`. + +## Structure et metadata + +Les contrôles initiaux ont réussi : + +```text +VERSION = 0.0.1-alpha.3 +git diff --check = clean +Knowledge 0.0.1-alpha.3 structural audit: clean +Knowledge 0.0.1-alpha.3 Cargo boundary audit: clean +``` + +`cargo metadata --format-version 1 --no-deps` a produit le graphe attendu avec exactement : + +```text +knowledge-domain-lib +knowledge-git-api +knowledge-sync-api +``` + +Les frontières de dépendances observées sont conformes : + +```text +knowledge-domain-lib -> aucune dépendance workspace +knowledge-git-api -> knowledge-domain-lib +knowledge-sync-api -> knowledge-domain-lib +``` + +## Formatage et audits Rust + +Les commandes suivantes ont réussi : + +```text +cargo fmt --all +cargo fmt --all -- --check +Rust blank-line audit: clean (8 file(s)) +Rust item-spacing audit: clean (8 file(s)) +Rust module-order audit: clean (8 file(s)) +Rust trait-import-block audit: clean (8 file(s)) +Rust re-export-block audit: clean (8 file(s)) +Rust export completeness audit: 0 candidate(s) across 3 package(s) +``` + +Ces preuves restent valides pour la structure livrée mais ne compensent pas l'échec de compilation. + +## Défaut de compilation + +`cargo check --workspace` échoue avec `E0423` dans : + +```text +crates/knowledge-domain-lib/src/source.rs:33 +``` + +Le code tente de construire : + +```text +crate::SourceId(value.to_owned()) +``` + +alors que `SourceId` est un tuple struct à champ privé défini dans le sous-module `source` puis réexporté par la façade crate-root. Le re-export rend le type accessible mais ne rend pas son constructeur de champ privé visible depuis la racine. + +Le compilateur suggère l'accès direct depuis le module propriétaire. Le correctif retenu doit préserver la façade publique tout en construisant la valeur depuis le scope qui possède le champ privé. + +## Propagation du défaut + +Le même `E0423` bloque : + +```text +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p knowledge-domain-lib --all-targets --all-features +cargo test -p knowledge-git-api --all-targets --all-features +cargo test -p knowledge-sync-api --all-targets --all-features +``` + +Aucune de ces commandes ne peut donc établir un `PASS` de tranche. + +`Cargo.lock` reste présent et non ignoré. La vérification de metadata/boundaries exécutée après les échecs reste propre, mais elle ne constitue pas une compilation. + +## Scope Git observé + +`git diff --cached --check` est propre. Le staging transmis avant ajout du présent history contient : + +```text +M CHANGELOG.md +M Cargo.lock +M Cargo.toml +M README.md +M RULES.md +M VERSION +A crates/knowledge-domain-lib/src/content_family.rs +M crates/knowledge-domain-lib/src/lib.rs +A crates/knowledge-domain-lib/src/source.rs +A crates/knowledge-domain-lib/src/unit_tests.rs +A crates/knowledge-git-api/Cargo.toml +A crates/knowledge-git-api/src/lib.rs +A crates/knowledge-git-api/src/unit_tests.rs +A crates/knowledge-sync-api/Cargo.toml +A crates/knowledge-sync-api/src/lib.rs +A crates/knowledge-sync-api/src/unit_tests.rs +A deltas/0.0.1/alpha.3.md +M docs/architecture/001-PROJECT_PROFILE.md +M docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +M docs/plans/001-V0_0_1_FOUNDATION_PLAN.md +M docs/validation/001-VALIDATION_MATRIX.md +A scripts/_rust_audit_common.py +A scripts/audit_rust_blank_lines.py +A scripts/audit_rust_export_completeness.py +A scripts/audit_rust_item_spacing.py +A scripts/audit_rust_module_order.py +A scripts/audit_rust_reexport_blocks.py +A scripts/audit_rust_trait_import_blocks.py +``` + +Ce scope correspond au delta annoncé et au `Cargo.lock` régénéré par Cargo. + +## Précision Web reçue avant clôture + +Avant production du présent history, le rôle futur du profil Web a été précisé : le skeleton Web PGR sera réellement utilisé pour développer et valider des templates statiques, TypeScript, Sass, Bootstrap et le pipeline Vite. Le markup runtime sera ensuite adapté en Maud dans les applications Actix Web. + +Le corpus historique `actix-auth-app` contient en outre un pattern d'intégration utile entre manifest Vite et rendu Maud. Cette précision ne change pas la cause du `FAIL` `alpha.3`; elle peut être documentée dans le fix sans démarrer l'implémentation Web. + +## Décision + +`0.0.1-alpha.3` est `FAIL`. + +La transition autorisée est : + +```text +0.0.1-alpha.3.fix.1 +``` + +Le fix doit corriger l'accès au constructeur privé de `SourceId`, ajouter une preuve de régression ciblée, rejouer les gates Rust complètes et conserver le scope fonctionnel de `alpha.3`. La clarification Web peut mettre à jour le plan vivant et l'architecture, mais aucun frontend/serveur fonctionnel ne doit être matérialisé dans ce fix. diff --git a/scripts/_rust_audit_common.py b/scripts/_rust_audit_common.py new file mode 100644 index 0000000..e54a3b7 --- /dev/null +++ b/scripts/_rust_audit_common.py @@ -0,0 +1,144 @@ +# file: scripts/_rust_audit_common.py +# version: 1 + +"""Small shared lexical and file-selection helpers for read-only Rust audits.""" + +from __future__ import annotations + +import pathlib + +_IGNORED = frozenset({".git", ".venv", "__pycache__", "target", "builds"}) + + +def selected_files(raw_paths: list[str]) -> tuple[list[pathlib.Path], list[str]]: + """Select explicit Rust files and directories, without following generated trees.""" + + files: set[pathlib.Path] = set() + errors: list[str] = [] + for raw in raw_paths: + path = pathlib.Path(raw) + if path.is_dir(): + files.update( + candidate + for candidate in path.rglob("*.rs") + if candidate.is_file() and not (set(candidate.parts) & _IGNORED) + ) + elif path.is_file() and path.suffix == ".rs": + files.add(path) + else: + errors.append(f"unsupported or missing Rust path: {path}") + return sorted(files), errors + + +def mask_source(text: str) -> tuple[str, set[int]]: + """Mask comments and literals, preserving offsets and structural punctuation. + + Return line numbers whose first character is inside a multiline literal, so + an empty line in a raw string cannot be mistaken for an empty code line. + This is a lexical helper, not a full Rust parser or a compiler substitute. + """ + + output = list(text) + protected: set[int] = set() + index = 0 + line = 1 + state = "code" + block_depth = 0 + raw_hashes = 0 + while index < len(text): + char = text[index] + nxt = text[index + 1] if index + 1 < len(text) else "" + if char == "\n": + line += 1 + if state in {"string", "raw_string"}: + protected.add(line) + if state == "line_comment": + state = "code" + index += 1 + continue + if state == "code": + if char == "/" and nxt in {"/", "*"}: + output[index:index + 2] = [" ", " "] + state = "line_comment" if nxt == "/" else "block_comment" + block_depth = 1 if nxt == "*" else 0 + index += 2 + continue + if char == '"': + output[index] = " " + state = "string" + index += 1 + continue + if char == "'": + # Lifetimes have no nearby closing quote; char literals do. + probe = index + 1 + escaped = False + found = False + while probe < min(len(text), index + 8) and text[probe] != "\n": + if text[probe] == "'" and not escaped: + found = True + break + escaped = text[probe] == "\\" and not escaped + probe += 1 + if found: + output[index] = " " + state = "char" + index += 1 + continue + # Also covers r#"..."#, br#"..."# and cr#"..."#. + if char == "r" and (index == 0 or text[index - 1] not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"): + probe = index + 1 + while probe < len(text) and text[probe] == "#": + probe += 1 + if probe < len(text) and text[probe] == '"': + output[index:probe + 1] = [" "] * (probe + 1 - index) + raw_hashes = probe - index - 1 + state = "raw_string" + index = probe + 1 + continue + index += 1 + continue + if state == "line_comment": + output[index] = " " + elif state == "block_comment": + output[index] = " " + if char == "/" and nxt == "*": + output[index + 1] = " " + block_depth += 1 + index += 2 + continue + if char == "*" and nxt == "/": + output[index + 1] = " " + block_depth -= 1 + if block_depth == 0: + state = "code" + index += 2 + continue + elif state in {"string", "char"}: + output[index] = " " + if char == "\\" and index + 1 < len(text): + if text[index + 1] != "\n": + output[index + 1] = " " + index += 2 + continue + if char == ('"' if state == "string" else "'"): + state = "code" + elif state == "raw_string": + output[index] = " " + if char == '"' and text.startswith("#" * raw_hashes, index + 1): + output[index + 1:index + 1 + raw_hashes] = [" "] * raw_hashes + index += 1 + raw_hashes + state = "code" + continue + index += 1 + return "".join(output), protected + + +def line_depths(masked: str) -> list[int]: + """Return brace depth at the start of each line, excluding literals.""" + + depths: list[int] = [] + depth = 0 + for line in masked.splitlines(): + depths.append(depth) + depth = max(0, depth + line.count("{") - line.count("}")) + return depths diff --git a/scripts/audit_rust_blank_lines.py b/scripts/audit_rust_blank_lines.py new file mode 100644 index 0000000..ce502cb --- /dev/null +++ b/scripts/audit_rust_blank_lines.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_blank_lines.py +# version: 1 + +"""Audit blank-line spacing in explicitly selected Rust sources.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from _rust_audit_common import line_depths, mask_source, selected_files + +_BODY = re.compile(r"^\s*(?:(?:pub(?:\([^)]*\))?|async|const|unsafe|extern|default)\s+)*(fn|struct|enum)\b") + + +def audit_file(path: pathlib.Path) -> list[str]: + """Check successive empty lines and empty function/struct/enum body lines.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked, protected = mask_source(text) + masked_lines = masked.splitlines() + depths = line_depths(masked) + errors: list[str] = [] + previous_blank = False + pending: tuple[str, int] | None = None + active: tuple[str, int] | None = None + + for number, (original, code, depth) in enumerate(zip(lines, masked_lines, depths, strict=True), 1): + blank = not original.strip() and number not in protected + if blank and previous_blank: + errors.append(f"{path}:{number}: consecutive blank Rust source lines") + previous_blank = blank + + if active is not None and depth <= active[1]: + active = None + if active is not None and blank: + errors.append(f"{path}:{number}: blank line inside {active[0]} body") + + if active is None and pending is None: + match = _BODY.match(code) + if match is not None: + pending = (match.group(1), depth) + if pending is not None: + if "{" in code: + active = pending + pending = None + elif ";" in code: + pending = None + return errors + + +def main() -> int: + """Run the read-only spacing audit on explicitly selected Rust files.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Rust files or directories to inspect") + args = parser.parse_args() + files, errors = selected_files(args.paths) + if errors or not files: + for error in errors or ["no Rust files selected"]: + print(f"Rust blank-line audit: {error}", file=sys.stderr) + return 2 + for path in files: + try: + errors.extend(audit_file(path)) + except (OSError, UnicodeError) as error: + print(f"Rust blank-line audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Rust blank-line audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + print(f"Rust blank-line audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_export_completeness.py b/scripts/audit_rust_export_completeness.py new file mode 100644 index 0000000..6dae51b --- /dev/null +++ b/scripts/audit_rust_export_completeness.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_export_completeness.py +# version: 1 + +"""Audit Rust crate-root façade completeness and canonical same-crate references.""" + +from __future__ import annotations + +import argparse +import dataclasses +import pathlib +import re +import sys +import tomllib + +from _rust_audit_common import line_depths, mask_source + +_IGNORED_DIRECTORY_NAMES = frozenset({ + ".git", + ".venv", + "__pycache__", + "build", + "builds", + "dist", + "node_modules", + "target", +}) + + +@dataclasses.dataclass(frozen=True) +class Candidate: + """One façade or canonical-reference violation candidate.""" + + code: str + path: str + line: int + message: str + + +@dataclasses.dataclass(frozen=True) +class Declaration: + """One module-level public or crate-public declaration.""" + + module: str + name: str + visibility: str + kind: str + path: pathlib.Path + line: int + 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: + """Return whether a path belongs to a generated or local-only tree.""" + + try: + relative = path.relative_to(root) + except ValueError: + return True + return bool(set(relative.parts) & _IGNORED_DIRECTORY_NAMES) + + +def crate_roots(root: pathlib.Path) -> list[pathlib.Path]: + """Discover Rust package roots recursively below one project root.""" + + crates: set[pathlib.Path] = set() + for manifest in root.rglob("Cargo.toml"): + if _is_ignored(manifest, root): + continue + crate = manifest.parent + 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 _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(): + 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_children(path: pathlib.Path, target_root: pathlib.Path) -> list[tuple[pathlib.Path, str]]: + """Return top-level external module declarations from one Rust source file.""" + + 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 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() + masked, _ = mask_source(text) + depths = line_depths(masked) + pattern = re.compile( + r"^\s*(pub(?:\(crate\))?)\s+" + r"(?:(?:async|unsafe|const)\s+)*" + r"(const|static|type|struct|enum|trait|union|fn)\s+" + r"([A-Za-z_][A-Za-z0-9_]*)" + ) + found: list[Declaration] = [] + for line_number, line in enumerate(lines, 1): + if line_number - 1 >= len(depths) or depths[line_number - 1] != 0: + continue + match = pattern.match(line) + if match is None: + continue + found.append( + Declaration( + 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 + + +def _parse_export_item(module: str, item: str) -> tuple[tuple[str, str], str] | None: + """Parse one simple explicit item from a crate-root re-export.""" + + stripped = item.strip() + if not stripped or stripped == "*" or "{" in stripped or "}" in stripped: + return None + alias_match = re.fullmatch( + r"([A-Za-z_][A-Za-z0-9_]*)(?:\s+as\s+([A-Za-z_][A-Za-z0-9_]*))?", + stripped, + ) + if alias_match is None: + return None + name = alias_match.group(1) + alias = alias_match.group(2) or name + return (module, name), alias + + +def root_exports(root_source: pathlib.Path) -> dict[tuple[str, str], str]: + """Return explicit crate-root re-exports keyed by source module and symbol.""" + + exports: dict[tuple[str, str], str] = {} + text = root_source.read_text(encoding="utf-8") + pattern = re.compile( + r"^\s*pub(?:\(crate\))?\s+use\s+(?:self|crate)::(.*?);", + re.MULTILINE | re.DOTALL, + ) + for match in pattern.finditer(text): + body = " ".join(part.strip() for part in match.group(1).splitlines()) + if "{" in body and body.endswith("}"): + prefix, group = body.split("{", 1) + module = prefix.rstrip().rstrip(":") + for item in group[:-1].split(","): + parsed = _parse_export_item(module, item) + if parsed is not None: + key, alias = parsed + exports[key] = alias + continue + if "::" not in body: + continue + module, item = body.rsplit("::", 1) + parsed = _parse_export_item(module, item) + if parsed is not None: + key, alias = parsed + exports[key] = alias + return exports + + +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}" + 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 or super_direct in masked: + return True + return False + + +def _declaration_lookup(declarations: list[Declaration]) -> dict[tuple[pathlib.Path, int, str], Declaration]: + """Index declaration-name occurrences that must not be treated as references.""" + + 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 [] + + 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 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 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 packages and all façade candidates below one project root.""" + + crates = crate_roots(root) + 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 + + +def main() -> int: + """Run the export-completeness audit.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--report-only", action="store_true") + parser.add_argument("--summary-only", action="store_true") + arguments = parser.parse_args() + + workspace = pathlib.Path(arguments.root).resolve() + if not workspace.is_dir(): + sys.stderr.write(f"Rust export completeness audit: invalid root: {workspace}\n") + return 2 + + crates, candidates = audit_workspace(workspace) + if not crates: + 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)} 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_item_spacing.py b/scripts/audit_rust_item_spacing.py new file mode 100644 index 0000000..d2d3156 --- /dev/null +++ b/scripts/audit_rust_item_spacing.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_item_spacing.py +# version: 1 + +"""Audit exact spacing between mechanically detectable Rust items.""" + +from __future__ import annotations + +import argparse +import dataclasses +import pathlib +import re +import sys + +from _rust_audit_common import line_depths, mask_source, selected_files + + +@dataclasses.dataclass(frozen=True) +class RustItem: + """One mechanically detected Rust item and its source span.""" + + kind: str + visibility: str + start: int + declaration: int + end: int + depth: int + braced: bool + + +def _visibility(stripped: str) -> str: + if stripped.startswith("pub(crate) "): + return "pub(crate)" + if stripped.startswith("pub "): + return "pub" + return "private" + + +def _kind(stripped: str) -> str | None: + prefix = r"(?:(?:pub|pub\(crate\))\s+)?" + qualifiers = r"(?:(?:async|const|unsafe)\s+)*" + if re.match(rf"^{prefix}{qualifiers}fn\b", stripped): + return "fn" + for kind in ("struct", "enum", "union", "trait", "impl", "const", "static", "type"): + if re.match(rf"^{prefix}{qualifiers}{kind}\b", stripped): + return kind + return None + + +def _leading_line(lines: list[str], declaration: int) -> int: + cursor = declaration - 2 + while cursor >= 0: + stripped = lines[cursor].strip() + if stripped.startswith("///") or stripped.startswith("#["): + cursor -= 1 + continue + break + return cursor + 2 + + +def _end_line(masked_lines: list[str], depths: list[int], declaration: int, kind: str) -> tuple[int, bool]: + start_depth = depths[declaration - 1] + if kind in {"const", "static", "type"}: + brackets: list[str] = [] + closing = {"}": "{", ")": "(", "]": "["} + for line_index in range(declaration - 1, len(masked_lines)): + for char in masked_lines[line_index]: + if char in "{([": + brackets.append(char) + elif char in "})]" and brackets and brackets[-1] == closing[char]: + brackets.pop() + elif char == ";" and not brackets: + return line_index + 1, False + return declaration, False + body_started = False + for line_index in range(declaration - 1, len(masked_lines)): + masked_line = masked_lines[line_index] + if not body_started and ";" in masked_line and "{" not in masked_line: + return line_index + 1, False + if "{" in masked_line: + body_started = True + if body_started: + end_depth = depths[line_index] + masked_line.count("{") - masked_line.count("}") + if end_depth == start_depth: + return line_index + 1, True + return declaration, body_started + + +def _items(lines: list[str], masked_lines: list[str], depths: list[int]) -> list[RustItem]: + result: list[RustItem] = [] + for number, line in enumerate(lines, 1): + stripped = line.strip() + kind = _kind(stripped) + if kind is None: + continue + end, braced = _end_line(masked_lines, depths, number, kind) + result.append(RustItem(kind, _visibility(stripped), _leading_line(lines, number), number, end, depths[number - 1], braced)) + return result + + +def _parent(item: RustItem, items: list[RustItem]) -> RustItem | None: + parents = [candidate for candidate in items if candidate.braced and candidate.declaration < item.declaration <= candidate.end and candidate.depth < item.depth] + if not parents: + return None + return max(parents, key=lambda candidate: candidate.depth) + + +def audit_file(path: pathlib.Path) -> list[str]: + """Require exact separation between adjacent items in one Rust scope.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked, _ = mask_source(text) + masked_lines = masked.splitlines() + depths = line_depths(masked) + items = _items(lines, masked_lines, depths) + siblings: dict[tuple[int, int] | None, list[RustItem]] = {} + for item in items: + parent = _parent(item, items) + key = None if parent is None else (parent.declaration, parent.end) + siblings.setdefault(key, []).append(item) + + errors: list[str] = [] + for group in siblings.values(): + group.sort(key=lambda item: item.declaration) + for previous, current in zip(group, group[1:]): + between = lines[previous.end:current.start - 1] + # Unknown syntax or a macro between items owns its own layout; do not infer across it. + if any(candidate.strip() for candidate in between): + continue + blank_count = sum(1 for candidate in between if not candidate.strip()) + compact = previous.kind == current.kind and previous.kind in {"const", "static", "type"} and previous.visibility == current.visibility + if compact: + # Compact declaration blocks have a dedicated owner when a project selects one. + continue + if blank_count != 1: + errors.append(f"{path}:{current.start}: expected exactly one blank line between Rust items; found {blank_count}") + return errors + + +def main() -> int: + """Run the read-only item-spacing audit on explicitly selected Rust files.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Rust files or directories to inspect") + args = parser.parse_args() + files, errors = selected_files(args.paths) + if errors or not files: + for error in errors or ["no Rust files selected"]: + print(f"Rust item-spacing audit: {error}", file=sys.stderr) + return 2 + for path in files: + try: + errors.extend(audit_file(path)) + except (OSError, UnicodeError) as error: + print(f"Rust item-spacing audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Rust item-spacing audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + print(f"Rust item-spacing audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_module_order.py b/scripts/audit_rust_module_order.py new file mode 100644 index 0000000..1d2c13d --- /dev/null +++ b/scripts/audit_rust_module_order.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_module_order.py +# version: 1 + +"""Audit top-level Rust module ordering with test modules last.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from _rust_audit_common import line_depths, mask_source, selected_files + + +def _natural_key(value: str) -> tuple[tuple[int, object], ...]: + parts = re.findall(r"[A-Za-z_]+|[0-9]+", value) + return tuple((1, int(part)) if part.isdigit() else (0, part) for part in parts) + + +def audit_file(path: pathlib.Path) -> list[str]: + """Require production modules before test modules and alphabetic order within each group.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked, _ = mask_source(text) + depths = line_depths(masked) + modules: list[tuple[int, bool, str]] = [] + for number, line in enumerate(lines, 1): + if depths[number - 1] != 0: + continue + match = re.match(r"^mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:;|\{)", line.strip()) + if match is None: + continue + cursor = number - 2 + is_test = False + while cursor >= 0 and lines[cursor].strip().startswith("#["): + if "cfg(test)" in lines[cursor].replace(" ", ""): + is_test = True + cursor -= 1 + modules.append((number, is_test, match.group(1))) + + production = [(number, name) for number, is_test, name in modules if not is_test] + tests = [(number, name) for number, is_test, name in modules if is_test] + errors: list[str] = [] + if production and tests and min(number for number, _ in tests) < max(number for number, _ in production): + errors.append(f"{path}:{min(number for number, _ in tests)}: test modules must follow production modules") + for group, label in ((production, "production"), (tests, "test")): + names = [name for _, name in group] + expected = sorted(names, key=_natural_key) + if names != expected: + for (number, name), wanted in zip(group, expected, strict=True): + if name != wanted: + errors.append(f"{path}:{number}: {label} module declarations are not alphabetically ordered") + break + return errors + + +def main() -> int: + """Run the read-only module-order audit on explicitly selected Rust files.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Rust files or directories to inspect") + args = parser.parse_args() + files, errors = selected_files(args.paths) + if errors or not files: + for error in errors or ["no Rust files selected"]: + print(f"Rust module-order audit: {error}", file=sys.stderr) + return 2 + for path in files: + try: + errors.extend(audit_file(path)) + except (OSError, UnicodeError) as error: + print(f"Rust module-order audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Rust module-order audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + print(f"Rust module-order audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_reexport_blocks.py b/scripts/audit_rust_reexport_blocks.py new file mode 100644 index 0000000..d216193 --- /dev/null +++ b/scripts/audit_rust_reexport_blocks.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_reexport_blocks.py +# version: 1 + +"""Audit crate-root Rust re-export block visibility and spacing.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from _rust_audit_common import line_depths, mask_source, selected_files + +_EXPORT = re.compile(r"^(pub(?:\(crate\))?)\s+use\s+") + + +def audit_file(path: pathlib.Path) -> list[str]: + """Require public re-exports before crate-public re-exports and compact blocks.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked, _ = mask_source(text) + depths = line_depths(masked) + exports: list[tuple[int, str]] = [] + for number, line in enumerate(lines, 1): + if depths[number - 1] != 0: + continue + match = _EXPORT.match(line.strip()) + if match is None: + continue + visibility = "pub(crate)" if match.group(1) == "pub(crate)" else "pub" + exports.append((number, visibility)) + + errors: list[str] = [] + crate_seen = False + previous_by_visibility: dict[str, int] = {} + for number, visibility in exports: + if visibility == "pub(crate)": + crate_seen = True + elif crate_seen: + errors.append(f"{path}:{number}: pub use block must precede pub(crate) use block") + previous = previous_by_visibility.get(visibility) + if previous is not None: + between = lines[previous:number - 1] + if any(not candidate.strip() for candidate in between): + errors.append(f"{path}:{number}: {visibility} use block contains an empty line") + previous_by_visibility[visibility] = number + + public = [number for number, visibility in exports if visibility == "pub"] + crate_public = [number for number, visibility in exports if visibility == "pub(crate)"] + if public and crate_public: + transition = lines[public[-1]:crate_public[0] - 1] + blank_count = sum(1 for candidate in transition if not candidate.strip()) + if blank_count != 1: + errors.append(f"{path}:{crate_public[0]}: pub use and pub(crate) use blocks require exactly one blank line; found {blank_count}") + return errors + + +def main() -> int: + """Run the read-only re-export-block audit on explicitly selected Rust files.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Rust crate-root files or directories to inspect") + args = parser.parse_args() + files, errors = selected_files(args.paths) + if errors or not files: + for error in errors or ["no Rust files selected"]: + print(f"Rust re-export-block audit: {error}", file=sys.stderr) + return 2 + for path in files: + try: + errors.extend(audit_file(path)) + except (OSError, UnicodeError) as error: + print(f"Rust re-export-block audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Rust re-export-block audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + print(f"Rust re-export-block audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit_rust_trait_import_blocks.py b/scripts/audit_rust_trait_import_blocks.py new file mode 100644 index 0000000..c46dec4 --- /dev/null +++ b/scripts/audit_rust_trait_import_blocks.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# file: scripts/audit_rust_trait_import_blocks.py +# version: 1 + +"""Audit placement and contiguity of module-level Rust trait-import blocks.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from _rust_audit_common import line_depths, mask_source, selected_files + +_DECLARATION = re.compile(r"^(?:pub(?:\(crate\))?\s+)?(?:async\s+|const\s+|unsafe\s+)*(?:const|static|type|struct|enum|trait|union|fn|impl)\b") +_USE = re.compile(r"^use\s+") + + +def audit_file(path: pathlib.Path) -> list[str]: + """Require ordinary module-level uses before declarations and in one compact block.""" + + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + masked, _ = mask_source(text) + depths = line_depths(masked) + seen_declaration = False + uses: list[int] = [] + errors: list[str] = [] + for number, line in enumerate(lines, 1): + if depths[number - 1] != 0: + continue + stripped = line.strip() + if _DECLARATION.match(stripped): + seen_declaration = True + if _USE.match(stripped): + uses.append(number) + if seen_declaration: + errors.append(f"{path}:{number}: module-level trait import must precede Rust declarations") + for previous, current in zip(uses, uses[1:]): + between = lines[previous:current - 1] + if any(not candidate.strip() for candidate in between): + errors.append(f"{path}:{current}: module-level trait import block contains an empty line") + return errors + + +def main() -> int: + """Run the read-only trait-import-block audit on explicitly selected Rust files.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", help="Rust files or directories to inspect") + args = parser.parse_args() + files, errors = selected_files(args.paths) + if errors or not files: + for error in errors or ["no Rust files selected"]: + print(f"Rust trait-import-block audit: {error}", file=sys.stderr) + return 2 + for path in files: + try: + errors.extend(audit_file(path)) + except (OSError, UnicodeError) as error: + print(f"Rust trait-import-block audit: cannot read {path}: {error}", file=sys.stderr) + return 2 + for error in errors: + print(error, file=sys.stderr) + if errors: + print(f"Rust trait-import-block audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) + return 1 + print(f"Rust trait-import-block audit: clean ({len(files)} file(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())