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();