Skip to content

Commit 0e5d4e9

Browse files
ekropotinclaude
andcommitted
feat: refactor config discovery and severity handling
Simplified config discovery which uses git and cwd boundaries only: - Remove project markers (Cargo.toml, package.json, go.mod, etc.) as boundaries - Keep only git repositories (.git) as meaningful boundaries for config search - Add current working directory as boundary for CLI mode - Update ConfigDiscovery struct to track current working directory - Maintain workspace root boundaries for LSP integration - Update README.md to reflect new simpler boundary detection strategy - Remove obsolete test for Cargo.toml boundary detection - Fix CLI integration test naming for consistency Fix the bug, when the violations printed with WARN severity instead of ERR: - The `print_cli_errors` function in `quickmark-cli` now directly uses the severity from the `RuleViolation` struct, instead of looking it up from the configuration. This simplifies the code and makes it more robust. - The `MultiRuleLinter` in `quickmark-core` now injects the correct severity into each `RuleViolation` as it is created. - The test for `print_cli_errors` has been updated to reflect these changes and now uses the `MultiRuleLinter` to generate violations with the correct severities. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a56f431 commit 0e5d4e9

6 files changed

Lines changed: 76 additions & 114 deletions

File tree

.markdownlint.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
default: false
2+
3+
MD051: true

README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,42 +211,48 @@ QuickMark uses a sophisticated hierarchical configuration discovery system that
211211

212212
#### Hierarchical Configuration Discovery
213213

214-
QuickMark automatically discovers configuration files by searching upward from the target markdown file's directory, stopping at natural project boundaries. This enables different parts of your project to have their own linting rules while maintaining a sensible inheritance hierarchy.
214+
QuickMark automatically discovers configuration files by searching upward from the target markdown file's directory, stopping at repository boundaries. This enables different parts of your project to have their own linting rules while maintaining a sensible inheritance hierarchy.
215215

216216
**Search Process:**
217217

218218
- Starts from the directory containing the target markdown file
219219
- Searches upward through parent directories for `quickmark.toml` files
220220
- Uses the first configuration file found
221-
- Stops searching when it encounters project boundary markers
221+
- Stops searching when it encounters boundary markers
222222

223-
**Project Boundary Markers** (search stops at these):
223+
**Boundary Markers** (search stops at these):
224224

225-
- **IDE Workspace Roots**: Configured workspace directories (LSP integration)
225+
- **IDE Workspace Roots**: Configured workspace directories (LSP integration only)
226226
- **Git Repository Root**: Directories containing `.git`
227-
- **Common Project Markers**: `package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`, `.vscode`, `.idea`, `.sublime-project`
227+
- **Current Working Directory**: For CLI usage (prevents searching beyond the directory where you ran the command)
228228

229229
**Example Hierarchical Structure:**
230230

231231
```
232232
my-project/
233+
├── .git/ # Git repository boundary (search stops here)
233234
├── quickmark.toml # Project-wide config (relaxed rules)
234-
├── Cargo.toml # Project boundary marker
235+
├── Cargo.toml # Regular project file (ignored during config search)
235236
├── README.md # Uses project-wide config
236237
├── src/
237238
│ ├── quickmark.toml # Stricter rules for source code
238239
│ ├── api.md # Uses src/ config
239240
│ └── docs/
240241
│ └── guide.md # Inherits src/ config (stricter)
241-
└── tests/
242-
└── integration.md # Uses project-wide config (relaxed)
242+
├── tests/
243+
│ └── integration.md # Uses project-wide config (relaxed)
244+
└── vendor/
245+
└── external-lib/
246+
├── .git/ # Another git boundary (search stops here)
247+
└── README.md # Uses default config (no inheritance from parent)
243248
```
244249

245250
In this example:
246251

247252
- `src/api.md` and `src/docs/guide.md` use the stricter `src/quickmark.toml` configuration
248-
- `README.md` and `tests/integration.md` use the relaxed project-wide `quickmark.toml` configuration
249-
- Search stops at `Cargo.toml` level, preventing the search from going beyond the project boundary
253+
- `README.md` and `tests/integration.md` use the relaxed project-wide `quickmark.toml` configuration
254+
- `vendor/external-lib/README.md` uses the default configuration because the search stops at the `.git` boundary
255+
- Only `.git` directories act as boundaries - other project markers like `Cargo.toml` are ignored
250256

