From 88a63b06cc7f2e831ab61062c3145ca60b3af370 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Tue, 29 Sep 2026 11:31:59 +0200 Subject: [PATCH] 0.0.3-alpha.2 --- Cargo.toml | 4 +- README.md | 2 +- VERSION | 2 +- crates/knowledge-git-api/src/lib.rs | 8 +- crates/knowledge-git-api/src/write.rs | 319 ++++++++++++++++++ crates/knowledge-git-api/unit_tests/lib.rs | 153 ++++++++- deltas/0.0.3/alpha.2.md | 232 +++++++++++++ .../002-KNOWLEDGE_ARCHITECTURE.md | 4 + docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md | 4 +- docs/validation/001-VALIDATION_MATRIX.md | 20 +- history/0.0.3/alpha.2.md | 142 ++++++++ 11 files changed, 881 insertions(+), 9 deletions(-) create mode 100644 crates/knowledge-git-api/src/write.rs create mode 100644 deltas/0.0.3/alpha.2.md create mode 100644 history/0.0.3/alpha.2.md diff --git a/Cargo.toml b/Cargo.toml index fc59ee0..93eccaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ # file: Cargo.toml -# version: 32 +# version: 33 [workspace] resolver = "3" @@ -16,7 +16,7 @@ members = [ ] [workspace.package] -version = "0.0.3-alpha.1.fix.2" +version = "0.0.3-alpha.2" edition = "2024" publish = false repository = "https://git.sasedev.com/Sasedev/sasedev-knowledge" diff --git a/README.md b/README.md index b7a97cc..a3f0490 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ La version courante est définie par [`VERSION`](VERSION). `0.0.1` est la première stable de fondation de SaseDev Knowledge. `0.0.2` est la stable du chemin Git générique de lecture, de l'autonomie normative locale et du cas d'usage partagé de synchronisation `Changed` / `Unchanged`. -`0.0.3-alpha.1` ouvre la release d'écriture Git contrôlée et de push gardé. Cette première tranche est exclusivement un cadrage : elle sélectionne un worktree d'écriture isolé, un contrat d'écriture distinct de la lecture et une mise à jour distante avec attente explicite sur l'état précédent. Aucun code produit n'est encore modifié. PostgreSQL, forge, workflow complet de proposition, rebase/merge automatique et auth/admin restent hors scope. +`0.0.3-alpha.2` matérialise le contrat d'écriture Git provider-neutral dans `knowledge-git-api` : chemins de mutation validés, create/update/delete, identité de commit explicite, branche `refs/heads/...` validée, état distant attendu, requêtes candidate/push et conflit explicite. Le contrat reste distinct de `GitRepository` afin de préserver les consommateurs lecture seule. Le worktree d'écriture et le push Git réels restent réservés aux tranches suivantes ; PostgreSQL, forge, workflow complet de proposition, rebase/merge automatique et auth/admin restent hors scope. `knowledge-git-lib` implémente `knowledge-git-api::GitRepository` pour des sources physiques enregistrées : mirror contrôlé, refresh avec pruning, résolution d'une référence vers un commit et snapshot/worktree détaché adressé par révision. `alpha.4` ne change pas cette sémantique. Les règles nécessaires au développement courant sont désormais possédées localement sous `docs/rules/`, et les futurs prompts Knowledge ne chargent pas PGR sauf réévaluation explicitement engagée. `knowledge-app-lib` compose maintenant refresh, résolution, comparaison avec la révision précédente et préparation de snapshot en un résultat explicite `Changed` / `Unchanged`, sans persister la dernière révision ni matérialiser de scheduler. diff --git a/VERSION b/VERSION index f55a30b..553d4a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.3-alpha.1.fix.2 +0.0.3-alpha.2 diff --git a/crates/knowledge-git-api/src/lib.rs b/crates/knowledge-git-api/src/lib.rs index 43a4aae..73b5609 100644 --- a/crates/knowledge-git-api/src/lib.rs +++ b/crates/knowledge-git-api/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/knowledge-git-api/src/lib.rs -// version: 4 +// version: 5 //! Provider-neutral contracts for Git-backed Knowledge sources. //! @@ -10,6 +10,7 @@ mod reference; mod repository; mod revision; mod snapshot; +mod write; /// Opaque Git reference requested by a caller, such as a branch, tag or object expression. pub use self::reference::GitReference; @@ -19,6 +20,11 @@ pub use self::repository::GitRepository; pub use self::revision::GitRevision; /// Read-only local materialization of one authoritative Git revision. pub use self::snapshot::GitSnapshot; +/// Provider-neutral types and operations for controlled Git writes. +pub use self::write::{ + GitBranch, GitCommitIdentity, GitFileMutation, GitMutationPath, GitPushOutcome, GitPushRequest, GitRemoteState, GitRepositoryWriter, GitWriteInputError, + GitWriteRequest, +}; #[cfg(test)] #[path = "../unit_tests/lib.rs"] diff --git a/crates/knowledge-git-api/src/write.rs b/crates/knowledge-git-api/src/write.rs new file mode 100644 index 0000000..f11c9e8 --- /dev/null +++ b/crates/knowledge-git-api/src/write.rs @@ -0,0 +1,319 @@ +// file: crates/knowledge-git-api/src/write.rs +// version: 1 + +/// Invalid value supplied to the provider-neutral Git write contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GitWriteInputError { + /// A mutation path is empty or contains only whitespace. + EmptyMutationPath, + /// A mutation path is absolute or uses a platform-specific separator. + AbsoluteMutationPath, + /// A mutation path contains an empty, current-directory or parent-directory component. + UnnormalizedMutationPath, + /// A mutation path addresses Git's private `.git` area. + ProtectedGitPath, + /// A commit identity name is empty or contains a forbidden line break or NUL byte. + InvalidCommitIdentityName, + /// A commit identity email is empty or contains a forbidden line break or NUL byte. + InvalidCommitIdentityEmail, + /// A commit message is empty or contains a NUL byte. + InvalidCommitMessage, + /// A write request does not contain any file mutation. + NoMutations, + /// More than one mutation targets the same normalized path. + DuplicateMutationPath, + /// A target branch is not a valid full `refs/heads/...` Git reference. + InvalidBranch, +} + +impl std::fmt::Display for crate::GitWriteInputError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + crate::GitWriteInputError::EmptyMutationPath => return formatter.write_str("Git mutation path must not be empty"), + crate::GitWriteInputError::AbsoluteMutationPath => return formatter.write_str("Git mutation path must be relative and use '/' separators"), + crate::GitWriteInputError::UnnormalizedMutationPath => return formatter.write_str("Git mutation path must be normalized"), + crate::GitWriteInputError::ProtectedGitPath => return formatter.write_str("Git mutation path must not address .git"), + crate::GitWriteInputError::InvalidCommitIdentityName => return formatter.write_str("Git commit identity name is invalid"), + crate::GitWriteInputError::InvalidCommitIdentityEmail => return formatter.write_str("Git commit identity email is invalid"), + crate::GitWriteInputError::InvalidCommitMessage => return formatter.write_str("Git commit message is invalid"), + crate::GitWriteInputError::NoMutations => return formatter.write_str("Git write request must contain at least one mutation"), + crate::GitWriteInputError::DuplicateMutationPath => return formatter.write_str("Git write request must not mutate one path more than once"), + crate::GitWriteInputError::InvalidBranch => return formatter.write_str("Git target branch must be a valid refs/heads reference"), + } + } +} + +impl std::error::Error for crate::GitWriteInputError {} + +/// Normalized relative path that may be mutated inside an isolated Git write worktree. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct GitMutationPath(String); + +impl crate::GitMutationPath { + /// Validate and build a provider-neutral repository-relative mutation path. + pub fn new(value: &str) -> Result { + if value.trim().is_empty() { + return Err(crate::GitWriteInputError::EmptyMutationPath); + } + if value.starts_with('/') || value.contains('\\') || crate::write::has_windows_drive_prefix(value) { + return Err(crate::GitWriteInputError::AbsoluteMutationPath); + } + if value.contains('\0') { + return Err(crate::GitWriteInputError::UnnormalizedMutationPath); + } + for component in value.split('/') { + if component.is_empty() || component == "." || component == ".." { + return Err(crate::GitWriteInputError::UnnormalizedMutationPath); + } + if component.eq_ignore_ascii_case(".git") { + return Err(crate::GitWriteInputError::ProtectedGitPath); + } + } + return Ok(Self(value.to_owned())); + } + + /// Return the normalized repository-relative path. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// One file mutation requested for a candidate Git commit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitFileMutation { + /// Create or replace one file with the provided bytes. + Write { + /// Validated repository-relative file path. + path: crate::GitMutationPath, + /// Complete bytes that must become the file content. + content: Vec, + }, + /// Delete one existing path from the candidate tree. + Delete { + /// Validated repository-relative file path. + path: crate::GitMutationPath, + }, +} + +impl crate::GitFileMutation { + /// Return the validated path targeted by this mutation. + pub fn path(&self) -> &crate::GitMutationPath { + match self { + crate::GitFileMutation::Write { path, .. } => return path, + crate::GitFileMutation::Delete { path } => return path, + } + } +} + +/// Explicit author and committer identity used to create a candidate commit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitCommitIdentity { + name: String, + email: String, +} + +impl crate::GitCommitIdentity { + /// Validate and build an explicit Git commit identity. + pub fn new(name: &str, email: &str) -> Result { + if crate::write::invalid_identity_component(name) { + return Err(crate::GitWriteInputError::InvalidCommitIdentityName); + } + if crate::write::invalid_identity_component(email) { + return Err(crate::GitWriteInputError::InvalidCommitIdentityEmail); + } + return Ok(Self { name: name.to_owned(), email: email.to_owned() }); + } + + /// Return the explicit identity name. + pub fn name(&self) -> &str { + return self.name.as_str(); + } + + /// Return the explicit identity email. + pub fn email(&self) -> &str { + return self.email.as_str(); + } +} + +/// Validated full Git branch reference accepted as a remote write target. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct GitBranch(String); + +impl crate::GitBranch { + /// Validate a full `refs/heads/...` branch reference. + pub fn new(value: &str) -> Result { + if !crate::write::valid_branch_reference(value) { + return Err(crate::GitWriteInputError::InvalidBranch); + } + return Ok(Self(value.to_owned())); + } + + /// Return the validated full branch reference. + pub fn as_str(&self) -> &str { + return self.0.as_str(); + } +} + +/// Expected or observed state of one remote branch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitRemoteState { + /// The branch does not exist. + Absent, + /// The branch resolves to the provided revision. + Revision(crate::GitRevision), +} + +/// Request to build one candidate commit from an explicit base revision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitWriteRequest { + base_revision: crate::GitRevision, + mutations: Vec, + identity: crate::GitCommitIdentity, + message: String, +} + +impl crate::GitWriteRequest { + /// Validate and build a candidate-commit request. + pub fn new( + base_revision: crate::GitRevision, + mutations: Vec, + identity: crate::GitCommitIdentity, + message: &str, + ) -> Result { + if mutations.is_empty() { + return Err(crate::GitWriteInputError::NoMutations); + } + if message.trim().is_empty() || message.contains('\0') { + return Err(crate::GitWriteInputError::InvalidCommitMessage); + } + let mut paths = std::collections::HashSet::new(); + for mutation in &mutations { + if !paths.insert(mutation.path().as_str()) { + return Err(crate::GitWriteInputError::DuplicateMutationPath); + } + } + return Ok(Self { base_revision, mutations, identity, message: message.to_owned() }); + } + + /// Return the resolved base revision from which the candidate must be created. + pub fn base_revision(&self) -> &crate::GitRevision { + return &self.base_revision; + } + + /// Return the ordered mutations to apply in the isolated write worktree. + pub fn mutations(&self) -> &[crate::GitFileMutation] { + return self.mutations.as_slice(); + } + + /// Return the explicit author/committer identity. + pub fn identity(&self) -> &crate::GitCommitIdentity { + return &self.identity; + } + + /// Return the candidate commit message. + pub fn message(&self) -> &str { + return self.message.as_str(); + } +} + +/// Request to compare-and-exchange one remote branch to a candidate revision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GitPushRequest { + target_branch: crate::GitBranch, + expected_remote: crate::GitRemoteState, + candidate_revision: crate::GitRevision, +} + +impl crate::GitPushRequest { + /// Build a guarded remote update request from already validated values. + pub fn new(target_branch: crate::GitBranch, expected_remote: crate::GitRemoteState, candidate_revision: crate::GitRevision) -> crate::GitPushRequest { + return Self { target_branch, expected_remote, candidate_revision }; + } + + /// Return the target remote branch. + pub fn target_branch(&self) -> &crate::GitBranch { + return &self.target_branch; + } + + /// Return the remote state that must still hold for the update to succeed. + pub fn expected_remote(&self) -> &crate::GitRemoteState { + return &self.expected_remote; + } + + /// Return the candidate revision to publish. + pub fn candidate_revision(&self) -> &crate::GitRevision { + return &self.candidate_revision; + } +} + +/// Result of one guarded remote branch update. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GitPushOutcome { + /// The target branch was updated to the candidate revision. + Updated( + /// Revision now expected at the updated target branch. + crate::GitRevision, + ), + /// The target branch no longer matched the expected state. + Conflict { + /// Actual remote state observed when the guarded update was rejected. + actual: crate::GitRemoteState, + }, +} + +/// Provider-neutral write operations required from a Git repository backend. +/// +/// This capability is intentionally separate from [`crate::GitRepository`] so read-only consumers +/// and fakes do not need to implement write methods. A concrete backend may implement this trait +/// independently from the read contract. +pub trait GitRepositoryWriter { + /// Implementation-specific recoverable error. + type Error; + + /// Create a candidate commit without updating a remote branch. + fn create_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitWriteRequest) -> Result; + + /// Attempt one guarded remote branch update for a previously created candidate revision. + fn push_candidate(&mut self, source_id: &knowledge_domain_lib::SourceId, request: &crate::GitPushRequest) -> Result; +} + +fn has_windows_drive_prefix(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 2 { + return false; + } + return bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; +} + +fn invalid_identity_component(value: &str) -> bool { + return value.trim().is_empty() || value.contains('\0') || value.contains('\n') || value.contains('\r'); +} + +fn valid_branch_reference(value: &str) -> bool { + const PREFIX: &str = "refs/heads/"; + let branch = match value.strip_prefix(PREFIX) { + Some(value) => value, + None => return false, + }; + if branch.is_empty() + || branch.starts_with('/') + || branch.ends_with('/') + || branch.ends_with('.') + || branch.contains("..") + || branch.contains("@{") + || branch.contains("//") + { + return false; + } + for byte in branch.bytes() { + if byte <= 0x20 || byte == 0x7f || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\') { + return false; + } + } + for component in branch.split('/') { + if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") { + return false; + } + } + return true; +} diff --git a/crates/knowledge-git-api/unit_tests/lib.rs b/crates/knowledge-git-api/unit_tests/lib.rs index e088e9a..8cc03b7 100644 --- a/crates/knowledge-git-api/unit_tests/lib.rs +++ b/crates/knowledge-git-api/unit_tests/lib.rs @@ -1,5 +1,5 @@ // file: crates/knowledge-git-api/unit_tests/lib.rs -// version: 3 +// version: 4 #[derive(Debug, Eq, PartialEq)] struct FakeGitError; @@ -22,6 +22,20 @@ impl crate::GitRepository for FakeGitRepository { } } +struct FakeGitWriter; + +impl crate::GitRepositoryWriter for FakeGitWriter { + type Error = FakeGitError; + + fn create_candidate(&mut self, _source_id: &knowledge_domain_lib::SourceId, request: &crate::GitWriteRequest) -> Result { + return Ok(crate::GitRevision::new(&format!("candidate:{}", request.base_revision().as_str()))); + } + + fn push_candidate(&mut self, _source_id: &knowledge_domain_lib::SourceId, request: &crate::GitPushRequest) -> Result { + return Ok(crate::GitPushOutcome::Updated(request.candidate_revision().clone())); + } +} + #[test] fn generic_repository_contract_does_not_require_a_forge() { let source_result = knowledge_domain_lib::SourceId::new("local-pgr"); @@ -43,3 +57,140 @@ fn generic_repository_contract_does_not_require_a_forge() { assert_eq!(snapshot_result, Ok(crate::GitSnapshot::new(revision, std::path::PathBuf::from("/controlled/snapshot")))); return; } + +#[test] +fn mutation_paths_are_relative_normalized_and_protect_git_metadata() { + let valid = crate::GitMutationPath::new("docs/rules/200-GOVERNANCE.md"); + assert_eq!(valid.as_ref().map(crate::GitMutationPath::as_str), Ok("docs/rules/200-GOVERNANCE.md")); + assert_eq!(crate::GitMutationPath::new(""), Err(crate::GitWriteInputError::EmptyMutationPath)); + assert_eq!(crate::GitMutationPath::new("/etc/passwd"), Err(crate::GitWriteInputError::AbsoluteMutationPath)); + assert_eq!(crate::GitMutationPath::new("C:/outside.txt"), Err(crate::GitWriteInputError::AbsoluteMutationPath)); + assert_eq!(crate::GitMutationPath::new("docs\\rules.md"), Err(crate::GitWriteInputError::AbsoluteMutationPath)); + assert_eq!(crate::GitMutationPath::new("docs/../RULES.md"), Err(crate::GitWriteInputError::UnnormalizedMutationPath)); + assert_eq!(crate::GitMutationPath::new("docs/./RULES.md"), Err(crate::GitWriteInputError::UnnormalizedMutationPath)); + assert_eq!(crate::GitMutationPath::new(".git/config"), Err(crate::GitWriteInputError::ProtectedGitPath)); + assert_eq!(crate::GitMutationPath::new("nested/.GIT/config"), Err(crate::GitWriteInputError::ProtectedGitPath)); + return; +} + +#[test] +fn commit_identity_is_explicit_and_rejects_line_breaks() { + let identity = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid"); + assert_eq!(identity.as_ref().map(crate::GitCommitIdentity::name), Ok("Knowledge Publisher")); + assert_eq!(identity.as_ref().map(crate::GitCommitIdentity::email), Ok("knowledge@example.invalid")); + assert_eq!(crate::GitCommitIdentity::new(" ", "knowledge@example.invalid"), Err(crate::GitWriteInputError::InvalidCommitIdentityName)); + assert_eq!( + crate::GitCommitIdentity::new("Knowledge\nPublisher", "knowledge@example.invalid"), + Err(crate::GitWriteInputError::InvalidCommitIdentityName) + ); + assert_eq!(crate::GitCommitIdentity::new("Knowledge Publisher", " "), Err(crate::GitWriteInputError::InvalidCommitIdentityEmail)); + return; +} + +#[test] +fn target_branch_is_limited_to_valid_heads_references() { + let branch = crate::GitBranch::new("refs/heads/knowledge/proposal-42"); + assert_eq!(branch.as_ref().map(crate::GitBranch::as_str), Ok("refs/heads/knowledge/proposal-42")); + for invalid in [ + "main", + "refs/tags/v1", + "refs/heads/", + "refs/heads/.hidden", + "refs/heads/topic..next", + "refs/heads/topic.lock", + "refs/heads/topic@{next", + "refs/heads/topic\\next", + "refs/heads/topic//next", + ] { + assert_eq!(crate::GitBranch::new(invalid), Err(crate::GitWriteInputError::InvalidBranch), "{invalid}"); + } + return; +} + +#[test] +fn write_request_requires_mutations_unique_paths_and_message() { + let identity_result = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid"); + let identity = match identity_result { + Ok(value) => value, + Err(_) => return, + }; + let empty = crate::GitWriteRequest::new(crate::GitRevision::new("base"), Vec::new(), identity.clone(), "candidate"); + assert_eq!(empty, Err(crate::GitWriteInputError::NoMutations)); + let path_result = crate::GitMutationPath::new("content.md"); + let path = match path_result { + Ok(value) => value, + Err(_) => return, + }; + let duplicates = + vec![crate::GitFileMutation::Write { path: path.clone(), content: b"one".to_vec() }, crate::GitFileMutation::Delete { path: path.clone() }]; + let duplicate_request = crate::GitWriteRequest::new(crate::GitRevision::new("base"), duplicates, identity.clone(), "candidate"); + assert_eq!(duplicate_request, Err(crate::GitWriteInputError::DuplicateMutationPath)); + let mutation = crate::GitFileMutation::Write { path, content: b"content".to_vec() }; + let invalid_message = crate::GitWriteRequest::new(crate::GitRevision::new("base"), vec![mutation.clone()], identity.clone(), " "); + assert_eq!(invalid_message, Err(crate::GitWriteInputError::InvalidCommitMessage)); + let request = crate::GitWriteRequest::new(crate::GitRevision::new("base"), vec![mutation], identity, "candidate"); + assert!(request.is_ok()); + return; +} + +#[test] +fn push_request_models_absent_or_revision_remote_state() { + let branch_result = crate::GitBranch::new("refs/heads/proposals/example"); + let branch = match branch_result { + Ok(value) => value, + Err(_) => return, + }; + let candidate = crate::GitRevision::new("candidate"); + let create = crate::GitPushRequest::new(branch.clone(), crate::GitRemoteState::Absent, candidate.clone()); + assert_eq!(create.target_branch(), &branch); + assert_eq!(create.expected_remote(), &crate::GitRemoteState::Absent); + assert_eq!(create.candidate_revision(), &candidate); + let existing = crate::GitPushRequest::new(branch, crate::GitRemoteState::Revision(crate::GitRevision::new("old")), candidate); + assert_eq!(existing.expected_remote(), &crate::GitRemoteState::Revision(crate::GitRevision::new("old"))); + return; +} + +#[test] +fn write_contract_is_separate_from_read_contract_and_provider_neutral() { + let source_result = knowledge_domain_lib::SourceId::new("snippets-extra"); + let source_id = match source_result { + Ok(value) => value, + Err(_) => return, + }; + let path_result = crate::GitMutationPath::new("snippets/example.md"); + let path = match path_result { + Ok(value) => value, + Err(_) => return, + }; + let identity_result = crate::GitCommitIdentity::new("Knowledge Publisher", "knowledge@example.invalid"); + let identity = match identity_result { + Ok(value) => value, + Err(_) => return, + }; + let write_result = crate::GitWriteRequest::new( + crate::GitRevision::new("base"), + vec![crate::GitFileMutation::Write { path, content: b"snippet".to_vec() }], + identity, + "Add snippet", + ); + let write_request = match write_result { + Ok(value) => value, + Err(_) => return, + }; + let mut writer = FakeGitWriter; + let candidate_result = crate::GitRepositoryWriter::create_candidate(&mut writer, &source_id, &write_request); + assert_eq!(candidate_result, Ok(crate::GitRevision::new("candidate:base"))); + let candidate = match candidate_result { + Ok(value) => value, + Err(_) => return, + }; + let branch_result = crate::GitBranch::new("refs/heads/proposals/snippet"); + let branch = match branch_result { + Ok(value) => value, + Err(_) => return, + }; + let push_request = crate::GitPushRequest::new(branch, crate::GitRemoteState::Absent, candidate.clone()); + let push_result = crate::GitRepositoryWriter::push_candidate(&mut writer, &source_id, &push_request); + assert_eq!(push_result, Ok(crate::GitPushOutcome::Updated(candidate))); + return; +} diff --git a/deltas/0.0.3/alpha.2.md b/deltas/0.0.3/alpha.2.md new file mode 100644 index 0000000..2389dd2 --- /dev/null +++ b/deltas/0.0.3/alpha.2.md @@ -0,0 +1,232 @@ + + + +# Delta 0.0.3-alpha.2 — contrat d'écriture Git provider-neutral + +## Baseline requise + +Partir de `0.0.3-alpha.1.fix.2` après application, commit et push de son history `PASS`. + +La version devient : + +```text +0.0.3-alpha.2 +``` + +## Objet + +Matérialiser uniquement la frontière publique provider-neutral nécessaire aux futures opérations d'écriture Git, sans implémenter encore le worktree d'écriture ou le push distant réel. + +La tranche : + +- conserve `GitRepository` comme contrat de lecture inchangé ; +- ajoute `GitRepositoryWriter` comme capacité distincte afin de ne pas imposer l'écriture aux consommateurs/fakes lecture seule ; +- introduit `GitMutationPath` pour des chemins relatifs normalisés, sans `.` / `..`, séparateur plateforme, chemin absolu, drive Windows ou composant `.git` ; +- introduit `GitFileMutation` pour écrire/créer/remplacer ou supprimer un fichier ; +- introduit `GitCommitIdentity` et exige une identité explicite ; +- introduit `GitBranch` et borne la publication à une référence complète valide sous `refs/heads/...` ; +- introduit `GitRemoteState` pour représenter explicitement branche absente ou révision attendue ; +- introduit `GitWriteRequest`, `GitPushRequest` et `GitPushOutcome` pour séparer création de candidate, mise à jour distante gardée et conflit ; +- rejette une requête sans mutation, un message vide/NUL et plusieurs mutations visant le même chemin ; +- documente la matérialisation de la frontière sans déplacer les responsabilités backend prévues pour `alpha.3` / `alpha.4`. + +Aucun processus Git d'écriture n'est lancé par cette tranche. + +## Scope des fichiers + +REPLACE : + +```text +VERSION +Cargo.toml +README.md +crates/knowledge-git-api/src/lib.rs +crates/knowledge-git-api/unit_tests/lib.rs +docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md +docs/validation/001-VALIDATION_MATRIX.md +``` + +ADD : + +```text +crates/knowledge-git-api/src/write.rs +deltas/0.0.3/alpha.2.md +``` + +DELETE : aucun. + +Aucun fichier sous `knowledge-git-lib`, `knowledge-app-lib` ou `Web/` n'est modifié. + +Aucun history `alpha.2` n'est inclus dans ce delta. + +## Contrat public matérialisé + +Le contrat expose les valeurs suivantes depuis la façade `knowledge_git_api` : + +```text +GitWriteInputError +GitMutationPath +GitFileMutation +GitCommitIdentity +GitBranch +GitRemoteState +GitWriteRequest +GitPushRequest +GitPushOutcome +GitRepositoryWriter +``` + +Le chemin conceptuel reste : + +```text +GitRevision de base ++ mutations confinées ++ identité/message explicites +-> create_candidate +-> GitRevision candidate + +GitBranch ++ GitRemoteState attendu ++ GitRevision candidate +-> push_candidate +-> Updated ou Conflict +``` + +`alpha.2` définit ce contrat mais ne prétend pas encore prouver le confinement filesystem face aux symlinks, la neutralisation des hooks, la création effective d'un commit ou le compare-and-exchange distant. Ces preuves appartiennent au backend `knowledge-git-lib` des tranches suivantes. + +## Gate opérateur + +Exécuter depuis la racine du dépôt après application du delta : + +```bash +test "$(cat VERSION)" = '0.0.3-alpha.2' +grep -F 'version = "0.0.3-alpha.2"' Cargo.toml + +git diff --check + +python3 -B scripts/audit_markdown_headers.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history +python3 -B scripts/audit_markdown_links.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history +python3 -B scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts deltas history + +cargo fmt --all +cargo fmt --all -- --check + +python3 -B scripts/audit_rust_test_layout.py crates +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_crate_root_facades.py crates +python3 -B scripts/audit_rust_export_completeness.py --root . --summary-only + +cargo metadata --format-version 1 --no-deps > /tmp/sasedev-knowledge-0.0.3-alpha.2-metadata.json +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p knowledge-git-api --all-targets --all-features +cargo test --workspace --all-targets --all-features + +python3 - <<'PYGATE' +from pathlib import Path + +api = Path('crates/knowledge-git-api/src/lib.rs').read_text(encoding='utf-8') +write = Path('crates/knowledge-git-api/src/write.rs').read_text(encoding='utf-8') +read_contract = Path('crates/knowledge-git-api/src/repository.rs').read_text(encoding='utf-8') +manifest = Path('crates/knowledge-git-api/Cargo.toml').read_text(encoding='utf-8') +plan = Path('docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md').read_text(encoding='utf-8') +changelog = Path('CHANGELOG.md').read_text(encoding='utf-8') + +for item in ( + 'GitWriteInputError', + 'GitMutationPath', + 'GitFileMutation', + 'GitCommitIdentity', + 'GitBranch', + 'GitRemoteState', + 'GitWriteRequest', + 'GitPushRequest', + 'GitPushOutcome', + 'GitRepositoryWriter', +): + assert item in api, item + assert item in write, item + +assert 'fn refresh(' in read_contract +assert 'fn resolve_revision(' in read_contract +assert 'fn prepare_snapshot(' in read_contract +assert 'create_candidate' not in read_contract +assert 'push_candidate' not in read_contract + +assert 'refs/heads/' in write +assert 'ProtectedGitPath' in write +assert 'DuplicateMutationPath' in write +assert 'GitRemoteState::Absent' not in write or 'GitRemoteState' in write +assert 'rebase' not in write.lower() +assert 'merge' not in write.lower() + +for forbidden in ('actix', 'tokio', 'gitea', 'gitlab', 'github', 'postgres', 'sqlx', 'diesel'): + assert forbidden not in manifest.lower(), forbidden + +assert '`alpha.1.fix.2` a complété la gate documentaire et technique en `PASS`' in plan +assert '### alpha.2 — contrat d\'écriture provider-neutral' in plan +assert '## 0.0.3' not in changelog + +assert Path('history/0.0.3/alpha.1.fix.2.md').is_file() +assert not Path('history/0.0.3/alpha.2.md').exists() +assert not Path('crates/knowledge-forge-gitea-lib').exists() +assert not Path('crates/knowledge-store-postgres-lib').exists() +assert not Path('migrations').exists() + +print('Knowledge 0.0.3-alpha.2 write-contract audit: clean') +PYGATE + +python3 - <<'PYTEXT' +from pathlib import Path + +roots = [Path('README.md'), Path('RULES.md'), Path('ROADMAP.md'), Path('CHANGELOG.md'), Path('docs'), Path('prompts'), Path('deltas'), Path('history')] +paths = [] +for root in roots: + if root.is_file(): + paths.append(root) + elif root.is_dir(): + paths.extend(path for path in root.rglob('*') if path.is_file()) +for path in paths: + data = path.read_bytes() + assert b'\r' not in data, path + assert data.endswith(b'\n'), path + assert not data.endswith(b'\n\n'), path + text = data.decode('utf-8') + for number, line in enumerate(text.splitlines(), start=1): + assert line == line.rstrip(' \t'), (path, number) +print(f'Knowledge 0.0.3-alpha.2 text hygiene: clean ({len(paths)} file(s))') +PYTEXT + +test -z "$(git diff --name-only -- crates/knowledge-git-lib crates/knowledge-app-lib Web)" +test ! -e history/0.0.3/alpha.2.md + +test -f Cargo.lock +git check-ignore -q Cargo.lock +if git ls-files --error-unmatch Cargo.lock >/dev/null 2>&1; then + echo 'Cargo.lock must remain untracked for Knowledge' >&2 + exit 1 +fi + +test -f Web/knowledge-template-lab/package-lock.json + +git add -A +git diff --cached --check +git status --short +``` + +La gate ne rejoue pas npm/Vite/HTTP : `Web/`, les launchers et leur contrat runtime ne sont pas modifiés. + +## Transition + +### PASS + +Produire `history/0.0.3/alpha.2.md`, l'appliquer, commit/push `0.0.3-alpha.2`, puis préparer `0.0.3-alpha.3` pour le worktree d'écriture isolé et le commit candidat réel dans `knowledge-git-lib`. + +### FAIL / PARTIAL / BLOCKED + +Conserver le résultat réel dans son history avant toute correction. Une correction bornée utilise `alpha.2.fix.N` ; une modification de la mission ou de la frontière d'ownership exige un `REPLAN` explicite plutôt qu'un fix absorbant une nouvelle fonctionnalité. diff --git a/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md b/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md index 13b51b4..839d947 100644 --- a/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +++ b/docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md @@ -385,6 +385,10 @@ Après publication vérifiée de `v0.0.2`, la prochaine mission explicitement en `0.0.3` borne la mise à jour distante à des branches `refs/heads/...` et exige un état distant attendu. Un push n'est accepté que lorsque cette attente reste vraie ; sinon le résultat est un conflit récupérable. Le backend ne résout pas ce conflit par rebase/merge automatique. +`alpha.2` matérialise cette frontière dans `knowledge-git-api` sans implémentation de backend : `GitMutationPath` confine lexicalement les mutations à des chemins relatifs normalisés hors `.git`, `GitFileMutation` représente écriture ou suppression, `GitCommitIdentity` impose une identité explicite, `GitBranch` borne la cible à une référence de branche complète valide, et `GitRemoteState` représente soit l'absence attendue soit une révision distante attendue. `GitWriteRequest`, `GitPushRequest` et `GitPushOutcome` portent respectivement la création de candidate, la comparaison-et-échange distante et son résultat. Le trait `GitRepositoryWriter` reste séparé de `GitRepository` afin qu'un consommateur ou fake lecture seule n'acquière aucune obligation d'écriture. + +Le confinement filesystem contre les symlinks, la neutralisation des hooks, la création du commit et l'exécution du push gardé restent des responsabilités de `knowledge-git-lib` à partir de `alpha.3`; l'API passive ne prétend pas les prouver seule. + PostgreSQL, workflow complet de proposition, auth/admin, parsing final et adapter Gitea restent des missions distinctes. ## 10. Administration diff --git a/docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md b/docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md index 9a23384..09d0a4d 100644 --- a/docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md +++ b/docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md @@ -1,5 +1,5 @@ - + # Plan 0.0.3 — écriture Git contrôlée et push gardé @@ -19,7 +19,7 @@ Knowledge start prompt prompts/003-V0_0_3_START_PROMPT.md PGR `v0.1.3` reste une provenance historique de l'adoption fermée pendant `0.0.2`. Aucune version PGR plus récente n'est une source de cette release et aucune réévaluation PGR n'est engagée. -`0.0.3-alpha.1` a produit une gate technique propre jusqu'aux tests workspace mais reste `PARTIAL` : la fin de gate n'a pas été fournie et la revue opérateur a détecté un mauvais cycle de rédaction du changelog, du prompt suivant et de la roadmap. `0.0.3-alpha.1.fix.1` corrige uniquement ce workflow documentaire avant l'implémentation fonctionnelle. +`0.0.3-alpha.1` et `alpha.1.fix.1` sont conservées `PARTIAL` avec leurs preuves réelles. `alpha.1.fix.2` a complété la gate documentaire et technique en `PASS`, ce qui ferme le cadrage et autorise `alpha.2`. `alpha.2` matérialise maintenant uniquement le contrat d'écriture provider-neutral dans `knowledge-git-api`; le backend de worktree reste réservé à `alpha.3`. ## Mission diff --git a/docs/validation/001-VALIDATION_MATRIX.md b/docs/validation/001-VALIDATION_MATRIX.md index 976660c..e819d23 100644 --- a/docs/validation/001-VALIDATION_MATRIX.md +++ b/docs/validation/001-VALIDATION_MATRIX.md @@ -1,5 +1,5 @@ - + # Matrice de validation Knowledge @@ -117,6 +117,24 @@ Les tests doivent permettre plusieurs `source_id` et ne pas confondre famille de Un test live de remote ou de forge doit être explicitement classé comme externe, séparé des tests unitaires et ne doit pas devenir une condition cachée pour les opérations locales. +## Gate de `0.0.3-alpha.2` + +La tranche de contrat d'écriture Git doit prouver : + +- `VERSION = 0.0.3-alpha.2` et version workspace identique ; +- `knowledge-git-api` reste une crate passive sans dépendance de forge, runtime, filesystem backend ou PostgreSQL ; +- `GitRepository` de lecture reste inchangé et implémentable sans capacité d'écriture ; +- `GitRepositoryWriter` est une capacité distincte ; +- les chemins de mutation refusent valeur vide, chemin absolu/séparateur plateforme, `.` / `..`, composant vide et `.git` ; +- les mutations représentent écriture create/update et suppression, avec rejet des chemins dupliqués dans une même requête ; +- l'identité de commit et le message sont explicites et validés ; +- la branche cible est une référence complète valide sous `refs/heads/...` ; +- l'état distant attendu représente explicitement branche absente ou révision connue ; +- le résultat de push peut représenter succès ou conflit sans rebase/merge automatique ; +- `cargo fmt --all`, check, Clippy strict et tests ciblés `knowledge-git-api` passent ; +- les tests workspace passent car la façade publique de la crate change ; +- `knowledge-git-lib`, `knowledge-app-lib`, `Web/`, PostgreSQL et adapters forge ne sont pas modifiés par cette tranche. + ## PostgreSQL Aucune gate PostgreSQL n'est créée avant matérialisation d'une dépendance ou d'un schéma réel. diff --git a/history/0.0.3/alpha.2.md b/history/0.0.3/alpha.2.md new file mode 100644 index 0000000..6d6d74d --- /dev/null +++ b/history/0.0.3/alpha.2.md @@ -0,0 +1,142 @@ + + + +# History 0.0.3-alpha.2 — contrat d'écriture Git provider-neutral + +## Statut + +PASS + +La gate opérateur de `0.0.3-alpha.2` exécutée le `2026-09-29` est complète et propre. Elle valide la frontière publique d'écriture Git provider-neutral et autorise `0.0.3-alpha.3` pour le worktree d'écriture isolé et la création réelle du commit candidat. + +## Baseline opérateur + +`alpha.1.fix.2` `PASS` a été historisé, committé et poussé avant application de `alpha.2` : + +```text +history/0.0.3/alpha.1.fix.2.md PASS +commit 3004e4b +message 0.0.3-alpha.1.fix.2 +branch master +push origin/master PASS +workspace après push propre +``` + +Le push observé a avancé `origin/master` de `dd63e85` à `3004e4b`. + +## Version et documentation + +Les contrôles de version et Markdown passent : + +```text +VERSION 0.0.3-alpha.2 +Cargo workspace version 0.0.3-alpha.2 +git diff --check PASS +Markdown header audit clean (106 file(s)) +Markdown link audit clean (78 local link(s), 106 file(s)) +Markdown table audit clean (4 table(s), 106 file(s)) +``` + +## Formatage et audits Rust + +La séquence locale imposée a été respectée : + +```text +cargo fmt --all PASS +cargo fmt --all -- --check PASS +Rust test-layout audit clean (9 crate(s)) +Rust blank-line audit clean (33 file(s)) +Rust item-spacing audit clean (33 file(s)) +Rust module-order audit clean (33 file(s)) +Rust trait-import-block audit clean (33 file(s)) +Rust re-export-block audit clean (33 file(s)) +Rust crate-root facade audit clean (6 crate root(s)) +Rust export completeness audit 0 candidate(s) across 9 package(s) +``` + +## Cargo, Clippy et tests + +Les neuf packages compilent en `0.0.3-alpha.2` et Clippy ne produit aucun warning avec `-D warnings`. + +```text +cargo metadata --format-version 1 --no-deps PASS +cargo check --workspace PASS +cargo clippy --workspace --all-targets --all-features + -- -D warnings PASS +cargo test -p knowledge-git-api --all-targets --all-features PASS (7) +cargo test --workspace --all-targets --all-features PASS +``` + +Tests workspace observés : + +```text +knowledge-app-lib 3 PASS +knowledge-domain-lib 3 PASS +knowledge-git-api 7 PASS +knowledge-git-lib 8 unit PASS + 2 integration PASS +knowledge-sync-api 1 PASS +knowledge-web-lib 4 PASS +launchers 0 test chacun +``` + +Les sept tests `knowledge-git-api` valident notamment : + +- la séparation du contrat d'écriture du contrat de lecture ; +- l'absence de dépendance à une forge ; +- la validation des chemins de mutation et la protection de `.git` ; +- l'identité de commit explicite ; +- les branches bornées à `refs/heads/...` ; +- les états distants absent/révision ; +- le rejet des requêtes sans mutation, messages invalides et chemins dupliqués. + +## Audit spécifique d'écriture + +L'audit de tranche passe : + +```text +Knowledge 0.0.3-alpha.2 write-contract audit: clean +``` + +Il confirme notamment : + +- la présence et le réexport des dix types/traits d'écriture attendus ; +- `GitRepository` de lecture inchangé et sans méthodes `create_candidate` / `push_candidate` ; +- la validation `refs/heads/...`, `ProtectedGitPath` et `DuplicateMutationPath` ; +- l'absence de dépendances Actix, Tokio, forge, PostgreSQL ou ORM dans `knowledge-git-api` ; +- `CHANGELOG.md` toujours sans section prématurée `0.0.3` ; +- absence de history `alpha.2` avant staging ; +- absence de crates Gitea/PostgreSQL et de migrations. + +## Hygiène et scope + +```text +Knowledge 0.0.3-alpha.2 text hygiene: clean (106 file(s)) +``` + +La gate confirme également : + +- aucun diff sous `crates/knowledge-git-lib`, `crates/knowledge-app-lib` ou `Web/` ; +- `Cargo.lock` local présent, ignoré et non suivi ; +- `Web/knowledge-template-lab/package-lock.json` présent ; +- `git diff --cached --check` propre. + +Le staging final contient exactement les changements attendus de la tranche : + +```text +M Cargo.toml +M README.md +M VERSION +M crates/knowledge-git-api/src/lib.rs +A crates/knowledge-git-api/src/write.rs +M crates/knowledge-git-api/unit_tests/lib.rs +A deltas/0.0.3/alpha.2.md +M docs/architecture/002-KNOWLEDGE_ARCHITECTURE.md +M docs/plans/003-V0_0_3_GIT_WRITE_PLAN.md +M docs/validation/001-VALIDATION_MATRIX.md +``` + +## Décision + +`0.0.3-alpha.2` est `PASS`. + +La frontière provider-neutral est acquise. La tranche suivante peut implémenter dans `knowledge-git-lib` le worktree d'écriture isolé, les mutations confinées, l'identité explicite, la neutralisation des hooks/configurations pertinentes et la création d'une révision candidate vérifiée. Le push distant gardé reste réservé à `alpha.4`.