From 96753e4ba110406975cbaac160009e9a2d9bacd3 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 15 Aug 2026 20:57:13 +0200 Subject: [PATCH] v0.1.3-pre.007 --- Cargo.toml | 7 +- config/examples/std.logging.example.json | 64 +++ config/schemas/std.logging.schema.json | 266 +++++++++++ config/std.logging.json | 73 +++ crates/ksp-config-lib/Cargo.toml | 5 +- crates/ksp-config-lib/src/document.rs | 451 ++++++++++++++++++ crates/ksp-config-lib/src/error.rs | 17 +- crates/ksp-config-lib/src/lib.rs | 25 +- crates/ksp-config-lib/src/registry.rs | 109 ++++- crates/ksp-config-lib/tests/public_api.rs | 24 +- crates/ksp-config-lib/unit_tests/document.rs | 224 +++++++++ crates/ksp-config-lib/unit_tests/registry.rs | 26 +- deltas/0.1.3/pre.007.md | 347 ++++++++++++++ docs/plans/000-README.md | 4 +- docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md | 4 +- .../005-V0_1_3_CONFIG_FOUNDATION_PLAN.md | 33 +- docs/rules/FILE_CONTRACTS.md | 12 +- 17 files changed, 1647 insertions(+), 44 deletions(-) create mode 100644 config/examples/std.logging.example.json create mode 100644 config/schemas/std.logging.schema.json create mode 100644 config/std.logging.json create mode 100644 crates/ksp-config-lib/src/document.rs create mode 100644 crates/ksp-config-lib/unit_tests/document.rs create mode 100644 deltas/0.1.3/pre.007.md diff --git a/Cargo.toml b/Cargo.toml index 9d15cdb..5a16cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 47 +# version: 48 [workspace] resolver = "3" members = ["crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib"] [workspace.package] -version = "0.1.3-pre.6" +version = "0.1.3-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" @@ -14,6 +14,9 @@ authors = ["SinuS von SifriduS "] publish = false [workspace.dependencies] +serde = { version = "^1.0", features = ["derive"] } +serde_json = { version = "^1.0" } +jsonschema = { version = "^0.49", default-features = false } solana-pubkey = { version = "^4.3", default-features = false } tracing = { version = "^0.1", default-features = false, features = ["std"] } tracing-subscriber = { version = "^0.3", default-features = false, features = ["fmt", "json", "ansi"] } diff --git a/config/examples/std.logging.example.json b/config/examples/std.logging.example.json new file mode 100644 index 0000000..4372608 --- /dev/null +++ b/config/examples/std.logging.example.json @@ -0,0 +1,64 @@ +{ + "format_version": 1, + "logs_directory": "${KSP_LOGS_DIRECTORY:-logs}", + "default_profile": "example", + "profiles": [ + { + "profile_id": "example", + "default_filter": "info", + "span_events": "new_and_close", + "console": { + "enabled": true, + "output": "stdout", + "ansi": true, + "format": "pretty", + "filter": { + "level": "debug", + "targets": [ + "*" + ], + "domains": [ + "*" + ] + } + }, + "files": [ + { + "output_id": "file.all.info", + "enabled": true, + "path": "ksp-info.log", + "rotation": "daily", + "format": "compact", + "ansi": false, + "filter": { + "level": "info", + "targets": [ + "*" + ], + "domains": [ + "*" + ] + } + }, + { + "output_id": "file.store.trace", + "enabled": false, + "path": "store/store-trace.jsonl", + "rotation": "hourly", + "format": "json", + "ansi": false, + "filter": { + "level": "trace", + "targets": [ + "*" + ], + "domains": [ + "store" + ] + } + } + ], + "target_filters": [] + } + ] +} \ No newline at end of file diff --git a/config/schemas/std.logging.schema.json b/config/schemas/std.logging.schema.json new file mode 100644 index 0000000..321143b --- /dev/null +++ b/config/schemas/std.logging.schema.json @@ -0,0 +1,266 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:ksp:schema:std.logging:v1", + "title": "KSP standard Logging configuration", + "type": "object", + "additionalProperties": false, + "required": [ + "format_version", + "logs_directory", + "default_profile", + "profiles" + ], + "properties": { + "format_version": { + "const": 1 + }, + "logs_directory": { + "type": "string", + "minLength": 1 + }, + "default_profile": { + "$ref": "#/$defs/profileId" + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/profile" + } + } + }, + "$defs": { + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "level": { + "enum": [ + "off", + "error", + "warn", + "info", + "debug", + "trace" + ] + }, + "format": { + "enum": [ + "human", + "compact", + "pretty", + "json" + ] + }, + "selectorList": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + }, + "allOf": [ + { + "if": { + "contains": { + "const": "*" + } + }, + "then": { + "maxItems": 1 + } + } + ] + }, + "targetSelectorList": { + "allOf": [ + { + "$ref": "#/$defs/selectorList" + }, + { + "items": { + "anyOf": [ + { + "const": "*" + }, + { + "type": "string", + "pattern": "^ksp-" + } + ] + } + } + ] + }, + "outputFilter": { + "type": "object", + "additionalProperties": false, + "required": [ + "level", + "targets", + "domains" + ], + "properties": { + "level": { + "$ref": "#/$defs/level" + }, + "targets": { + "$ref": "#/$defs/targetSelectorList" + }, + "domains": { + "$ref": "#/$defs/selectorList" + } + } + }, + "console": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "output", + "ansi", + "format", + "filter" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "output": { + "enum": [ + "stdout", + "stderr" + ] + }, + "ansi": { + "type": "boolean" + }, + "format": { + "$ref": "#/$defs/format" + }, + "filter": { + "$ref": "#/$defs/outputFilter" + } + }, + "not": { + "properties": { + "ansi": { + "const": true + }, + "format": { + "const": "json" + } + }, + "required": [ + "ansi", + "format" + ] + } + }, + "outputId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*(\\.[a-z0-9][a-z0-9_-]*)*$" + }, + "file": { + "type": "object", + "additionalProperties": false, + "required": [ + "output_id", + "enabled", + "path", + "rotation", + "format", + "ansi", + "filter" + ], + "properties": { + "output_id": { + "$ref": "#/$defs/outputId" + }, + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string", + "minLength": 1 + }, + "rotation": { + "enum": [ + "never", + "hourly", + "daily" + ] + }, + "format": { + "$ref": "#/$defs/format" + }, + "ansi": { + "const": false + }, + "filter": { + "$ref": "#/$defs/outputFilter" + } + } + }, + "targetFilter": { + "type": "object", + "additionalProperties": false, + "required": [ + "target_prefix", + "level" + ], + "properties": { + "target_prefix": { + "type": "string", + "pattern": "^ksp-" + }, + "level": { + "$ref": "#/$defs/level" + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "default_filter", + "span_events", + "console", + "files", + "target_filters" + ], + "properties": { + "profile_id": { + "$ref": "#/$defs/profileId" + }, + "default_filter": { + "$ref": "#/$defs/level" + }, + "span_events": { + "enum": [ + "off", + "new_and_close", + "full" + ] + }, + "console": { + "$ref": "#/$defs/console" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/file" + } + }, + "target_filters": { + "type": "array", + "items": { + "$ref": "#/$defs/targetFilter" + } + } + } + } + } +} \ No newline at end of file diff --git a/config/std.logging.json b/config/std.logging.json new file mode 100644 index 0000000..5c6c4b0 --- /dev/null +++ b/config/std.logging.json @@ -0,0 +1,73 @@ +{ + "format_version": 1, + "logs_directory": "${KSP_LOGS_DIRECTORY:-logs}", + "default_profile": "local_dev", + "profiles": [ + { + "profile_id": "local_dev", + "default_filter": "warn", + "span_events": "new_and_close", + "console": { + "enabled": true, + "output": "stderr", + "ansi": true, + "format": "compact", + "filter": { + "level": "debug", + "targets": [ + "*" + ], + "domains": [ + "*" + ] + } + }, + "files": [ + { + "output_id": "file.all.debug", + "enabled": true, + "path": "debug/ksp-debug.log", + "rotation": "daily", + "format": "human", + "ansi": false, + "filter": { + "level": "debug", + "targets": [ + "*" + ], + "domains": [ + "*" + ] + } + }, + { + "output_id": "file.config.error", + "enabled": true, + "path": "config/ksp-config-errors.jsonl", + "rotation": "daily", + "format": "json", + "ansi": false, + "filter": { + "level": "error", + "targets": [ + "ksp-config-lib" + ], + "domains": [ + "config" + ] + } + } + ], + "target_filters": [ + { + "target_prefix": "ksp-config-lib", + "level": "trace" + }, + { + "target_prefix": "ksp-logging-lib", + "level": "debug" + } + ] + } + ] +} \ No newline at end of file diff --git a/crates/ksp-config-lib/Cargo.toml b/crates/ksp-config-lib/Cargo.toml index dbd58f6..2d41c3b 100644 --- a/crates/ksp-config-lib/Cargo.toml +++ b/crates/ksp-config-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-config-lib/Cargo.toml -# version: 1 +# version: 2 [package] name = "ksp-config-lib" @@ -9,6 +9,9 @@ repository.workspace = true [dependencies] ksp-core-lib = { path = "../ksp-core-lib" } +serde.workspace = true +serde_json.workspace = true +jsonschema.workspace = true [lints] workspace = true diff --git a/crates/ksp-config-lib/src/document.rs b/crates/ksp-config-lib/src/document.rs new file mode 100644 index 0000000..0bfb081 --- /dev/null +++ b/crates/ksp-config-lib/src/document.rs @@ -0,0 +1,451 @@ +// file: crates/ksp-config-lib/src/document.rs +// version: 1 + +/// A Config-managed JSON document that has passed syntax, schema and current semantic validation. +#[derive(Clone, Debug, PartialEq)] +pub struct ConfigJsonDocument { + file_id: crate::ConfigFileId, + path: std::path::PathBuf, + value: serde_json::Value, +} + +impl ConfigJsonDocument { + /// Returns the logical Config file identifier used to load this document. + #[must_use] + pub fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the resolved physical path from which this document was loaded. + #[must_use] + pub fn path(&self) -> &std::path::Path { + return self.path.as_path(); + } + + /// Returns the validated JSON value without transferring Config ownership of file I/O or validation. + #[must_use] + pub fn value(&self) -> &serde_json::Value { + return &self.value; + } +} + +/// Generic JSON/JSON Schema engine owned by `ksp-config-lib`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfigDocumentEngine { + bootstrap: crate::ConfigBootstrapOptions, + registry: crate::ConfigFileRegistry, +} + +impl ConfigDocumentEngine { + /// Creates a document engine from already validated bootstrap options and a logical file registry. + #[must_use] + pub fn new(bootstrap: crate::ConfigBootstrapOptions, registry: crate::ConfigFileRegistry) -> Self { + return Self { bootstrap, registry }; + } + + /// Returns the bootstrap roots used by this engine. + #[must_use] + pub const fn bootstrap(&self) -> &crate::ConfigBootstrapOptions { + return &self.bootstrap; + } + + /// Returns the logical file registry used by this engine. + #[must_use] + pub const fn registry(&self) -> &crate::ConfigFileRegistry { + return &self.registry; + } + + /// Loads one registered Config document and validates it against its registered JSON Schema and current KSP semantic invariants. + pub fn load_validated_document(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { + let descriptor = self.registry.descriptor(file_id); + let descriptor = match descriptor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if descriptor.kind() != crate::ConfigFileKind::Config { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "requested file_id does not identify a Config document") + .with_context("file_id", file_id.as_str()), + ); + } + let schema_file_id = match descriptor.schema_file_id() { + std::option::Option::Some(value) => value.clone(), + std::option::Option::None => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_FILE_MAPPING_INVALID, "Config document has no registered validation schema") + .with_context("file_id", file_id.as_str()), + ); + }, + }; + let document = self.load_json(file_id); + let document = match document { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let schema = self.load_json(&schema_file_id); + let schema = match schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let schema_validation = validate_schema_document(&schema); + match schema_validation { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let instance_validation = validate_instance(&document, &schema); + match instance_validation { + std::result::Result::Ok(()) => {}, + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + let semantic_validation = validate_document_semantics(&document); + return match semantic_validation { + std::result::Result::Ok(()) => std::result::Result::Ok(document), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } + + fn load_json(&self, file_id: &crate::ConfigFileId) -> ksp_core_lib::Result { + let path = self.registry.resolve_path(&self.bootstrap, file_id); + let path = match path { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let content = std::fs::read_to_string(path.as_path()); + let content = match content { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(json_read_error(file_id, &path, error)), + }; + let value = serde_json::from_str::(content.as_str()); + return match value { + std::result::Result::Ok(value) => std::result::Result::Ok(ConfigJsonDocument { file_id: file_id.clone(), path, value }), + std::result::Result::Err(error) => std::result::Result::Err(json_syntax_error(file_id, &path, error)), + }; + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingDocumentSource { + format_version: u32, + logs_directory: String, + default_profile: String, + profiles: std::vec::Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingProfileSource { + profile_id: String, + default_filter: String, + span_events: String, + console: LoggingConsoleSource, + files: std::vec::Vec, + target_filters: std::vec::Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingConsoleSource { + enabled: bool, + output: String, + ansi: bool, + format: String, + filter: LoggingOutputFilterSource, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingFileSource { + output_id: String, + enabled: bool, + path: String, + rotation: String, + format: String, + ansi: bool, + filter: LoggingOutputFilterSource, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingOutputFilterSource { + level: String, + targets: std::vec::Vec, + domains: std::vec::Vec, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingTargetFilterSource { + target_prefix: String, + level: String, +} + +fn validate_schema_document(schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { + let validation = jsonschema::meta::validate(schema.value()); + return match validation { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_INVALID, "Config JSON Schema document is invalid") + .with_context("file_id", schema.file_id().as_str()) + .with_context("path", schema.path().to_string_lossy().into_owned()) + .with_context("detail", error.to_string()), + ), + }; +} + +fn validate_instance(document: &ConfigJsonDocument, schema: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { + let validation = jsonschema::draft202012::validate(schema.value(), document.value()); + return match validation { + std::result::Result::Ok(()) => std::result::Result::Ok(()), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED, "Config document does not satisfy its registered JSON Schema") + .with_context("file_id", document.file_id().as_str()) + .with_context("schema_file_id", schema.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("detail", error.to_string()), + ), + }; +} + +fn validate_document_semantics(document: &ConfigJsonDocument) -> ksp_core_lib::Result<()> { + if document.file_id().as_str() != crate::FILE_ID_STD_LOGGING { + return std::result::Result::Ok(()); + } + let parsed = serde_json::from_value::(document.value().clone()); + let parsed = match parsed { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + semantic_error(document, "schema-valid Logging document cannot be decoded into the KSP source contract").with_source(error), + ); + }, + }; + return validate_logging_document(document, &parsed); +} + +fn validate_logging_document(document: &ConfigJsonDocument, source: &LoggingDocumentSource) -> ksp_core_lib::Result<()> { + if source.format_version != 1 { + return std::result::Result::Err(semantic_error(document, "unsupported Logging document format_version")); + } + if source.logs_directory.trim().is_empty() { + return std::result::Result::Err(semantic_error(document, "logs_directory must not be empty")); + } + if source.default_profile.trim().is_empty() { + return std::result::Result::Err(semantic_error(document, "default_profile must not be empty")); + } + for (profile_index, profile) in source.profiles.iter().enumerate() { + let validation = validate_logging_profile(document, profile, profile_index); + if let std::result::Result::Err(error) = validation { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); +} + +fn validate_logging_profile(document: &ConfigJsonDocument, profile: &LoggingProfileSource, profile_index: usize) -> ksp_core_lib::Result<()> { + if profile.profile_id.trim().is_empty() || profile.default_filter.trim().is_empty() || profile.span_events.trim().is_empty() { + return std::result::Result::Err( + semantic_error(document, "Logging profile identity and base settings must not be empty").with_context("profile_index", profile_index.to_string()), + ); + } + let console_validation = validate_logging_console(document, &profile.console, profile_index); + if let std::result::Result::Err(error) = console_validation { + return std::result::Result::Err(error); + } + for (file_index, file) in profile.files.iter().enumerate() { + let file_validation = validate_logging_file(document, file, profile_index, file_index); + if let std::result::Result::Err(error) = file_validation { + return std::result::Result::Err(error); + } + for previous in &profile.files[..file_index] { + if previous.output_id == file.output_id { + return std::result::Result::Err( + semantic_error(document, "Logging file output identifiers must be unique within a profile") + .with_context("profile_index", profile_index.to_string()) + .with_context("output_id", file.output_id.as_str()), + ); + } + } + } + for (target_index, target_filter) in profile.target_filters.iter().enumerate() { + if target_filter.target_prefix.trim().is_empty() || !target_filter.target_prefix.starts_with("ksp-") || target_filter.level.trim().is_empty() { + return std::result::Result::Err( + semantic_error(document, "Logging global target filter must identify a KSP-owned target") + .with_context("profile_index", profile_index.to_string()) + .with_context("target_filter_index", target_index.to_string()), + ); + } + } + return std::result::Result::Ok(()); +} + +fn validate_logging_console(document: &ConfigJsonDocument, console: &LoggingConsoleSource, profile_index: usize) -> ksp_core_lib::Result<()> { + let _enabled = console.enabled; + if console.output.trim().is_empty() || console.format.trim().is_empty() { + return std::result::Result::Err( + semantic_error(document, "Logging console output and format must not be empty").with_context("profile_index", profile_index.to_string()), + ); + } + if console.ansi && console.format == "json" { + return std::result::Result::Err( + semantic_error(document, "ANSI formatting is not compatible with JSON console output").with_context("profile_index", profile_index.to_string()), + ); + } + return validate_logging_output_filter(document, &console.filter, profile_index, "console"); +} + +fn validate_logging_file(document: &ConfigJsonDocument, file: &LoggingFileSource, profile_index: usize, file_index: usize) -> ksp_core_lib::Result<()> { + let _enabled = file.enabled; + if !valid_output_id(file.output_id.as_str()) { + return std::result::Result::Err( + semantic_error(document, "Logging file output_id is invalid") + .with_context("profile_index", profile_index.to_string()) + .with_context("file_index", file_index.to_string()) + .with_context("output_id", file.output_id.as_str()), + ); + } + if file.path.trim().is_empty() || !relative_log_path_is_valid(file.path.as_str()) { + return std::result::Result::Err( + semantic_error(document, "Logging file path must stay relative to logs_directory without traversal") + .with_context("profile_index", profile_index.to_string()) + .with_context("file_index", file_index.to_string()), + ); + } + if file.rotation.trim().is_empty() || file.format.trim().is_empty() { + return std::result::Result::Err( + semantic_error(document, "Logging file rotation and format must not be empty").with_context("profile_index", profile_index.to_string()), + ); + } + if file.ansi { + return std::result::Result::Err( + semantic_error(document, "ANSI sequences are not allowed in persistent Logging outputs").with_context("profile_index", profile_index.to_string()), + ); + } + return validate_logging_output_filter(document, &file.filter, profile_index, file.output_id.as_str()); +} + +fn validate_logging_output_filter( + document: &ConfigJsonDocument, + filter: &LoggingOutputFilterSource, + profile_index: usize, + output: &str, +) -> ksp_core_lib::Result<()> { + if filter.level.trim().is_empty() { + return std::result::Result::Err( + semantic_error(document, "Logging output filter level must not be empty").with_context("profile_index", profile_index.to_string()), + ); + } + let targets = validate_selectors(document, &filter.targets, true, profile_index, output, "targets"); + if let std::result::Result::Err(error) = targets { + return std::result::Result::Err(error); + } + return validate_selectors(document, &filter.domains, false, profile_index, output, "domains"); +} + +fn validate_selectors( + document: &ConfigJsonDocument, + selectors: &[String], + target_dimension: bool, + profile_index: usize, + output: &str, + dimension: &'static str, +) -> ksp_core_lib::Result<()> { + if selectors.is_empty() { + return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "selector list must not be empty")); + } + if selectors.len() > 1 + && selectors.iter().any(|selector| -> bool { + return selector == "*"; + }) + { + return std::result::Result::Err(selector_error(document, profile_index, output, dimension, "wildcard selector must be used alone")); + } + for (index, selector) in selectors.iter().enumerate() { + if selector.trim().is_empty() { + return std::result::Result::Err( + selector_error(document, profile_index, output, dimension, "selector must not be empty").with_context("selector_index", index.to_string()), + ); + } + if target_dimension && selector != "*" && !selector.starts_with("ksp-") { + return std::result::Result::Err( + selector_error(document, profile_index, output, dimension, "target selector must identify a KSP-owned target") + .with_context("selector_index", index.to_string()), + ); + } + for previous in &selectors[..index] { + if previous == selector { + return std::result::Result::Err( + selector_error(document, profile_index, output, dimension, "selectors must be unique").with_context("selector_index", index.to_string()), + ); + } + } + } + return std::result::Result::Ok(()); +} + +fn valid_output_id(output_id: &str) -> bool { + let mut previous_was_separator = true; + if output_id.is_empty() { + return false; + } + for byte in output_id.bytes() { + if byte == b'.' { + if previous_was_separator { + return false; + } + previous_was_separator = true; + } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' { + previous_was_separator = false; + } else { + return false; + } + } + return !previous_was_separator; +} + +fn relative_log_path_is_valid(value: &str) -> bool { + let path = std::path::Path::new(value); + if path.is_absolute() { + return false; + } + let mut has_normal_component = false; + for component in path.components() { + match component { + std::path::Component::Normal(_) => has_normal_component = true, + std::path::Component::CurDir | std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_) => return false, + } + } + return has_normal_component; +} + +fn selector_error(document: &ConfigJsonDocument, profile_index: usize, output: &str, dimension: &'static str, reason: &'static str) -> ksp_core_lib::Error { + return semantic_error(document, reason) + .with_context("profile_index", profile_index.to_string()) + .with_context("output", output) + .with_context("dimension", dimension); +} + +fn semantic_error(document: &ConfigJsonDocument, reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID, "Config document violates KSP semantic invariants") + .with_context("file_id", document.file_id().as_str()) + .with_context("path", document.path().to_string_lossy().into_owned()) + .with_context("reason", reason); +} + +fn json_read_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: std::io::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "Config-managed JSON file cannot be read") + .with_context("file_id", file_id.as_str()) + .with_context("path", path.to_string_lossy().into_owned()) + .with_source(source); +} + +fn json_syntax_error(file_id: &crate::ConfigFileId, path: &std::path::Path, source: serde_json::Error) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "Config-managed file contains invalid JSON syntax") + .with_context("file_id", file_id.as_str()) + .with_context("path", path.to_string_lossy().into_owned()) + .with_source(source); +} + +#[cfg(test)] +#[path = "../unit_tests/document.rs"] +mod tests; diff --git a/crates/ksp-config-lib/src/error.rs b/crates/ksp-config-lib/src/error.rs index 7793019..b544e0c 100644 --- a/crates/ksp-config-lib/src/error.rs +++ b/crates/ksp-config-lib/src/error.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/error.rs -// version: 2 +// version: 3 /// Error code used when a Config bootstrap argument is missing its value. pub const ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "bootstrap_argument_missing_value"); @@ -18,3 +18,18 @@ pub const ERROR_CODE_FILE_ID_DUPLICATE: ksp_core_lib::ErrorCode = ksp_core_lib:: /// Error code used when a Config filename mapping or descriptor relation is invalid. pub const ERROR_CODE_FILE_MAPPING_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "file_mapping_invalid"); + +/// Error code used when a Config-managed JSON document or schema cannot be read from its resolved path. +pub const ERROR_CODE_JSON_FILE_READ_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_file_read_failed"); + +/// Error code used when a Config-managed file contains invalid JSON syntax. +pub const ERROR_CODE_JSON_SYNTAX_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "json_syntax_invalid"); + +/// Error code used when a JSON Schema document is itself invalid for the selected JSON Schema draft. +pub const ERROR_CODE_SCHEMA_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_invalid"); + +/// Error code used when a Config document does not satisfy its registered JSON Schema. +pub const ERROR_CODE_SCHEMA_VALIDATION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "schema_validation_failed"); + +/// Error code used when a schema-valid Config document violates KSP semantic invariants for its document type. +pub const ERROR_CODE_DOCUMENT_SEMANTIC_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("config", "document_semantic_invalid"); diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index d377843..939c540 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,16 +1,17 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 2 +// version: 3 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] //! KSP-owned application configuration facade. //! -//! `0.1.3-pre.003` provides the non-recursive bootstrap roots plus a stable logical file registry. Consumers select Config-managed files by `file_id`; -//! physical filenames can be replaced at bootstrap without changing those logical identities. JSON/schema loading, profiles, environment resolution and -//! persistence are introduced by later bounded prereleases. +//! `0.1.3-pre.007` owns the non-recursive bootstrap roots, stable logical file registry and first generic JSON/JSON Schema loading pipeline. The standard +//! Logging document is the first registered runtime document. Profile resolution, environment substitution, sensitivity and persistence remain in later bounded +//! prereleases. mod bootstrap; +mod document; mod error; mod registry; @@ -24,10 +25,16 @@ pub use self::bootstrap::ConfigBootstrapOptions; pub use self::bootstrap::DEFAULT_CFG_PATH; /// Default root containing KSP JSON schemas. pub use self::bootstrap::DEFAULT_SCHEMA_PATH; +/// Generic JSON/JSON Schema engine owned by Config. +pub use self::document::ConfigDocumentEngine; +/// A Config-managed JSON document after syntax, schema and current semantic validation. +pub use self::document::ConfigJsonDocument; /// Error code used when a Config bootstrap argument is missing its value. pub use self::error::ERROR_CODE_BOOTSTRAP_ARGUMENT_MISSING_VALUE; /// Error code used when a Config bootstrap path is empty, inaccessible, or resolves to an existing non-directory path. pub use self::error::ERROR_CODE_BOOTSTRAP_INVALID_PATH; +/// Error code used when a schema-valid Config document violates KSP semantic invariants. +pub use self::error::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID; /// Error code used when the same logical Config file identifier is registered more than once. pub use self::error::ERROR_CODE_FILE_ID_DUPLICATE; /// Error code used when a logical Config file identifier is malformed. @@ -36,9 +43,17 @@ pub use self::error::ERROR_CODE_FILE_ID_INVALID; pub use self::error::ERROR_CODE_FILE_ID_UNKNOWN; /// Error code used when a Config filename mapping or descriptor relation is invalid. pub use self::error::ERROR_CODE_FILE_MAPPING_INVALID; +/// Error code used when a Config-managed JSON document or schema cannot be read. +pub use self::error::ERROR_CODE_JSON_FILE_READ_FAILED; +/// Error code used when a Config-managed file contains invalid JSON syntax. +pub use self::error::ERROR_CODE_JSON_SYNTAX_INVALID; +/// Error code used when a JSON Schema document is itself invalid. +pub use self::error::ERROR_CODE_SCHEMA_INVALID; +/// Error code used when a Config document fails its registered JSON Schema validation. +pub use self::error::ERROR_CODE_SCHEMA_VALIDATION_FAILED; /// Bootstrap argument used to replace a known Config filename mapping. pub use self::registry::ARG_FILE_MAP; -/// Logical descriptor associating a stable file identifier with its physical filename and root category. +/// Logical descriptor associating a stable file identifier with its physical filename and validation schema. pub use self::registry::ConfigFileDescriptor; /// Stable logical identifier for a Config-managed file. pub use self::registry::ConfigFileId; diff --git a/crates/ksp-config-lib/src/registry.rs b/crates/ksp-config-lib/src/registry.rs index 8b7ee73..566aa76 100644 --- a/crates/ksp-config-lib/src/registry.rs +++ b/crates/ksp-config-lib/src/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/registry.rs -// version: 1 +// version: 2 /// Bootstrap argument used to replace a known Config filename mapping. pub const ARG_FILE_MAP: &str = "--filemap"; @@ -43,12 +43,13 @@ pub enum ConfigFileKind { Schema, } -/// Logical descriptor associating a stable file identifier with its physical filename and root category. +/// Logical descriptor associating a stable file identifier with its physical filename, root category and optional validation schema. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConfigFileDescriptor { file_id: ConfigFileId, kind: ConfigFileKind, filename: std::path::PathBuf, + schema_file_id: std::option::Option, } impl ConfigFileDescriptor { @@ -70,7 +71,18 @@ impl ConfigFileDescriptor { return self.filename.as_path(); } - fn new(file_id: &'static str, kind: ConfigFileKind, filename: &'static str) -> ksp_core_lib::Result { + /// Returns the logical schema identifier associated with this Config document when one is declared. + #[must_use] + pub fn schema_file_id(&self) -> std::option::Option<&ConfigFileId> { + return self.schema_file_id.as_ref(); + } + + fn new( + file_id: &'static str, + kind: ConfigFileKind, + filename: &'static str, + schema_file_id: std::option::Option<&'static str>, + ) -> ksp_core_lib::Result { let file_id = ConfigFileId::new(file_id); let file_id = match file_id { std::result::Result::Ok(value) => value, @@ -82,8 +94,13 @@ impl ConfigFileDescriptor { std::result::Result::Err(error) => return std::result::Result::Err(error), } let filename = validate_relative_filename(&file_id, std::path::PathBuf::from(filename)); - return match filename { - std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename: value }), + let filename = match filename { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let schema_file_id = parse_schema_file_id(&file_id, kind, schema_file_id); + return match schema_file_id { + std::result::Result::Ok(value) => std::result::Result::Ok(Self { file_id, kind, filename, schema_file_id: value }), std::result::Result::Err(error) => std::result::Result::Err(error), }; } @@ -98,12 +115,18 @@ pub struct ConfigFileRegistry { impl ConfigFileRegistry { /// Creates the registry containing the KSP default file mappings known in the current release. pub fn defaults() -> ksp_core_lib::Result { - let logging = ConfigFileDescriptor::new(FILE_ID_STD_LOGGING, ConfigFileKind::Config, DEFAULT_STD_LOGGING_FILENAME); + let logging = ConfigFileDescriptor::new( + FILE_ID_STD_LOGGING, + ConfigFileKind::Config, + DEFAULT_STD_LOGGING_FILENAME, + std::option::Option::Some(FILE_ID_SCHEMA_STD_LOGGING), + ); let logging = match logging { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let logging_schema = ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME); + let logging_schema = + ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_LOGGING, ConfigFileKind::Schema, DEFAULT_STD_LOGGING_SCHEMA_FILENAME, std::option::Option::None); let logging_schema = match logging_schema { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), @@ -154,7 +177,7 @@ impl ConfigFileRegistry { return std::result::Result::Ok(root.join(descriptor.filename())); } - /// Replaces the physical filename of one known logical identifier while preserving its kind and logical identity. + /// Replaces the physical filename of one known logical identifier while preserving its kind, schema association and logical identity. pub fn with_filename_override(mut self, file_id: &ConfigFileId, filename: impl std::convert::Into) -> ksp_core_lib::Result { let update = self.set_filename_override(file_id, filename.into()); return match update { @@ -174,7 +197,12 @@ impl ConfigFileRegistry { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; - let updated = ConfigFileDescriptor { file_id: descriptor.file_id.clone(), kind: descriptor.kind, filename }; + let updated = ConfigFileDescriptor { + file_id: descriptor.file_id.clone(), + kind: descriptor.kind, + filename, + schema_file_id: descriptor.schema_file_id.clone(), + }; self.descriptors.insert(file_id.clone(), updated); return std::result::Result::Ok(()); } @@ -190,7 +218,68 @@ fn build_registry(descriptors: [ConfigFileDescriptor; N]) -> ksp return std::result::Result::Err(duplicate_file_id_error(duplicate_id.as_str())); } } - return std::result::Result::Ok(registry); + let associations = validate_schema_associations(®istry); + return match associations { + std::result::Result::Ok(()) => std::result::Result::Ok(registry), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +fn parse_schema_file_id( + file_id: &ConfigFileId, + kind: ConfigFileKind, + schema_file_id: std::option::Option<&'static str>, +) -> ksp_core_lib::Result> { + return match (kind, schema_file_id) { + (ConfigFileKind::Schema, std::option::Option::Some(_)) => { + std::result::Result::Err(invalid_file_mapping_with_id_error(file_id.as_str(), "schema descriptors cannot declare another validation schema")) + }, + (ConfigFileKind::Schema, std::option::Option::None) | (ConfigFileKind::Config, std::option::Option::None) => { + std::result::Result::Ok(std::option::Option::None) + }, + (ConfigFileKind::Config, std::option::Option::Some(value)) => { + let schema_id = ConfigFileId::new(value); + match schema_id { + std::result::Result::Ok(schema_id) => { + if schema_id.as_str().starts_with("schema.") { + std::result::Result::Ok(std::option::Option::Some(schema_id)) + } else { + std::result::Result::Err(invalid_file_mapping_with_id_error( + file_id.as_str(), + "validation schema file_id must use the schema namespace", + )) + } + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + }; +} + +fn validate_schema_associations(registry: &ConfigFileRegistry) -> ksp_core_lib::Result<()> { + for descriptor in registry.descriptors.values() { + let schema_file_id = match descriptor.schema_file_id() { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + let schema = registry.descriptors.get(schema_file_id); + let schema = match schema { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(invalid_file_mapping_with_id_error( + descriptor.file_id().as_str(), + "validation schema file_id is not registered", + )); + }, + }; + if schema.kind() != ConfigFileKind::Schema { + return std::result::Result::Err(invalid_file_mapping_with_id_error( + descriptor.file_id().as_str(), + "validation schema descriptor must have schema kind", + )); + } + } + return std::result::Result::Ok(()); } fn apply_file_map_argument(registry: &mut ConfigFileRegistry, argument: &std::ffi::OsStr) -> ksp_core_lib::Result<()> { diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index ce2f506..d2e3983 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,7 +1,7 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 3 +// version: 4 -//! Integration tests for the public `ksp-config-lib` bootstrap and logical file registry contracts. +//! Integration tests for the public `ksp-config-lib` bootstrap, logical file registry and validated JSON document contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -72,3 +72,23 @@ fn logical_file_registry_is_available_from_crate_root() { assert_eq!(ksp_config_lib::ARG_FILE_MAP, "--filemap"); } } + +#[test] +fn validated_json_document_engine_is_available_from_crate_root() { + let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let bootstrap = ksp_config_lib::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let registry = ksp_config_lib::ConfigFileRegistry::defaults(); + let file_id = ksp_config_lib::ConfigFileId::new(ksp_config_lib::FILE_ID_STD_LOGGING); + assert!(bootstrap.is_ok(), "public bootstrap should accept committed Config roots: {bootstrap:?}"); + assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); + assert!(file_id.is_ok(), "public Logging file_id should remain constructible: {file_id:?}"); + if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) { + let engine = ksp_config_lib::ConfigDocumentEngine::new(bootstrap, registry); + let document = engine.load_validated_document(&file_id); + assert!(document.is_ok(), "public document engine should validate committed Logging configuration: {document:?}"); + if let std::result::Result::Ok(document) = document { + assert_eq!(document.file_id(), &file_id); + assert_eq!(document.value().get("format_version"), std::option::Option::Some(&serde_json::Value::from(1))); + } + } +} diff --git a/crates/ksp-config-lib/unit_tests/document.rs b/crates/ksp-config-lib/unit_tests/document.rs new file mode 100644 index 0000000..10cfc6e --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/document.rs @@ -0,0 +1,224 @@ +// file: crates/ksp-config-lib/unit_tests/document.rs +// version: 1 + +#[test] +fn committed_logging_document_passes_registered_schema_and_semantic_validation() { + let workspace = workspace_root(); + let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let registry = crate::ConfigFileRegistry::defaults(); + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + assert!(bootstrap.is_ok(), "workspace Config paths should be valid: {bootstrap:?}"); + assert!(registry.is_ok(), "default registry should be valid: {registry:?}"); + assert!(file_id.is_ok(), "logging file_id should be valid: {file_id:?}"); + if let (std::result::Result::Ok(bootstrap), std::result::Result::Ok(registry), std::result::Result::Ok(file_id)) = (bootstrap, registry, file_id) { + let engine = super::ConfigDocumentEngine::new(bootstrap, registry); + let document = engine.load_validated_document(&file_id); + assert!(document.is_ok(), "committed std.logging.json should validate: {document:?}"); + if let std::result::Result::Ok(document) = document { + assert_eq!(document.file_id(), &file_id); + assert_eq!(document.path(), workspace.join("config/std.logging.json").as_path()); + let default_profile = document.value().get("default_profile"); + assert!(default_profile.is_some(), "validated Logging document should retain default_profile"); + if let std::option::Option::Some(default_profile) = default_profile { + assert_eq!(default_profile.as_str(), std::option::Option::Some("local_dev")); + } + } + } +} + +#[test] +fn missing_document_is_reported_with_file_read_error() { + let fixture = fixture_roots("missing-document"); + let prepared = prepare_fixture(&fixture, std::option::Option::None, valid_minimal_logging_schema()); + assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_JSON_FILE_READ_FAILED); + } + cleanup_fixture(&fixture); +} + +#[test] +fn malformed_json_is_reported_before_schema_validation() { + let fixture = fixture_roots("malformed-json"); + let prepared = prepare_fixture(&fixture, std::option::Option::Some("{ invalid-json"), valid_minimal_logging_schema()); + assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_JSON_SYNTAX_INVALID); + } + cleanup_fixture(&fixture); +} + +#[test] +fn invalid_schema_document_is_reported_before_instance_validation() { + let fixture = fixture_roots("invalid-schema"); + let schema = r#"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "definitely-not-a-json-schema-type" +}"#; + let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema); + assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_SCHEMA_INVALID); + } + cleanup_fixture(&fixture); +} + +#[test] +fn schema_violation_is_distinct_from_json_syntax_failure() { + let fixture = fixture_roots("schema-violation"); + let schema = r#"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["required_field"], + "properties": { + "required_field": {"type": "string"} + } +}"#; + let prepared = prepare_fixture(&fixture, std::option::Option::Some("{}"), schema); + assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_SCHEMA_VALIDATION_FAILED); + } + cleanup_fixture(&fixture); +} + +#[test] +fn schema_valid_logging_document_can_still_fail_ksp_semantics() { + let fixture = fixture_roots("semantic-invalid"); + let workspace = workspace_root(); + let schema_source = std::fs::read_to_string(workspace.join("config/schemas/std.logging.schema.json")); + assert!(schema_source.is_ok(), "committed Logging schema should be readable: {schema_source:?}"); + if let std::result::Result::Ok(schema_source) = schema_source { + let document = r#"{ + "format_version": 1, + "logs_directory": "logs", + "default_profile": "duplicate-output", + "profiles": [ + { + "profile_id": "duplicate-output", + "default_filter": "info", + "span_events": "off", + "console": { + "enabled": false, + "output": "stderr", + "ansi": false, + "format": "human", + "filter": {"level": "trace", "targets": ["*"], "domains": ["*"]} + }, + "files": [ + { + "output_id": "file.same", + "enabled": true, + "path": "first.log", + "rotation": "daily", + "format": "human", + "ansi": false, + "filter": {"level": "info", "targets": ["*"], "domains": ["*"]} + }, + { + "output_id": "file.same", + "enabled": true, + "path": "second.log", + "rotation": "daily", + "format": "human", + "ansi": false, + "filter": {"level": "info", "targets": ["*"], "domains": ["*"]} + } + ], + "target_filters": [] + } + ] +}"#; + let prepared = prepare_fixture(&fixture, std::option::Option::Some(document), schema_source.as_str()); + assert!(prepared.is_ok(), "fixture should be writable: {prepared:?}"); + if prepared.is_ok() { + let result = load_fixture(&fixture); + assert_error_code(result, crate::ERROR_CODE_DOCUMENT_SEMANTIC_INVALID); + } + } + cleanup_fixture(&fixture); +} + +fn load_fixture(fixture: &FixtureRoots) -> ksp_core_lib::Result { + let bootstrap = crate::ConfigBootstrapOptions::from_paths(fixture.config.as_path(), fixture.schemas.as_path()); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_LOGGING); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let engine = super::ConfigDocumentEngine::new(bootstrap, registry); + return engine.load_validated_document(&file_id); +} + +fn prepare_fixture(fixture: &FixtureRoots, document: std::option::Option<&str>, schema: &str) -> std::io::Result<()> { + cleanup_fixture(fixture); + let config = std::fs::create_dir_all(fixture.config.as_path()); + if let std::result::Result::Err(error) = config { + return std::result::Result::Err(error); + } + let schemas = std::fs::create_dir_all(fixture.schemas.as_path()); + if let std::result::Result::Err(error) = schemas { + return std::result::Result::Err(error); + } + let schema_write = std::fs::write(fixture.schemas.join(crate::DEFAULT_STD_LOGGING_SCHEMA_FILENAME), schema); + if let std::result::Result::Err(error) = schema_write { + return std::result::Result::Err(error); + } + if let std::option::Option::Some(document) = document { + let document_write = std::fs::write(fixture.config.join(crate::DEFAULT_STD_LOGGING_FILENAME), document); + if let std::result::Result::Err(error) = document_write { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(()); +} + +fn valid_minimal_logging_schema() -> &'static str { + return r#"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object" +}"#; +} + +fn assert_error_code(result: ksp_core_lib::Result, expected: ksp_core_lib::ErrorCode) { + assert!(result.is_err(), "fixture should fail with {expected:?}: {result:?}"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), expected); + } +} + +fn workspace_root() -> std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); +} + +struct FixtureRoots { + root: std::path::PathBuf, + config: std::path::PathBuf, + schemas: std::path::PathBuf, +} + +fn fixture_roots(name: &str) -> FixtureRoots { + let mut root = std::env::temp_dir(); + root.push(format!("ksp-config-lib-pre007-{name}-{}", std::process::id())); + return FixtureRoots { config: root.join("config"), schemas: root.join("schemas"), root }; +} + +fn cleanup_fixture(fixture: &FixtureRoots) { + let result = std::fs::remove_dir_all(fixture.root.as_path()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.kind(), std::io::ErrorKind::NotFound, "fixture cleanup should only ignore missing directories: {error}"); + } +} diff --git a/crates/ksp-config-lib/unit_tests/registry.rs b/crates/ksp-config-lib/unit_tests/registry.rs index faaa994..8bb5771 100644 --- a/crates/ksp-config-lib/unit_tests/registry.rs +++ b/crates/ksp-config-lib/unit_tests/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/registry.rs -// version: 1 +// version: 2 #[test] fn defaults_register_logging_document_and_schema_with_distinct_roots() { @@ -18,6 +18,11 @@ fn defaults_register_logging_document_and_schema_with_distinct_roots() { if let (std::result::Result::Ok(logging), std::result::Result::Ok(schema)) = (logging, schema) { assert_eq!(logging.kind(), super::ConfigFileKind::Config); assert_eq!(logging.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_FILENAME)); + let logging_schema = logging.schema_file_id(); + assert!(logging_schema.is_some(), "logging document should declare its validation schema"); + if let std::option::Option::Some(logging_schema) = logging_schema { + assert_eq!(logging_schema, &schema_id); + } assert_eq!(schema.kind(), super::ConfigFileKind::Schema); assert_eq!(schema.filename(), std::path::Path::new(super::DEFAULT_STD_LOGGING_SCHEMA_FILENAME)); } @@ -143,8 +148,8 @@ fn malformed_filemap_arguments_are_rejected() { #[test] fn duplicate_registry_ids_are_rejected() { - let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json"); - let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json"); + let first = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "first.json", std::option::Option::None); + let second = super::ConfigFileDescriptor::new("cfg.duplicate", super::ConfigFileKind::Config, "second.json", std::option::Option::None); assert!(first.is_ok(), "first descriptor should be valid: {first:?}"); assert!(second.is_ok(), "second descriptor should be valid: {second:?}"); if let (std::result::Result::Ok(first), std::result::Result::Ok(second)) = (first, second) { @@ -158,13 +163,26 @@ fn duplicate_registry_ids_are_rejected() { #[test] fn descriptor_kind_must_match_file_id_namespace() { - let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json"); + let result = super::ConfigFileDescriptor::new("schema.invalid-kind", super::ConfigFileKind::Config, "invalid.json", std::option::Option::None); assert!(result.is_err(), "descriptor kind mismatch must be rejected"); if let std::result::Result::Err(error) = result { assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID); } } +#[test] +fn config_schema_association_must_reference_registered_schema_descriptor() { + let config = super::ConfigFileDescriptor::new("cfg.test", super::ConfigFileKind::Config, "test.json", std::option::Option::Some("schema.test")); + assert!(config.is_ok(), "config descriptor should be valid before registry association validation: {config:?}"); + if let std::result::Result::Ok(config) = config { + let result = super::build_registry([config]); + assert!(result.is_err(), "registry must reject a missing schema association"); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_FILE_MAPPING_INVALID); + } + } +} + fn assert_mapping_invalid(result: ksp_core_lib::Result) { assert!(result.is_err(), "invalid filename must be rejected: {result:?}"); if let std::result::Result::Err(error) = result { diff --git a/deltas/0.1.3/pre.007.md b/deltas/0.1.3/pre.007.md new file mode 100644 index 0000000..c7c55f1 --- /dev/null +++ b/deltas/0.1.3/pre.007.md @@ -0,0 +1,347 @@ + + + +# Delta 0.1.3-pre.007 + +## Base requise + +Livraison précédente validée : + +```text +0.1.3-pre.006 +``` + +Version technique de cette base : + +```text +workspace.package.version = "0.1.3-pre.6" +Cargo.toml header version = 47 +``` + +Validations utilisateur exécutées le 2026-08-15 : + +```text +cargo fmt --all OK +cargo check --workspace OK +cargo clippy --workspace --all-targets OK +cargo test --workspace OK +cargo tree -p ksp-logging-lib OK +cargo tree -p ksp-logging-lib -d OK — aucune duplication +cargo tree -p ksp-logging-lib -e features OK +``` + +Les tranches Logging préalables au premier schema Config sont donc closes. + +## Objet de pre.007 + +Introduire la première surface JSON/JSON Schema réelle de `ksp-config-lib` sans ouvrir encore la résolution de profils, les composites ou l'environnement : + +```text +registry file_id + -> resolved Config path + -> JSON parse + -> registered schema file_id + -> schema parse + meta-schema validation + -> document validation + -> bounded document semantics +``` + +Le premier document concret est : + +```text +cfg.std.logging -> config/std.logging.json +``` + +validé par : + +```text +schema.std.logging -> config/schemas/std.logging.schema.json +``` + +## Dépendances + +Versions vérifiées au moment de l'ajout depuis les sources officielles `crates.io` / `docs.rs` : + +```text +serde 1.0.229 -> workspace constraint ^1.0 +serde_json 1.0.151 -> workspace constraint ^1.0 +jsonschema 0.49.9 -> workspace constraint ^0.49 +``` + +`jsonschema` est déclaré avec : + +```text +default-features = false +``` + +La première surface KSP utilise un schema autonome avec références JSON Pointer internes uniquement ; aucune récupération HTTP/file de schemas distants n'est nécessaire. + +Les trois dépendances sont déclarées uniquement sous `[workspace.dependencies]`, puis consommées par `ksp-config-lib` avec `.workspace = true`. + +## Association document -> schema + +`ConfigFileDescriptor` possède maintenant : + +```text +schema_file_id: Option +``` + +Le mapping par défaut devient conceptuellement : + +```text +cfg.std.logging + kind = Config + filename = std.logging.json + schema_file_id = schema.std.logging + +schema.std.logging + kind = Schema + filename = std.logging.schema.json + schema_file_id = none +``` + +Le registre valide : + +- qu'un schema référencé utilise le namespace `schema.*` ; +- que son descriptor existe réellement ; +- qu'il possède `ConfigFileKind::Schema` ; +- qu'un descriptor Schema ne référence pas lui-même un schema Config. + +Un `--filemap` change uniquement le filename physique et conserve cette association logique. + +Aucun `schema.composite` n'est encore créé : il sera ajouté avec la tranche composite qui en a réellement besoin. + +## Moteur JSON générique + +Nouvelle façade : + +```text +ConfigDocumentEngine +ConfigJsonDocument +``` + +`ConfigDocumentEngine::load_validated_document(file_id)` : + +1. vérifie que le `file_id` désigne un document Config ; +2. résout son path via `ConfigBootstrapOptions` + `ConfigFileRegistry` ; +3. lit et parse le JSON ; +4. charge le schema associé par son propre `file_id` ; +5. valide le schema contre son meta-schema ; +6. valide le document contre le schema Draft 2020-12 ; +7. exécute les invariants sémantiques actuellement connus pour ce type de document ; +8. retourne le JSON validé sans transférer aux consumers la responsabilité de lecture/validation filesystem. + +`ConfigJsonDocument` expose uniquement : + +```text +file_id +resolved path +validated serde_json::Value +``` + +Les autres crates restent interdites de lecture directe des fichiers Config. + +## Diagnostics + +Les nouveaux codes Config distinguent : + +```text +config.json_file_read_failed +config.json_syntax_invalid +config.schema_invalid +config.schema_validation_failed +config.document_semantic_invalid +``` + +Les erreurs enregistrent le `file_id` et le path concernés, mais jamais le contenu JSON complet. + +## Premier schema Logging + +`config/schemas/std.logging.schema.json` utilise JSON Schema Draft 2020-12 et couvre la surface Logging stabilisée en `pre.004/.005/.006` : + +```text +format_version +logs_directory +default_profile +profiles[] + profile_id + default_filter + span_events + console + enabled + output + ansi + format + filter.level + filter.targets[] + filter.domains[] + files[] + output_id + enabled + path + rotation + format + ansi = false + filter.level + filter.targets[] + filter.domains[] + target_filters[] +``` + +Le schema encode notamment : + +- niveaux `off/error/warn/info/debug/trace` ; +- lifecycle `off/new_and_close/full` ; +- formats `human/compact/pretty/json` ; +- rotation `never/hourly/daily` ; +- console `stdout/stderr` ; +- targets KSP ou wildcard `*` ; +- selectors uniques, wildcard utilisé seul ; +- ANSI interdit pour les fichiers ; +- ANSI + JSON console interdit. + +## Document runtime et exemple + +Ajouts : + +```text +config/std.logging.json +config/schemas/std.logging.schema.json +config/examples/std.logging.example.json +``` + +Le runtime utilise déjà : + +```text +"logs_directory": "${KSP_LOGS_DIRECTORY:-logs}" +``` + +mais `pre.007` ne résout encore aucune variable. Le placeholder reste une valeur source string valide jusqu'au resolver de `pre.010`. + +Le runtime démontre : + +- console configurable ; +- plusieurs fichiers ; +- formats humain et JSON ; +- filtres par level/target/domain ; +- target overrides globaux ; +- `output_id` distinct du `file_id` Config. + +## Validation sémantique de base + +Après JSON Schema, Config vérifie déjà les invariants directement liés à la surface Logging : + +- `format_version = 1` ; +- chaînes globales requises non vides ; +- `output_id` fichier conforme ; +- `output_id` uniques dans un même profil ; +- path fichier relatif à `logs_directory`, sans traversal ; +- ANSI fichier interdit ; +- ANSI + JSON console interdit ; +- selectors non vides/uniques ; +- wildcard seul ; +- target selector et global `target_prefix` limités aux targets `ksp-*`. + +Ne sont volontairement **pas encore** traités dans cette tranche : + +- unicité des `profile_id` ; +- résolution de `default_profile` ; +- sélection explicite d'un profil ; +- construction d'une configuration effective globals + profil. + +Ces responsabilités appartiennent à `pre.008`. + +## Tests ajoutés + +Les tests unitaires Config couvrent : + +- document Logging runtime commité valide ; +- fichier Config absent ; +- syntaxe JSON invalide ; +- schema lui-même invalide ; +- document ne satisfaisant pas son schema ; +- document schema-valide mais sémantiquement invalide ; +- association document -> schema présente dans le registre ; +- association vers schema absent refusée. + +Le test d'API publique vérifie que `ConfigDocumentEngine` charge le vrai `std.logging.json` depuis les racines Config du workspace. + +## Hors scope + +Cette tranche ne fait pas encore : + +- résolution globals/profils/`default_profile` ; +- composite ; +- `.env` ; +- `std::env` ; +- interpolation `${...}` ; +- classification secret/public/internal ; +- redaction ; +- adaptation vers `ksp_logging_lib::LoggingSettings` ; +- mutation/persistence. + +`ksp-config-lib` ne dépend donc toujours pas de `ksp-logging-lib` dans `pre.007`. + +## Version technique + +La prerelease devient : + +```text +workspace.package.version = "0.1.3-pre.7" +Cargo.toml header version = 48 +``` + +## Fichiers ajoutés + +```text +config/std.logging.json +config/schemas/std.logging.schema.json +config/examples/std.logging.example.json +crates/ksp-config-lib/src/document.rs +crates/ksp-config-lib/unit_tests/document.rs +deltas/0.1.3/pre.007.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-config-lib/Cargo.toml +crates/ksp-config-lib/src/error.rs +crates/ksp-config-lib/src/lib.rs +crates/ksp-config-lib/src/registry.rs +crates/ksp-config-lib/tests/public_api.rs +crates/ksp-config-lib/unit_tests/registry.rs +docs/plans/000-README.md +docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +docs/rules/FILE_CONTRACTS.md +``` + +## Contrôles exécutés dans l'environnement de génération + +Les fichiers JSON ont été parsés avec `python -m json.tool`. + +Le schema Draft 2020-12 et le document runtime ont également été validés avec l'implémentation Python `jsonschema` disponible dans l'environnement de génération. + +Aucune commande Cargo n'est disponible dans cet environnement ; aucune validation Rust n'est donc déclarée réussie ici. + +## Validations utilisateur à exécuter + +```bash +cargo fmt --all +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test --workspace +cargo tree -p ksp-config-lib +cargo tree -p ksp-config-lib -d +cargo tree -p ksp-config-lib -e features +``` + +Une attention particulière doit être portée au graphe `jsonschema` avec `default-features = false` afin de confirmer qu'aucun stack HTTP/TLS de résolution distante n'est introduit inutilement. + +Après validation de `pre.007`, la prochaine tranche est : + +```text +0.1.3-pre.008 — globals + profils + default_profile +``` diff --git a/docs/plans/000-README.md b/docs/plans/000-README.md index 310992e..fadfbb4 100644 --- a/docs/plans/000-README.md +++ b/docs/plans/000-README.md @@ -1,5 +1,5 @@ - + # Plans KSP @@ -13,7 +13,7 @@ Un plan décrit le périmètre, les décisions déjà acquises, les questions ou - [`002-FUNCTIONAL_RELEASE_SEQUENCE.md`](002-FUNCTIONAL_RELEASE_SEQUENCE.md) — séquence active de référence des premières releases fonctionnelles ; - [`003-V0_1_1_CORE_FOUNDATION_PLAN.md`](003-V0_1_1_CORE_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.1`, établi par `0.1.1-pre.001` puis consolidé jusqu'à `0.1.1-rel.001`. - [`004-V0_1_2_LOGGING_FOUNDATION_PLAN.md`](004-V0_1_2_LOGGING_FOUNDATION_PLAN.md) — plan historique clôturé de la release stable `0.1.2`, établi par `0.1.2-pre.001` puis consolidé jusqu'à `0.1.2-rel.001`. -- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` ferme maintenant le routing structuré `domain` avant le premier schema Logging de `pre.007`. +- [`005-V0_1_3_CONFIG_FOUNDATION_PLAN.md`](005-V0_1_3_CONFIG_FOUNDATION_PLAN.md) — plan actif de `0.1.3 — Configuration foundation`, établi par `0.1.3-pre.001`, corrigé par `pre.001-fix.001`, complété par `pre.001-fix.002` pour le registre `file_id`/bootstrap/non-régression Logging, regranularisé par `pre.001-fix.003`, puis rescindé pendant `pre.005`; `pre.006` a fermé le routing structuré `domain` et `pre.007` livre maintenant le moteur JSON/JSON Schema et le premier `std.logging.json`; `pre.008` poursuit avec globals/profils/`default_profile`. Le `pre.001` de chaque release fonctionnelle peut introduire son propre plan détaillé lorsque la release s'ouvre. diff --git a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md index b6570d9..82ddac4 100644 --- a/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md +++ b/docs/plans/002-FUNCTIONAL_RELEASE_SEQUENCE.md @@ -1,5 +1,5 @@ - + # Séquence des releases fonctionnelles KSP @@ -219,7 +219,7 @@ pre.015 clôture Cette prévision n'est pas un plafond : chaque prerelease doit rester une petite tranche, avec scission explicite si l'objectif dépasse environ 15–20 minutes de travail effectif. -Après validation de `pre.005-fix.001`, `pre.006` ferme le routing Logging structuré `domain`; `pre.007` redevient donc la prochaine tranche Config avec JSON/JSON Schema et le premier document `std.logging.json`. +`pre.006` a fermé le routing Logging structuré `domain`; `pre.007` livre le moteur JSON/JSON Schema et le premier document `std.logging.json`. Après validation utilisateur de `pre.007`, la prochaine tranche est `pre.008` pour globals, profils et `default_profile`. ## `0.1.4` — Config desktop par défaut diff --git a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md index 1028242..354ceba 100644 --- a/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md +++ b/docs/plans/005-V0_1_3_CONFIG_FOUNDATION_PLAN.md @@ -1,11 +1,11 @@ - + # Plan `0.1.3` — Configuration foundation ## 1. Statut et objectif -Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats et `pre.006` ferme le routing structuré `domain`. Le premier schema Logging peut donc commencer en `pre.007` après validation utilisateur de cette tranche. +Ce plan a été établi par `0.1.3-pre.001`, corrigé par `0.1.3-pre.001-fix.001/.002/.003`, puis exécuté par petites tranches. `pre.002` a livré le bootstrap Config, `pre.003` le registre `file_id`, `pre.004` les contrats publics multi-output de Logging, `pre.005` le runtime multi-sink sur niveau/target/formats, `pre.006` le routing structuré `domain` et `pre.007` introduit le moteur JSON/JSON Schema ainsi que le premier document runtime `std.logging.json`. La prochaine tranche est `pre.008` pour la résolution des globals/profils/`default_profile`. La base auditée reste la release stable `v0.1.2`. @@ -280,10 +280,9 @@ Premier mapping codé dans `ksp-config-lib` : ```text cfg.std.logging -> std.logging.json schema.std.logging -> std.logging.schema.json -schema.composite -> composite.schema.json ``` -Le descriptor `cfg.std.logging` référence logiquement `schema.std.logging`; il ne contient pas le nom physique du schema. +Le descriptor `cfg.std.logging` référence logiquement `schema.std.logging`; il ne contient pas le nom physique du schema. Le futur `schema.composite` n'est ajouté au registre qu'avec la tranche composite qui en a réellement besoin. Un futur consumer pourra introduire par le registre KSP : @@ -1701,19 +1700,25 @@ Implémentation candidate livrée par `pre.006` : Aucune dépendance externe ou feature Cargo supplémentaire n'est nécessaire pour cette tranche. `pre.007` peut donc construire `std.logging.schema.json` sur une surface Logging qui représente et exécute réellement les trois dimensions de routing retenues : level, target et domain. -La validation utilisateur de `pre.006` reste requise avant l'ouverture de `pre.007`. +La validation utilisateur de `pre.006` est acquise : `fmt/check/clippy/test` et les trois graphes Cargo demandés sont propres. ### `0.1.3-pre.007` — JSON/JSON Schema + premier document Logging -- `serde`/`serde_json`/`jsonschema` au workspace avec versions revérifiées ; -- infrastructure générique de lecture JSON ; -- association descriptor -> schema par `file_id` ; -- validation JSON Schema ; -- `config/`, `config/schemas/`, `config/examples/` ; -- `std.logging.schema.json` aligné sur les contrats Logging réellement stabilisés en `pre.004/.005/.006` ; -- runtime `config/std.logging.json` ; -- exemple Logging ; -- parse/schema/validation sémantique de base. +Tranche livrée : + +- `serde ^1.0`, `serde_json ^1.0` et `jsonschema ^0.49` sont déclarés au workspace puis consommés avec `.workspace = true` par `ksp-config-lib` ; les versions observées au moment de l'ajout sont respectivement `1.0.229`, `1.0.151` et `0.49.9` ; +- `jsonschema` est consommé avec `default-features = false` : `0.1.3` n'a besoin d'aucune récupération HTTP/file de références externes pour le schema Logging autonome ; +- `ConfigFileDescriptor` possède désormais l'association logique optionnelle `schema_file_id`; `cfg.std.logging` référence `schema.std.logging` ; +- le registre vérifie qu'un schema référencé existe réellement et possède `ConfigFileKind::Schema` ; +- `ConfigDocumentEngine` centralise la lecture des JSON enregistrés, la validation du document schema contre son meta-schema, la validation de l'instance puis les invariants sémantiques propres au document ; +- les diagnostics distinguent lecture impossible, syntaxe JSON invalide, schema invalide, échec de validation schema et invalidité sémantique ; +- `config/std.logging.json`, `config/schemas/std.logging.schema.json` et `config/examples/std.logging.example.json` constituent la première surface Config réelle ; +- le schema est JSON Schema Draft 2020-12 et reflète la surface Logging stabilisée : filtre global, lifecycle spans, console, multi-fichiers, `output_id`, rotation, formats, ANSI et routing `level/target/domain` ; +- le document source contient déjà `logs_directory`, `default_profile` et `profiles[]`, mais `pre.007` ne résout encore aucun profil et ne vérifie pas encore l'unicité des `profile_id` ni que `default_profile` référence un profil existant ; ces responsabilités restent explicitement à `pre.008` ; +- la validation sémantique de base couvre notamment les `output_id` fichier, chemins relatifs sous `logs_directory`, ANSI fichier interdit, incompatibilité ANSI+JSON console, selectors wildcard/target KSP et unicité des `output_id` dans un profil ; +- aucune interpolation `${...}` n'est encore exécutée : `${KSP_LOGS_DIRECTORY:-logs}` reste une string source schema-valide jusqu'au resolver de `pre.010`. + +Aucune dépendance à `ksp-logging-lib` n'est encore nécessaire dans Config : le mapping vers `LoggingSettings` reste réservé à `pre.012`. ### `0.1.3-pre.008` — globals + profils + `default_profile` diff --git a/docs/rules/FILE_CONTRACTS.md b/docs/rules/FILE_CONTRACTS.md index bdd0546..5143627 100644 --- a/docs/rules/FILE_CONTRACTS.md +++ b/docs/rules/FILE_CONTRACTS.md @@ -1,5 +1,5 @@ - + # Contrats des fichiers @@ -36,6 +36,16 @@ Les règles `FILE-*` définissent la responsabilité et le mode de modification | futurs documents de référence | Définir vocabulaire, identifiants et références canoniques. | Mettre à jour quand la référence canonique évolue. | | futures validations | Conserver des résultats réellement exécutés. | Ne jamais enregistrer une validation supposée comme réussie. | +## Répertoire `config/` + +| Fichier/famille | Responsabilité | Règle de modification | +|--------------------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `config/std..json` | Document runtime spécialisé Config possédé par `ksp-config-lib`. | Identifié par un `file_id` stable, validé par son schema enregistré et lu/modifié uniquement via Config. Comme JSON ne porte pas de commentaires de header, la version du format appartient au champ JSON `format_version`. | +| `config/schemas/*.schema.json` | JSON Schema des documents Config gérés. | Identifié par un `file_id` `schema.*`; le schema doit être valide pour le draft déclaré avant validation d'une instance. Aucun secret/runtime local ne doit y apparaître. | +| `config/examples/*.json` | Exemples versionnés séparés des vrais fichiers runtime. | Doivent rester schema-valides et illustratifs ; ils ne constituent jamais une source runtime implicite. | + +Les noms physiques sont remplaçables via le registre Config lorsque le contrat le permet ; les consumers référencent les documents par `file_id`, pas par filename. + ## Répertoire `prompts/` | Fichier/famille | Responsabilité | Règle de modification |