251257
#### Using QUICKMARK_CONFIG Environment Variable
252258

crates/quickmark-cli/src/main.rs

Lines changed: 12 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,9 @@ fn is_markdown_file(path: &Path) -> bool {
127127
}
128128

129129
/// Print linting errors with 1-based line numbering for CLI display
130-
fn print_cli_errors(results: &[RuleViolation], config: &QuickmarkConfig) -> (i32, i32) {
131-
let severities = &config.linters.severity;
132-
130+
fn print_cli_errors(results: &[RuleViolation]) -> (i32, i32) {
133131
let res = results.iter().fold((0, 0), |(errs, warns), v| {
134-
let severity = severities.get(v.rule().alias).unwrap();
132+
let severity = v.severity();
135133
let prefix;
136134
let mut new_err = errs;
137135
let mut new_warns = warns;
@@ -224,7 +222,7 @@ fn main() -> anyhow::Result<()> {
224222

225223
// Use optimized single config loading only when QUICKMARK_CONFIG is set
226224
// Otherwise, preserve hierarchical config discovery for correctness
227-
let (all_violations, config) = if std::env::var("QUICKMARK_CONFIG").is_ok() {
225+
let (all_violations, _config) = if std::env::var("QUICKMARK_CONFIG").is_ok() {
228226
// Performance optimization: Load config once when using environment config
229227
let pwd = env::current_dir()?;
230228
let config = config_from_env_path_or_default(&pwd)?;
@@ -262,7 +260,7 @@ fn main() -> anyhow::Result<()> {
262260
(violations, config)
263261
};
264262

265-
let (errs, _) = print_cli_errors(&all_violations, &config);
263+
let (errs, _) = print_cli_errors(&all_violations);
266264
let exit_code = min(errs, 1);
267265
exit(exit_code);
268266
}
@@ -271,8 +269,6 @@ fn main() -> anyhow::Result<()> {
271269
mod tests {
272270
use super::*;
273271
use quickmark_core::config::{HeadingStyle, LintersSettingsTable, MD003HeadingStyleTable};
274-
use quickmark_core::linter::{CharPosition, Range};
275-
use quickmark_core::rules::{md001::MD001, md003::MD003};
276272
use quickmark_core::test_utils::test_helpers::test_config_with_settings;
277273
use std::path::{Path, PathBuf};
278274

@@ -290,41 +286,15 @@ mod tests {
290286
..Default::default()
291287
},
292288
);
293-
let range = Range {
294-
start: CharPosition {
295-
line: 1,
296-
character: 1,
297-
},
298-
end: CharPosition {
299-
line: 1,
300-
character: 5,
301-
},
302-
};
303-
let file = PathBuf::default();
304-
let results = vec![
305-
RuleViolation::new(
306-
&MD001,
307-
"all is bad".to_string(),
308-
file.clone(),
309-
range.clone(),
310-
),
311-
RuleViolation::new(
312-
&MD003,
313-
"all is even worse".to_string(),
314-
file.clone(),
315-
range.clone(),
316-
),
317-
RuleViolation::new(
318-
&MD003,
319-
"all is even worse2".to_string(),
320-
file.clone(),
321-
range,
322-
),
323-
];
324-
325-
let (errs, warns) = print_cli_errors(&results, &config);
289+
290+
let file_path = PathBuf::from("test.md");
291+
let file_content = "# Heading 1\n\n### Heading 3\n\nHeading 1\n=========\n";
292+
let mut linter = MultiRuleLinter::new_for_document(file_path, config.clone(), file_content);
293+
let results = linter.analyze();
294+
295+
let (errs, warns) = print_cli_errors(&results);
326296
assert_eq!(1, errs);
327-
assert_eq!(2, warns);
297+
assert_eq!(1, warns);
328298
}
329299

330300
#[test]

crates/quickmark-cli/tests/cli_integration_tests.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ fn test_cli_hierarchical_config_discovery() {
448448

449449
/// Test that config discovery stops at git repository boundaries
450450
#[test]
451-
fn test_cli_config_discovery_git_boundary() {
451+
fn test_cli_config_discovery_stops_at_git_boundary() {
452452
// Create a temporary git repository structure
453453
let temp_dir = TempDir::new().unwrap();
454454

crates/quickmark-core/src/config/mod.rs

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ pub enum ConfigSearchResult {
211211
/// Hierarchical config discovery with workspace root stopping point
212212
pub struct ConfigDiscovery {
213213
workspace_roots: Vec<PathBuf>,
214+
current_working_dir: Option<PathBuf>,
214215
}
215216

216217
impl Default for ConfigDiscovery {
@@ -220,17 +221,20 @@ impl Default for ConfigDiscovery {
220221
}
221222

222223
impl ConfigDiscovery {
223-
/// Create a new ConfigDiscovery for CLI usage (no workspace roots)
224+
/// Create a new ConfigDiscovery for CLI usage (uses current working directory as boundary)
224225
pub fn new() -> Self {
226+
let current_working_dir = std::env::current_dir().ok();
225227
Self {
226228
workspace_roots: Vec::new(),
229+
current_working_dir,
227230
}
228231
}
229232

230233
/// Create a new ConfigDiscovery for LSP usage with workspace roots
231234
pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
232235
Self {
233236
workspace_roots: roots,
237+
current_working_dir: None,
234238
}
235239
}
236240

@@ -291,35 +295,25 @@ impl ConfigDiscovery {
291295

292296
/// Determine if search should stop at the current directory
293297
fn should_stop_search(&self, dir: &Path) -> bool {
294-
// 1. IDE Workspace Root (highest priority)
298+
// Stop at workspace roots (for LSP mode)
295299
for workspace_root in &self.workspace_roots {
296300
if dir == workspace_root.as_path() {
297301
return true;
298302
}
299303
}
300304

301-
// 2. Git Repository Root
302-
if dir.join(".git").exists() {
303-
return true;
304-
}
305-
306-
// 3. Common Project Root Markers
307-
let project_markers = [
308-
"package.json",
309-
"Cargo.toml",
310-
"pyproject.toml",
311-
"go.mod",
312-
".vscode",
313-
".idea",
314-
".sublime-project",
315-
];
316-
317-
for marker in &project_markers {
318-
if dir.join(marker).exists() {
305+
// Stop at current working directory (for CLI mode)
306+
if let Some(cwd) = &self.current_working_dir {
307+
if dir == cwd.as_path() {
319308
return true;
320309
}
321310
}
322311

312+
// Stop at git repository boundaries
313+
if dir.join(".git").exists() {
314+
return true;
315+
}
316+
323317
false
324318
}
325319
}
@@ -1351,10 +1345,10 @@ mod test {
13511345
let src_dir = repo_dir.join("src");
13521346
std::fs::create_dir_all(&src_dir).unwrap();
13531347

1354-
// Create .git directory to mark as repo root
1348+
// Create .git directory (should stop search)
13551349
std::fs::create_dir(repo_dir.join(".git")).unwrap();
13561350

1357-
// Create config outside repo (should not be found)
1351+
// Create config outside repo (should NOT be found due to .git boundary)
13581352
let outer_config = temp_dir.path().join("quickmark.toml");
13591353
std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
13601354

@@ -1413,42 +1407,6 @@ mod test {
14131407
}
14141408
}
14151409

1416-
#[test]
1417-
fn test_config_discovery_stops_at_cargo_toml() {
1418-
let temp_dir = TempDir::new().unwrap();
1419-
1420-
// Create nested directories: temp_dir/project/src/
1421-
let project_dir = temp_dir.path().join("project");
1422-
let src_dir = project_dir.join("src");
1423-
std::fs::create_dir_all(&src_dir).unwrap();
1424-
1425-
// Create Cargo.toml to mark as project root
1426-
std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1427-
1428-
// Create config outside project (should not be found)
1429-
let outer_config = temp_dir.path().join("quickmark.toml");
1430-
std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap();
1431-
1432-
// Create file in src/
1433-
let file_path = src_dir.join("test.md");
1434-
std::fs::write(&file_path, "# Test").unwrap();
1435-
1436-
let discovery = ConfigDiscovery::new();
1437-
let result = discovery.find_config(&file_path);
1438-
1439-
match result {
1440-
ConfigSearchResult::NotFound { searched_paths } => {
1441-
// Should have searched in src/ and project/ but not in temp_dir (stopped at Cargo.toml)
1442-
let searched_dirs: Vec<_> =
1443-
searched_paths.iter().filter_map(|p| p.parent()).collect();
1444-
assert!(searched_dirs.contains(&src_dir.as_path()));
1445-
assert!(searched_dirs.contains(&project_dir.as_path()));
1446-
assert!(!searched_dirs.contains(&temp_dir.path()));
1447-
}
1448-
_ => panic!("Expected NotFound result, got: {:?}", result),
1449-
}
1450-
}
1451-
14521410
#[test]
14531411
fn test_config_discovery_error() {
14541412
let temp_dir = TempDir::new().unwrap();

crates/quickmark-core/src/linter.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub struct RuleViolation {
3030
location: Location,
3131
message: String,
3232
rule: &'static Rule,
33+
pub(crate) severity: RuleSeverity,
3334
}
3435

3536
impl RuleViolation {
@@ -38,6 +39,7 @@ impl RuleViolation {
3839
rule,
3940
message,
4041
location: Location { file_path, range },
42+
severity: RuleSeverity::Error, // Default, will be overridden by MultiRuleLinter
4143
}
4244
}
4345

@@ -52,6 +54,10 @@ impl RuleViolation {
5254
pub fn rule(&self) -> &'static Rule {
5355
self.rule
5456
}
57+
58+
pub fn severity(&self) -> &RuleSeverity {
59+
&self.severity
60+
}
5561
}
5662

5763
/// Convert from tree-sitter range to library range
@@ -266,6 +272,7 @@ pub trait RuleLinter {
266272
pub struct MultiRuleLinter {
267273
linters: Vec<Box<dyn RuleLinter>>,
268274
tree: Option<tree_sitter::Tree>,
275+
config: QuickmarkConfig,
269276
}
270277

271278
impl MultiRuleLinter {
@@ -297,6 +304,7 @@ impl MultiRuleLinter {
297304
return Self {
298305
linters: Vec::new(),
299306
tree: None,
307+
config,
300308
};
301309
}
302310

@@ -308,7 +316,12 @@ impl MultiRuleLinter {
308316
let tree = parser.parse(document, None).expect("Parse failed");
309317

310318
// Create context with pre-initialized cache only for active rules
311-
let context = Rc::new(Context::new(file_path, config, document, &tree.root_node()));
319+
let context = Rc::new(Context::new(
320+
file_path,
321+
config.clone(),
322+
document,
323+
&tree.root_node(),
324+
));
312325

313326
// Create rule linters for active rules only
314327
let linters = active_rules
@@ -319,6 +332,7 @@ impl MultiRuleLinter {
319332
Self {
320333
linters,
321334
tree: Some(tree),
335+
config,
322336
}
323337
}
324338

@@ -347,10 +361,21 @@ impl MultiRuleLinter {
347361
}
348362
});
349363

350-
// Collect all violations from finalize
364+
// Collect all violations from finalize and inject severity from config
351365
let mut violations = Vec::new();
352366
for linter in &mut self.linters {
353-
let linter_violations = linter.finalize();
367+
let mut linter_violations = linter.finalize();
368+
// Inject severity into each violation based on current config
369+
for violation in &mut linter_violations {
370+
let severity = self
371+
.config
372+
.linters
373+
.severity
374+
.get(violation.rule().alias)
375+
.cloned()
376+
.unwrap_or(RuleSeverity::Error);
377+
violation.severity = severity;
378+
}
354379
violations.extend(linter_violations);
355380
}
356381

0 commit comments

Comments
 (0)