diff --git a/crates/detect/src/cli.rs b/crates/detect/src/cli.rs index b4dcb0f03..500bc6941 100644 --- a/crates/detect/src/cli.rs +++ b/crates/detect/src/cli.rs @@ -734,7 +734,7 @@ fn scan_targets( // Unreadable directories and files are reported, not silently // skipped, and each one forces exit 1 (#711). let mut walk_failures: Vec<(String, String)> = Vec::new(); - let files: Vec = walk_dir_reporting(&resolved, &mut |dir, err| { + let files: Vec = walk_dir_reporting(&resolved, &ctx.config.extensions, &mut |dir, err| { walk_failures.push((dir.to_string(), node_scan_error(dir, err))); }) .into_iter() diff --git a/crates/detect/src/config.rs b/crates/detect/src/config.rs index c469541ce..713a88742 100644 --- a/crates/detect/src/config.rs +++ b/crates/detect/src/config.rs @@ -88,6 +88,7 @@ pub struct DetectionConfig { pub ignore_values: Vec, pub design_system_enabled: Option, pub advisory_rules: Option, + pub extensions: Vec, } impl DetectionConfig { @@ -131,6 +132,40 @@ fn apply_detection_config_source(config: &mut DetectionConfig, raw: Option<&Map< if let Some(Value::Array(values)) = raw.get("ignoreValues") { config.ignore_values = merge_ignore_values(&config.ignore_values, values); } + if let Some(Value::Array(list)) = raw.get("extensions") { + config.extensions = unique_strings( + config + .extensions + .iter() + .cloned() + .chain(normalize_detection_extensions(list)) + .collect(), + ); + } +} + +fn normalize_detection_extensions(entries: &[Value]) -> Vec { + let mut out = Vec::new(); + for entry in entries { + let raw = match entry { + Value::String(s) => Some(s.as_str()), + Value::Object(o) => match o.get("ext") { + Some(Value::String(s)) => Some(s.as_str()), + _ => None, + }, + _ => None, + }; + let Some(raw) = raw else { continue }; + let mut ext = js::to_lower_case(js::trim(raw)); + if ext.is_empty() { + continue; + } + if !ext.starts_with('.') { + ext = format!(".{ext}"); + } + out.push(ext); + } + out } fn unique_strings(values: Vec) -> Vec { @@ -1121,4 +1156,25 @@ mod tests { assert_eq!(decode_uri_component("Open%20Sans"), "Open Sans"); assert_eq!(decode_uri_component("bad%zz"), "bad%zz"); } + + #[test] + fn detection_extensions() { + let dir = std::env::temp_dir().join(format!( + "impeccable-detect-ext-{}", + std::process::id() + )); + let impeccable = dir.join(".impeccable"); + std::fs::create_dir_all(&impeccable).unwrap(); + std::fs::write( + impeccable.join("config.json"), + r#"{"detector":{"extensions":[{"ext":".html.erb","engine":"html"},"blade.php"]}}"#, + ) + .unwrap(); + let config = read_detection_config(dir.to_str().unwrap()); + assert_eq!( + config.extensions, + vec![".html.erb".to_string(), ".blade.php".to_string()] + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/detect/src/file_system.rs b/crates/detect/src/file_system.rs index 820b78e6a..97f9bf0e8 100644 --- a/crates/detect/src/file_system.rs +++ b/crates/detect/src/file_system.rs @@ -37,6 +37,11 @@ pub const HTML_EXTENSIONS: &[&str] = &[".html", ".htm"]; /// JS: file-system.mjs#hasScannableExtension pub fn has_scannable_extension(filename: &str) -> bool { + has_scannable_extension_with(filename, &[]) +} + +/// Built-in scannable extensions plus configured suffix matches (#822). +fn has_scannable_extension_with(filename: &str, extra_exts: &[String]) -> bool { let lower = impeccable_core::js::to_lower_case(filename); if SCANNABLE_EXTENSIONS.contains(&jsp::extname(&lower).as_str()) { return true; @@ -46,6 +51,13 @@ pub fn has_scannable_extension(filename: &str) -> bool { return true; } } + let name_len = lower.encode_utf16().count(); + for ext in extra_exts { + let ext_len = ext.encode_utf16().count(); + if name_len > ext_len && lower.ends_with(ext.as_str()) { + return true; + } + } false } @@ -57,13 +69,14 @@ pub fn is_html_path(file_path: &str) -> bool { /// JS: file-system.mjs#walkDir. Files in `readdirSync` order (the OS order, /// which Node does not sort either), recursive; an unreadable dir yields []. pub fn walk_dir(dir: &str) -> Vec { - walk_dir_reporting(dir, &mut |_, _| {}) + walk_dir_reporting(dir, &[], &mut |_, _| {}) } /// JS: file-system.mjs#walkDir(dir, onReadError). An unreadable directory is /// reported and skipped rather than silently yielding nothing (#711). pub fn walk_dir_reporting( dir: &str, + extra_exts: &[String], on_read_error: &mut dyn FnMut(&str, &std::io::Error), ) -> Vec { let mut files = Vec::new(); @@ -95,8 +108,8 @@ pub fn walk_dir_reporting( } let full = jsp::join(&[dir, &name]); if is_dir { - files.extend(walk_dir_reporting(&full, on_read_error)); - } else if has_scannable_extension(&name) { + files.extend(walk_dir_reporting(&full, extra_exts, on_read_error)); + } else if has_scannable_extension_with(&name, extra_exts) { files.push(full); } } @@ -543,6 +556,11 @@ mod tests { assert!(has_scannable_extension("A.HTML")); assert!(!has_scannable_extension("a.php")); assert!(is_html_path("/x/y.HTM")); + let erb = vec![".html.erb".to_string()]; + assert!(!has_scannable_extension("first.html.erb")); + assert!(has_scannable_extension_with("first.html.erb", &erb)); + assert!(has_scannable_extension_with("A.HTML.ERB", &erb)); + assert!(!has_scannable_extension_with("notes.txt", &erb)); } #[test] diff --git a/crates/hook/src/hook_lib.rs b/crates/hook/src/hook_lib.rs index 76628c3e4..3dacc3b4d 100644 --- a/crates/hook/src/hook_lib.rs +++ b/crates/hook/src/hook_lib.rs @@ -991,6 +991,7 @@ pub fn filter_findings(findings: Vec, config: &HookConfig) -> Vec !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order). + Then `files = walkDir(resolved, config.extensions).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. The walk collects built-in scannable extensions plus any `detector.extensions` from cwd config (empty under `--no-config`). If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order). - **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`. - `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}` → `detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`. 4. Post-filter: `filterDetectionFindings(all, config)` (ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop. @@ -253,7 +253,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` ( #### `cli/engine/node/file-system.mjs` - `SKIP_DIRS = {'node_modules','dist','build','__pycache__'}`; any directory whose name starts with `.` is skipped **except** `HIDDEN_SOURCE_DIRS = {'.vitepress','.vuepress','.storybook'}`. The root passed to `walkDir` is never name-checked (an explicit hidden dir scans). -- `SCANNABLE_EXTENSIONS = {'.html','.htm','.css','.scss','.sass','.less','.jsx','.tsx','.js','.ts','.vue','.svelte','.astro','.blade.php'}`; `hasScannableExtension` lowercases and also matches multi-dot exts by `endsWith` (`.blade.php`). +- `SCANNABLE_EXTENSIONS = {'.html','.htm','.css','.scss','.sass','.less','.jsx','.tsx','.js','.ts','.vue','.svelte','.astro','.blade.php'}`; `hasScannableExtension` lowercases and also matches multi-dot exts by `endsWith` (`.blade.php`). CLI directory walks pass configured `detector.extensions` as a second suffix match: `name` lowercased; `name.length > ext.length && name.endsWith(ext)`. - `walkDir` returns files in `readdirSync` order, recursive, unreadable dirs → `[]`. - **There is no generated-file detection in the CLI** (`skill/scripts/lib/is-generated.mjs` is hook-side only and not imported by `cli/`). - Import graph: `IMPORT_SPECIFIER_PATTERNS = [/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g, /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g]`; `resolveImport` only for specifiers matching `/^[./]/`: exact, `base+ext` for each scannable ext, then `base/index+ext`. @@ -281,7 +281,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` ( "designSystem": { "enabled": true }, "advisoryRules": "include"|"exclude" }, "hook": { "consent": "accepted"|"declined", ... }, "updateCheck": true } ``` -- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true}}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`. +- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true},extensions:[]}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`; `extensions` unions normalized `detector.extensions` entries (string or `{ext, engine}` → leading-dot lowercase; engine ignored by detect). Invalid/non-array `extensions` skipped. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`. - `normalizeIgnoreValue(v)`: trim, strip one leading/trailing quote, `+`→space, collapse whitespace, lowercase. Rules lowercased/trimmed. - `normalizeIgnoreValueEntries`: keeps `{rule, value, [files], [createdAt], [reason]}` in **that key order**; `file` (string) and `files` merged, trimmed, deduped. - Glob → regex: `**` → `.*` (swallowing a following `/`), `*` → `[^/]*`, `?` → `[^/]`, `{a,b}` → `(?:a|b)`, regex specials escaped; anchored `^...$`. `matchesAnyGlob` tests the `/`-normalized path and its basename. diff --git a/tests/oracle/cases/detect.mjs b/tests/oracle/cases/detect.mjs index 9147b9784..3ad6c7896 100644 --- a/tests/oracle/cases/detect.mjs +++ b/tests/oracle/cases/detect.mjs @@ -101,6 +101,9 @@ export default function cases() { { id: 'detect-config-css-text', verb: 'detect', workspace: 'detect-config', args: ['src/styles.css'] }, { id: 'detect-config-vendor-ignored', verb: 'detect', workspace: 'detect-config', args: ['--json', 'src/vendor/ignored.html'] }, { id: 'detect-config-from-subdir', verb: 'detect', workspace: 'detect-config', cwd: 'src', args: ['--json', 'page.html'] }, + // detector.extensions in directory walks (#822) + { id: 'detect-config-extensions-dir-json', verb: 'detect', workspace: 'detect-extensions', args: ['--json', '--no-design-system', 'app/views'] }, + { id: 'detect-config-extensions-dir-no-config', verb: 'detect', workspace: 'detect-extensions', args: ['--no-config', '--json', 'app/views'] }, // A file in one project must not pick up another project's DESIGN.md { id: 'detect-config-cross-project', verb: 'detect', workspace: 'detect-config', args: ['--json', `/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false }, ); diff --git a/tests/oracle/golden/detect-config-extensions-dir-json.json b/tests/oracle/golden/detect-config-extensions-dir-json.json new file mode 100644 index 000000000..11ca9ae10 --- /dev/null +++ b/tests/oracle/golden/detect-config-extensions-dir-json.json @@ -0,0 +1,7 @@ +{ + "stdout": "[\n {\n \"antipattern\": \"overused-font\",\n \"name\": \"Overused font\",\n \"description\": \"Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/app/views/first.html.erb\",\n \"line\": 1,\n \"snippet\": \"font-family: Inter\"\n },\n {\n \"antipattern\": \"broken-image\",\n \"name\": \"Broken or placeholder image\",\n \"description\": \" tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/app/views/first.html.erb\",\n \"line\": 1,\n \"snippet\": \"\\\"probe\\\"\"\n },\n {\n \"antipattern\": \"broken-image\",\n \"name\": \"Broken or placeholder image\",\n \"description\": \" tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/app/views/plain.html\",\n \"line\": 0,\n \"snippet\": \" with no src attribute\"\n }\n]\n", + "stderr": "", + "exit": 2, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/detect-config-extensions-dir-no-config.json b/tests/oracle/golden/detect-config-extensions-dir-no-config.json new file mode 100644 index 000000000..37b00586f --- /dev/null +++ b/tests/oracle/golden/detect-config-extensions-dir-no-config.json @@ -0,0 +1,7 @@ +{ + "stdout": "[\n {\n \"antipattern\": \"broken-image\",\n \"name\": \"Broken or placeholder image\",\n \"description\": \" tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"/app/views/plain.html\",\n \"line\": 0,\n \"snippet\": \" with no src attribute\"\n }\n]\n", + "stderr": "", + "exit": 2, + "signal": null, + "files": {} +} diff --git a/tests/oracle/workspaces/detect-extensions/.impeccable/config.json b/tests/oracle/workspaces/detect-extensions/.impeccable/config.json new file mode 100644 index 000000000..4d43c54f6 --- /dev/null +++ b/tests/oracle/workspaces/detect-extensions/.impeccable/config.json @@ -0,0 +1,6 @@ +{ + "detector": { + "extensions": [{ "ext": ".html.erb", "engine": "html" }], + "designSystem": { "enabled": false } + } +} diff --git a/tests/oracle/workspaces/detect-extensions/app/views/first.html.erb b/tests/oracle/workspaces/detect-extensions/app/views/first.html.erb new file mode 100644 index 000000000..96fef0704 --- /dev/null +++ b/tests/oracle/workspaces/detect-extensions/app/views/first.html.erb @@ -0,0 +1 @@ +probe diff --git a/tests/oracle/workspaces/detect-extensions/app/views/plain.html b/tests/oracle/workspaces/detect-extensions/app/views/plain.html new file mode 100644 index 000000000..96fef0704 --- /dev/null +++ b/tests/oracle/workspaces/detect-extensions/app/views/plain.html @@ -0,0 +1 @@ +probe