diff --git a/javascript/packages/config/src/config.ts b/javascript/packages/config/src/config.ts index 820ddf721..23f9ea0f2 100644 --- a/javascript/packages/config/src/config.ts +++ b/javascript/packages/config/src/config.ts @@ -286,12 +286,25 @@ export class Config { * @param excludePatterns - Array of glob patterns to check against * @returns true if the path matches any exclude pattern */ + private normalizeFilePath(filePath: string): string { + if (path.isAbsolute(filePath)) { + const projectDir = this.projectPath + path.sep + + if (filePath.startsWith(projectDir)) { + return filePath.slice(projectDir.length) + } + } + + return filePath + } + private isPathExcluded(filePath: string, excludePatterns?: string[]): boolean { if (!excludePatterns || excludePatterns.length === 0) { return false } - return excludePatterns.some(pattern => picomatch.isMatch(filePath, pattern)) + const normalized = this.normalizeFilePath(filePath) + return excludePatterns.some(pattern => picomatch.isMatch(normalized, pattern)) } /** @@ -305,7 +318,8 @@ export class Config { return true } - return includePatterns.some(pattern => picomatch.isMatch(filePath, pattern)) + const normalized = this.normalizeFilePath(filePath) + return includePatterns.some(pattern => picomatch.isMatch(normalized, pattern)) } /** diff --git a/javascript/packages/config/test/config.test.ts b/javascript/packages/config/test/config.test.ts index a480a9a3e..4a8374aa7 100644 --- a/javascript/packages/config/test/config.test.ts +++ b/javascript/packages/config/test/config.test.ts @@ -862,6 +862,59 @@ describe("@herb-tools/config", () => { expect(config.isRuleEnabledForPath("html-tag-name-lowercase", "app/views/index.html.erb")).toBe(false) }) + test("isRuleEnabledForPath normalizes absolute file paths against projectPath", () => { + const configOptions: HerbConfigOptions = { + linter: { + rules: { + "html-tag-name-lowercase": { + exclude: ["app/views/layouts/jasmine.*"] + } + } + } + } + + const config = Config.fromObject(configOptions, { projectPath: testDir }) + + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", "app/views/layouts/jasmine.html.erb")).toBe(false) + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/views/layouts/jasmine.html.erb`)).toBe(false) + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/views/home/index.html.erb`)).toBe(true) + }) + + test("isRuleEnabledForPath normalizes absolute file paths for include patterns", () => { + const configOptions: HerbConfigOptions = { + linter: { + rules: { + "html-tag-name-lowercase": { + include: ["app/components/**/*"] + } + } + } + } + + const config = Config.fromObject(configOptions, { projectPath: testDir }) + + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", "app/components/button.html.erb")).toBe(true) + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/components/button.html.erb`)).toBe(true) + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/views/home/index.html.erb`)).toBe(false) + }) + + test("isRuleEnabledForPath normalizes absolute file paths for only patterns", () => { + const configOptions: HerbConfigOptions = { + linter: { + rules: { + "html-tag-name-lowercase": { + only: ["app/views/**/*"] + } + } + } + } + + const config = Config.fromObject(configOptions, { projectPath: testDir }) + + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/views/home/index.html.erb`)).toBe(true) + expect(config.isRuleEnabledForPath("html-tag-name-lowercase", `${testDir}/app/components/button.html.erb`)).toBe(false) + }) + test("linter.exclude combines with files.exclude for file discovery", () => { const configOptions: HerbConfigOptions = { files: { diff --git a/javascript/packages/linter/test/rule-level-patterns.test.ts b/javascript/packages/linter/test/rule-level-patterns.test.ts index 7a9b679fe..5e5c16e7c 100644 --- a/javascript/packages/linter/test/rule-level-patterns.test.ts +++ b/javascript/packages/linter/test/rule-level-patterns.test.ts @@ -177,6 +177,26 @@ describe("Rule-level include/only/exclude patterns", () => { }) }) + describe("rule.exclude with absolute file paths", () => { + test("excludes files when fileName is an absolute path", () => { + const config = Config.fromObject({ + linter: { + rules: { + "html-tag-name-lowercase": { + exclude: ["app/views/layouts/jasmine.*"] + } + } + } + }, { projectPath: testDir }) + + const linter = Linter.from(Herb, config) + const source = "Test" + + const result = linter.lint(source, { fileName: "/test/project/app/views/layouts/jasmine.html.erb" }) + expect(result.offenses.some(offense => offense.rule === "html-tag-name-lowercase")).toBe(false) + }) + }) + describe("complex scenarios", () => { test("different rules can have different patterns", () => { const config = Config.fromObject({ diff --git a/rust/herb-config/src/linter_config.rs b/rust/herb-config/src/linter_config.rs index bcd26ddbc..986af649a 100644 --- a/rust/herb-config/src/linter_config.rs +++ b/rust/herb-config/src/linter_config.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use serde::{Deserialize, Serialize}; @@ -11,6 +12,26 @@ pub struct LinterConfig { pub rules: HashMap, } +fn normalize_file_path(file_path: &str, project_path: Option<&str>) -> String { + if let Some(project) = project_path { + let path = Path::new(file_path); + + if path.is_absolute() { + let project_prefix = if project.ends_with(std::path::MAIN_SEPARATOR) { + project.to_string() + } else { + format!("{}{}", project, std::path::MAIN_SEPARATOR) + }; + + if file_path.starts_with(&project_prefix) { + return file_path[project_prefix.len()..].to_string(); + } + } + } + + file_path.to_string() +} + impl LinterConfig { pub fn new() -> Self { Self::default() @@ -35,6 +56,13 @@ impl LinterConfig { } pub fn is_rule_enabled_for_path(&self, rule_name: &str, file_path: &str, default_exclude: &[&str]) -> bool { + self.is_rule_enabled_for_path_with_project(rule_name, file_path, default_exclude, None) + } + + pub fn is_rule_enabled_for_path_with_project(&self, rule_name: &str, file_path: &str, default_exclude: &[&str], project_path: Option<&str>) -> bool { + let normalized = normalize_file_path(file_path, project_path); + let file_path = normalized.as_str(); + if let Some(config) = self.rules.get(rule_name) { if !config.only.is_empty() { if !is_path_matching(file_path, &config.only) { diff --git a/rust/herb-config/tests/linter_config_test.rs b/rust/herb-config/tests/linter_config_test.rs index 12eede0a4..7927d6e75 100644 --- a/rust/herb-config/tests/linter_config_test.rs +++ b/rust/herb-config/tests/linter_config_test.rs @@ -266,6 +266,35 @@ fn is_rule_enabled_for_path_include_overrides_default_exclude() { assert!(!config.is_rule_enabled_for_path("html-tag-name-lowercase", "app/views/index.html.erb", &[])); } +#[test] +fn is_rule_enabled_for_path_normalizes_absolute_paths() { + let config = LinterConfig { + rules: HashMap::from([( + "html-tag-name-lowercase".into(), + RuleConfig { + exclude: vec!["app/views/layouts/jasmine.*".into()], + ..Default::default() + }, + )]), + }; + + assert!(!config.is_rule_enabled_for_path_with_project("html-tag-name-lowercase", "app/views/layouts/jasmine.html.erb", &[], Some("/test/project"),)); + + assert!(!config.is_rule_enabled_for_path_with_project( + "html-tag-name-lowercase", + "/test/project/app/views/layouts/jasmine.html.erb", + &[], + Some("/test/project"), + )); + + assert!(config.is_rule_enabled_for_path_with_project( + "html-tag-name-lowercase", + "/test/project/app/views/home/index.html.erb", + &[], + Some("/test/project"), + )); +} + #[test] fn get_rule_config_returns_none_when_not_configured() { let config = LinterConfig::new();