0.0.1-alpha.3

This commit is contained in:
2026-09-27 22:23:43 +02:00
parent 8cd187b700
commit 6b6bdcfc71
29 changed files with 1973 additions and 32 deletions

View File

@@ -1,5 +1,5 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Changelog SaseDev Knowledge # 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 ; - 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 ; - 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é ; - 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 ; - 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. - prépare un workspace Rust et une baseline Web composables sans construire prématurément la plateforme complète.

16
Cargo.lock generated
View File

@@ -4,4 +4,18 @@ version = 4
[[package]] [[package]]
name = "knowledge-domain-lib" 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",
]

View File

@@ -1,17 +1,22 @@
# file: Cargo.toml # file: Cargo.toml
# version: 1 # version: 2
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/knowledge-domain-lib"] members = [
"crates/knowledge-domain-lib",
"crates/knowledge-git-api",
"crates/knowledge-sync-api",
]
[workspace.package] [workspace.package]
version = "0.0.1-alpha.2" version = "0.0.1-alpha.3"
edition = "2024" edition = "2024"
publish = false publish = false
repository = "https://git.sasedev.com/Sasedev/sasedev-knowledge" repository = "https://git.sasedev.com/Sasedev/sasedev-knowledge"
[workspace.dependencies] [workspace.dependencies]
knowledge-domain-lib = { path = "crates/knowledge-domain-lib" }
[workspace.lints.rust] [workspace.lints.rust]
missing_docs = "warn" missing_docs = "warn"

View File

@@ -1,5 +1,5 @@
<!-- file: README.md --> <!-- file: README.md -->
<!-- version: 3 --> <!-- version: 4 -->
# SaseDev Knowledge # 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. 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 ## Invariants de fondation
- Git est la source autoritaire du contenu versionné. - Git est la source autoritaire du contenu versionné.

View File

@@ -1,5 +1,5 @@
<!-- file: RULES.md --> <!-- file: RULES.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Règles SaseDev Knowledge # 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-`. 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 ## Secrets et configuration locale

View File

@@ -1 +1 @@
0.0.1-alpha.2 0.0.1-alpha.3

View File

@@ -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,
}

View File

@@ -1,8 +1,16 @@
// file: crates/knowledge-domain-lib/src/lib.rs // 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 //! The crate owns content-family and source identities that are independent of HTTP, PostgreSQL,
//! domain contracts without introducing the Git/synchronization contracts reserved for the next //! forge providers and concrete Git implementations.
//! tranche.
mod content_family;
mod source;
pub use crate::content_family::ContentFamily;
pub use crate::source::{ContentSource, SourceId, SourceIdError};
#[cfg(test)]
mod unit_tests;

View File

@@ -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<crate::SourceId, crate::SourceIdError> {
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<crate::ContentSource, crate::SourceIdError> {
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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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<crate::GitRevision, Self::Error>;
}
#[cfg(test)]
mod unit_tests;

View File

@@ -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<crate::GitRevision, Self::Error> {
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;
}

View File

@@ -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

View File

@@ -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<Option<crate::RepositorySyncSignal>, Self::Error>;
}
#[cfg(test)]
mod unit_tests;

View File

@@ -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<crate::RepositorySyncSignal>,
}
impl crate::RepositoryEventSource for OneShotEventSource {
type Error = FakeEventError;
fn next_signal(&mut self) -> Result<Option<crate::RepositorySyncSignal>, 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;
}

232
deltas/0.0.1/alpha.3.md Normal file
View File

@@ -0,0 +1,232 @@
<!-- file: deltas/0.0.1/alpha.3.md -->
<!-- version: 1 -->
# 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/architecture/001-PROJECT_PROFILE.md --> <!-- file: docs/architecture/001-PROJECT_PROFILE.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Profil du projet Knowledge # 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 | | `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 | | `300-DOCUMENTATION` | `ADOPT` | `alpha.1` | rôles documentaires et taxonomie utile |
| `400-VALIDATION` | `ADOPT` | `alpha.1` | preuves proportionnées et autorité opérateur | | `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 | | `530-TYPESCRIPT` | `ADOPT` | tranche Web | frontend durable TypeScript |
| `540-JAVASCRIPT_PACKAGE_ECOSYSTEM` | `ADOPT` | tranche Web | package manager/lockfile/scripts explicites | | `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 | | `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 | | 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é | | 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 | | 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 | | `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é | | 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 | | 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 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 ```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-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-store-postgres-lib état applicatif PostgreSQL partagé
knowledge-forge-gitea-lib adapter Gitea optionnel 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 knowledge-web-lib composition Web réellement partagée, seulement si les trois serveurs dupliqueraient sinon du code

View File

@@ -1,5 +1,5 @@
<!-- file: docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md --> <!-- file: docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Architecture de fondation Knowledge # Architecture de fondation Knowledge
@@ -15,7 +15,15 @@ Le principe directeur reste :
## Workspace Rust matérialisé ## 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/`. 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. 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 ## 1. Applications publiques
Les trois façades publiques sont des unités de déploiement distinctes : Les trois façades publiques sont des unités de déploiement distinctes :

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/001-V0_0_1_FOUNDATION_PLAN.md --> <!-- file: docs/plans/001-V0_0_1_FOUNDATION_PLAN.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Plan 0.0.1 — fondation SaseDev Knowledge # 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 ### 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. 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.
É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.
### alpha.3 — contrats domaine/Git/synchronisation ### 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. 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. Ne pas ajouter d'API Gitea ni de persistance PostgreSQL par anticipation.
### alpha.4 — baseline Web indépendante ### alpha.4 — baseline Web indépendante

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/001-VALIDATION_MATRIX.md --> <!-- file: docs/validation/001-VALIDATION_MATRIX.md -->
<!-- version: 3 --> <!-- version: 4 -->
# Matrice de validation Knowledge # 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 ; - é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 ; - surfaces admin locales documentées sans figer `/admin`, `/login` ou un modèle de rôles ;
- hygiène Git et headers documentaires applicables. - 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.

157
history/0.0.1/alpha.3.md Normal file
View File

@@ -0,0 +1,157 @@
<!-- file: history/0.0.1/alpha.3.md -->
<!-- version: 1 -->
# 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.

View File

@@ -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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())

View File

@@ -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())