A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add [package].name scope contract to mlf.toml

Introduce a required [package] section whose name field declares the
package's NSID prefix. Every .mlf file's namespace must be equal to or
descended from it; a mis-scoped file is a miette-rendered error in
mlf check rather than a silent pass. mlf init prompts for the name
interactively (and derives a placeholder from the directory under
--yes); mlf fetch's implicit mlf.toml auto-create is dropped since a
package name would need its own prompt — users mlf init first.

authored by stavola.xyz and committed by

Tangled 68b2c1a7 bca36461

+251 -63
+60 -16
mlf-cli/src/check.rs
··· 1 - use crate::config::{ConfigError, MlfConfig, find_project_root, get_mlf_cache_dir}; 1 + use crate::config::{ConfigError, MlfConfig, PackageConfig, find_project_root, get_mlf_cache_dir}; 2 2 use crate::workspace_ext::workspace_with_std_and_cache; 3 3 use miette::Diagnostic; 4 4 use mlf_diagnostics::{ParseDiagnostic, ValidationDiagnostic}; ··· 46 46 #[error("Failed to load config: {0}")] 47 47 #[diagnostic(code(mlf::check::config_error))] 48 48 ConfigError(#[from] ConfigError), 49 + 50 + #[error("Lexicon `{namespace}` (in {file}) is outside package scope `{package_name}.*`")] 51 + #[diagnostic( 52 + code(mlf::check::scope_violation), 53 + help( 54 + "Move the file under `{package_name}.*` or change `[package].name` in mlf.toml so it covers this namespace." 55 + ) 56 + )] 57 + ScopeViolation { 58 + file: String, 59 + namespace: String, 60 + package_name: String, 61 + }, 62 + } 63 + 64 + fn enforce_scope( 65 + namespace: &str, 66 + file: &str, 67 + package: Option<&PackageConfig>, 68 + ) -> Result<(), CheckError> { 69 + let Some(pkg) = package else { 70 + return Ok(()); 71 + }; 72 + if pkg.namespace_is_in_scope(namespace) { 73 + return Ok(()); 74 + } 75 + Err(CheckError::ScopeViolation { 76 + file: file.to_string(), 77 + namespace: namespace.to_string(), 78 + package_name: pkg.name.clone(), 79 + }) 49 80 } 50 81 51 82 pub fn run_check( ··· 57 88 source: e, 58 89 })?; 59 90 60 - // Determine root directory and input paths 61 - let (root_dir, file_paths) = if input_paths.is_empty() { 91 + // Determine root directory, input paths, and the package config (if any). 92 + // The package config drives the scope contract — when present, every 93 + // lexicon we load must declare a namespace descended from 94 + // `[package].name`. 95 + let (root_dir, file_paths, package) = if input_paths.is_empty() { 62 96 // No input provided: must use mlf.toml 63 97 match find_project_root(&current_dir) { 64 98 Ok(project_root) => { ··· 71 105 config.source.directory 72 106 ); 73 107 74 - // Collect all .mlf files from source directory 75 108 let files = collect_mlf_files(&source_dir)?; 76 - (root, files) 109 + (root, files, Some(config.package)) 77 110 } 78 111 Err(ConfigError::NotFound) => { 79 112 return Err(CheckError::ValidationErrors { ··· 83 116 Err(e) => return Err(CheckError::ConfigError(e)), 84 117 } 85 118 } else { 86 - // Input provided: determine root 87 - let root = if let Some(explicit) = explicit_root { 88 - // --root flag takes precedence 89 - explicit 119 + // Input provided: determine root, and pick up the package config 120 + // if one exists, but don't require it — users may be linting 121 + // standalone files outside a project. 122 + let (root, package) = if let Some(explicit) = explicit_root { 123 + let pkg = find_project_root(&current_dir) 124 + .ok() 125 + .and_then(|r| MlfConfig::load(&r.join("mlf.toml")).ok()) 126 + .map(|c| c.package); 127 + (explicit, pkg) 90 128 } else if let Ok(project_root) = find_project_root(&current_dir) { 91 - // Try to use mlf.toml source directory 92 129 let config_path = project_root.join("mlf.toml"); 93 130 if let Ok(config) = MlfConfig::load(&config_path) { 94 - project_root.join(&config.source.directory) 131 + ( 132 + project_root.join(&config.source.directory), 133 + Some(config.package), 134 + ) 95 135 } else { 96 - current_dir.clone() 136 + (current_dir.clone(), None) 97 137 } 98 138 } else { 99 - // Fall back to current directory 100 - current_dir.clone() 139 + (current_dir.clone(), None) 101 140 }; 102 141 103 - // Collect files from input paths 104 142 let mut files = Vec::new(); 105 143 for input_path in input_paths { 106 144 let path = if input_path.is_absolute() { ··· 120 158 }); 121 159 } 122 160 } 123 - (root, files) 161 + (root, files, package) 124 162 }; 125 163 126 164 // Try to load cached lexicons from .mlf directory ··· 162 200 }; 163 201 164 202 let namespace = extract_namespace(&file_path, &root_dir)?; 203 + 204 + if let Err(e) = enforce_scope(&namespace, &filename, package.as_ref()) { 205 + eprintln!("{:?}", miette::Report::new(e)); 206 + had_parse_errors = true; 207 + continue; 208 + } 165 209 166 210 if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { 167 211 let diagnostic =
+110 -18
mlf-cli/src/config.rs
··· 13 13 14 14 #[error("No mlf.toml found in current directory or parent directories")] 15 15 NotFound, 16 + 17 + #[error( 18 + "Lexicon `{namespace}` (in {file}) is outside this package's scope `{package_name}.*`. Move the file or update `[package].name` in mlf.toml." 19 + )] 20 + ScopeViolation { 21 + file: String, 22 + namespace: String, 23 + package_name: String, 24 + }, 16 25 } 17 26 18 - #[derive(Debug, Serialize, Deserialize)] 27 + #[derive(Debug, Serialize, Deserialize, Clone)] 19 28 pub struct MlfConfig { 29 + pub package: PackageConfig, 30 + 20 31 #[serde(default)] 21 32 pub source: SourceConfig, 22 33 ··· 27 38 pub dependencies: DependenciesConfig, 28 39 } 29 40 30 - #[derive(Debug, Serialize, Deserialize)] 41 + /// Identity of this MLF package. 42 + /// 43 + /// `name` is the NSID prefix every lexicon in the package must sit under — 44 + /// e.g. `name = "com.example.forum"` means every `.mlf` file in the 45 + /// workspace must declare a namespace that equals `com.example.forum` or 46 + /// descends from it. The contract is checked when lexicons are loaded; a 47 + /// mis-scoped file is a hard error, not a warning. 48 + #[derive(Debug, Serialize, Deserialize, Clone)] 49 + pub struct PackageConfig { 50 + pub name: String, 51 + } 52 + 53 + impl PackageConfig { 54 + /// Is `namespace` `name` itself or a descendant of it? 55 + pub fn namespace_is_in_scope(&self, namespace: &str) -> bool { 56 + if namespace == self.name { 57 + return true; 58 + } 59 + let prefix = format!("{}.", self.name); 60 + namespace.starts_with(&prefix) 61 + } 62 + 63 + /// Produce a [`ConfigError::ScopeViolation`] for the given out-of-scope 64 + /// namespace + source file path. 65 + pub fn scope_violation(&self, file: impl Into<String>, namespace: &str) -> ConfigError { 66 + ConfigError::ScopeViolation { 67 + file: file.into(), 68 + namespace: namespace.to_string(), 69 + package_name: self.name.clone(), 70 + } 71 + } 72 + } 73 + 74 + #[derive(Debug, Serialize, Deserialize, Clone)] 31 75 pub struct SourceConfig { 32 76 #[serde( 33 77 default = "default_source_directory", ··· 52 96 s == default_source_directory() 53 97 } 54 98 55 - #[derive(Debug, Serialize, Deserialize)] 99 + #[derive(Debug, Serialize, Deserialize, Clone)] 56 100 pub struct OutputConfig { 57 101 pub r#type: String, 58 102 pub directory: String, 59 103 } 60 104 61 - #[derive(Debug, Serialize, Deserialize)] 105 + #[derive(Debug, Serialize, Deserialize, Clone)] 62 106 pub struct DependenciesConfig { 63 107 #[serde(default)] 64 108 pub dependencies: Vec<String>, ··· 102 146 } 103 147 } 104 148 105 - impl Default for MlfConfig { 106 - fn default() -> Self { 149 + impl MlfConfig { 150 + /// Create a fresh config for a package. The package name is required; 151 + /// all other fields default. 152 + pub fn new(package_name: impl Into<String>) -> Self { 107 153 Self { 154 + package: PackageConfig { 155 + name: package_name.into(), 156 + }, 108 157 source: SourceConfig::default(), 109 158 output: vec![], 110 159 dependencies: DependenciesConfig::default(), 111 160 } 112 161 } 113 - } 114 162 115 - impl MlfConfig { 116 163 pub fn load(path: &Path) -> Result<Self, ConfigError> { 117 164 let content = std::fs::read_to_string(path)?; 118 165 let config: MlfConfig = toml::from_str(&content)?; ··· 120 167 } 121 168 122 169 pub fn save(&self, path: &Path) -> Result<(), ConfigError> { 123 - let content = toml::to_string_pretty(self) 124 - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; 170 + let content = 171 + toml::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?; 125 172 std::fs::write(path, content)?; 126 173 Ok(()) 127 174 } 128 175 129 - pub fn create_default(path: &Path) -> Result<Self, ConfigError> { 130 - let config = MlfConfig::default(); 176 + /// Create a new mlf.toml at `path` for a package with the given name. 177 + pub fn create(path: &Path, package_name: impl Into<String>) -> Result<Self, ConfigError> { 178 + let config = MlfConfig::new(package_name); 131 179 config.save(path)?; 132 180 Ok(config) 133 181 } ··· 219 267 } 220 268 221 269 let content = std::fs::read_to_string(path)?; 222 - toml::from_str(&content).map_err(|e| ConfigError::ParseError(e)) 270 + toml::from_str(&content).map_err(ConfigError::ParseError) 223 271 } 224 272 225 273 pub fn save(&self, path: &Path) -> Result<(), ConfigError> { 226 - let content = toml::to_string_pretty(self) 227 - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; 274 + let content = 275 + toml::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?; 228 276 std::fs::write(path, content)?; 229 277 Ok(()) 230 278 } ··· 253 301 use super::*; 254 302 255 303 #[test] 256 - fn test_default_config() { 257 - let config = MlfConfig::default(); 304 + fn new_config_carries_package_name() { 305 + let config = MlfConfig::new("com.example.forum"); 306 + assert_eq!(config.package.name, "com.example.forum"); 258 307 assert_eq!(config.source.directory, "./lexicons"); 259 308 assert!(config.output.is_empty()); 260 309 assert!(config.dependencies.dependencies.is_empty()); 261 310 } 262 311 263 312 #[test] 264 - fn test_lockfile_basic() { 313 + fn load_requires_package_section() { 314 + // TOML missing `[package]` → parse error. 315 + let toml_str = r#" 316 + [source] 317 + directory = "./lexicons" 318 + "#; 319 + let err = toml::from_str::<MlfConfig>(toml_str).unwrap_err(); 320 + assert!(err.to_string().to_lowercase().contains("package")); 321 + } 322 + 323 + #[test] 324 + fn package_scope_accepts_exact_and_descendants() { 325 + let pkg = PackageConfig { 326 + name: "com.example.forum".into(), 327 + }; 328 + assert!(pkg.namespace_is_in_scope("com.example.forum")); 329 + assert!(pkg.namespace_is_in_scope("com.example.forum.thread")); 330 + assert!(pkg.namespace_is_in_scope("com.example.forum.thread.reply")); 331 + } 332 + 333 + #[test] 334 + fn package_scope_rejects_siblings_and_unrelated() { 335 + let pkg = PackageConfig { 336 + name: "com.example.forum".into(), 337 + }; 338 + assert!(!pkg.namespace_is_in_scope("com.example.forums")); // not a dot boundary 339 + assert!(!pkg.namespace_is_in_scope("com.other.forum")); 340 + assert!(!pkg.namespace_is_in_scope("com.example")); 341 + } 342 + 343 + #[test] 344 + fn scope_violation_names_file_and_prefix() { 345 + let pkg = PackageConfig { 346 + name: "com.foo".into(), 347 + }; 348 + let err = pkg.scope_violation("lexicons/com/bar/baz.mlf", "com.bar.baz"); 349 + let msg = err.to_string(); 350 + assert!(msg.contains("com.bar.baz")); 351 + assert!(msg.contains("com.foo")); 352 + assert!(msg.contains("lexicons/com/bar/baz.mlf")); 353 + } 354 + 355 + #[test] 356 + fn lockfile_basic() { 265 357 let mut lockfile = LockFile::new(); 266 358 assert_eq!(lockfile.version, 1); 267 359 assert!(lockfile.lexicons.is_empty());
+4 -16
mlf-cli/src/fetch.rs
··· 103 103 match find_project_root(current_dir) { 104 104 Ok(root) => Ok(root), 105 105 Err(ConfigError::NotFound) => { 106 - // Ask user if they want to create mlf.toml 107 106 eprintln!("No mlf.toml found in current or parent directories."); 108 - eprintln!("Would you like to create one in the current directory? (y/n)"); 109 - 110 - let mut input = String::new(); 111 - std::io::stdin() 112 - .read_line(&mut input) 113 - .map_err(FetchError::InitFailed)?; 114 - 115 - if input.trim().to_lowercase() == "y" { 116 - let config_path = current_dir.join("mlf.toml"); 117 - MlfConfig::create_default(&config_path).map_err(FetchError::NoProjectRoot)?; 118 - println!("Created mlf.toml in {}", current_dir.display()); 119 - Ok(current_dir.to_path_buf()) 120 - } else { 121 - Err(FetchError::NoProjectRoot(ConfigError::NotFound)) 122 - } 107 + eprintln!( 108 + "Run `mlf init` first to set up a project (it will prompt for the package NSID prefix)." 109 + ); 110 + Err(FetchError::NoProjectRoot(ConfigError::NotFound)) 123 111 } 124 112 Err(e) => Err(FetchError::NoProjectRoot(e)), 125 113 }
+44 -8
mlf-cli/src/init.rs
··· 5 5 let current_dir = std::env::current_dir()?; 6 6 let config_path = current_dir.join("mlf.toml"); 7 7 8 - // Check if mlf.toml already exists 9 8 if config_path.exists() { 10 9 eprintln!("mlf.toml already exists in current directory"); 11 10 eprintln!("Remove it first if you want to reinitialize"); ··· 33 32 } 34 33 } 35 34 36 - // Create default mlf.toml 37 - let config = MlfConfig::default(); 38 - config 39 - .save(&config_path) 40 - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; 35 + // Collect the package NSID prefix. 36 + let package_name = if skip_prompts { 37 + // --yes was passed; derive a best-effort placeholder the user can 38 + // edit. Tying this to the directory name is a better starting 39 + // point than a magic constant. 40 + default_package_name(&current_dir) 41 + } else { 42 + prompt_package_name()? 43 + }; 41 44 42 - println!("✓ Created mlf.toml"); 45 + let config = MlfConfig::create(&config_path, package_name) 46 + .map_err(|e| std::io::Error::other(e.to_string()))?; 43 47 44 - // Initialize .mlf directory 48 + println!("✓ Created mlf.toml (package `{}`)", config.package.name); 49 + 45 50 init_mlf_cache(&current_dir)?; 46 51 println!("✓ Initialized .mlf/ directory"); 47 52 ··· 56 61 57 62 Ok(()) 58 63 } 64 + 65 + fn prompt_package_name() -> Result<String, std::io::Error> { 66 + loop { 67 + print!("Package NSID prefix (e.g. com.example.forum): "); 68 + std::io::stdout().flush()?; 69 + let mut input = String::new(); 70 + std::io::stdin().read_line(&mut input)?; 71 + let trimmed = input.trim(); 72 + if trimmed.is_empty() { 73 + eprintln!("A package name is required (at least two dot-separated segments)."); 74 + continue; 75 + } 76 + if !trimmed.contains('.') { 77 + eprintln!("Expected a dot-separated NSID prefix (e.g. com.example.forum)."); 78 + continue; 79 + } 80 + return Ok(trimmed.to_string()); 81 + } 82 + } 83 + 84 + fn default_package_name(dir: &std::path::Path) -> String { 85 + let dir_name = dir 86 + .file_name() 87 + .and_then(|s| s.to_str()) 88 + .unwrap_or("package"); 89 + if dir_name.contains('.') { 90 + dir_name.to_string() 91 + } else { 92 + format!("example.{dir_name}") 93 + } 94 + }
+19
website/content/docs/cli/02-configuration.md
··· 11 11 Create an `mlf.toml` file in your project root: 12 12 13 13 ```toml 14 + [package] 15 + name = "com.example.forum" 16 + 14 17 [source] 15 18 directory = "./lexicons" 16 19 ··· 27 30 ``` 28 31 29 32 ## Configuration Sections 33 + 34 + ### Package Identity 35 + 36 + The `[package]` section declares this project's NSID prefix. It's **required**. 37 + 38 + ```toml 39 + [package] 40 + name = "com.example.forum" 41 + ``` 42 + 43 + Every lexicon in your workspace must declare a namespace that equals `name` or descends from it — `com.example.forum`, `com.example.forum.thread`, `com.example.forum.thread.reply` are all in-scope; `com.example.forums` (note the trailing `s`) and `com.other.thing` are not. A mis-scoped file is a hard error at load time, not a warning, so typos can't silently leak into generated output or (eventually) a `mlf publish` run. 44 + 45 + This contract is also what lets the publisher know which DNS authorities it will need to touch: every NSID under `name` rolls up to one or more `_lexicon.<authority>` TXT records under your DNS zone. 30 46 31 47 ### Source Directory 32 48 ··· 219 235 Here's a complete `mlf.toml` for a TypeScript project using ATProto lexicons: 220 236 221 237 ```toml 238 + [package] 239 + name = "com.example.forum" 240 + 222 241 [source] 223 242 directory = "./lexicons" 224 243
+14 -5
website/content/docs/cli/03-init.md
··· 9 9 ## Usage 10 10 11 11 ```bash 12 - # Interactive initialization (prompts for confirmation) 12 + # Interactive initialization (prompts for confirmation and package NSID) 13 13 mlf init 14 14 15 - # Skip prompts 15 + # Skip prompts (package NSID defaults to the containing directory name) 16 16 mlf init --yes 17 17 ``` 18 18 ··· 23 23 24 24 Running `mlf init` creates: 25 25 26 - 1. **mlf.toml** - Project configuration file with defaults: 26 + 1. **mlf.toml** - Project configuration file. Interactive mode prompts for the package NSID prefix; `--yes` derives a placeholder from the containing directory that you can edit: 27 27 ```toml 28 + [package] 29 + name = "com.example.forum" 30 + 28 31 [source] 29 32 directory = "./lexicons" 30 33 31 34 [dependencies] 32 35 dependencies = [] 33 36 ``` 37 + 38 + `[package].name` is the NSID prefix every `.mlf` file in this project must sit under. See [Configuration → Package Identity](../02-configuration/#package-identity) for the scope contract. 34 39 35 40 2. **.mlf/** - Cache directory structure: 36 41 ``` ··· 57 62 - .mlf/ (cache directory for fetched lexicons) 58 63 59 64 Continue? (y/n): y 60 - ✓ Created mlf.toml 65 + Package NSID prefix (e.g. com.example.forum): com.example.forum 66 + ✓ Created mlf.toml (package `com.example.forum`) 61 67 ✓ Initialized .mlf/ directory 62 68 63 69 Project initialized successfully! ··· 75 81 76 82 ```bash 77 83 $ mlf init --yes 78 - ✓ Created mlf.toml 84 + ✓ Created mlf.toml (package `example.my-project`) 79 85 ✓ Initialized .mlf/ directory 80 86 81 87 Project initialized successfully! ··· 153 159 154 160 **mlf.toml:** 155 161 ```toml 162 + [package] 163 + name = "com.example.forum" 164 + 156 165 [source] 157 166 directory = "./lexicons" 158 167