Compare commits

..
Author SHA1 Message Date
Abdul WahabandCursor 6a11efcc59 Test: read SOCKS handshake frames with read_exact
The mock proxy now reassembles greeting and CONNECT across TCP fragments so the ALL_PROXY regression cannot flake on a short read.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:35:18 +05:00
Abdul WahabandCursor ff65ce1bc6 Test: cover SOCKS5 ALL_PROXY on the shared HTTP agent
The socks-proxy feature existed so ALL_PROXY=socks5:// can connect; the new test actually dials that path.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:27:30 +05:00
Abdul WahabandCursor cb2a9af35b Fix: honor proxy environment variables in native downloads (#823)
The shared ureq agent now reads HTTP_PROXY/HTTPS_PROXY/ALL_PROXY so update and install work behind a corporate proxy. SOCKS is compiled in because ureq prefers ALL_PROXY, which is often socks5.

AI assistance disclosure: this commit was prepared with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 14:18:04 +05:00
15 changed files with 282 additions and 112 deletions
Generated
+40
View File
@@ -71,6 +71,12 @@ version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -1334,6 +1340,17 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1526,6 +1543,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"url",
"webpki-roots 0.26.11",
]
@@ -1656,6 +1674,28 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
+1 -1
View File
@@ -20,7 +20,7 @@ regex = { workspace = true }
once_cell = { workspace = true }
sha2 = "0.10"
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }
ureq = { version = "2", default-features = false, features = ["tls", "json"] }
ureq = { version = "2", default-features = false, features = ["tls", "json", "socks-proxy"] }
rustls-native-certs = "0.8"
webpki-roots = "1"
tiny_http = "0.12"
+1
View File
@@ -485,6 +485,7 @@ mod tests {
use std::time::Duration;
fn round_trip(edit: bool, override_model: Option<&str>, background: Option<&str>) {
let _proxy_lock = crate::http::PROXY_ENV_LOCK.lock().unwrap();
let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
let api_base = format!("http://{}", server.server_addr());
let temp = std::env::temp_dir().join(format!("impeccable-image-{}-{}", std::process::id(), server.server_addr().to_ip().unwrap().port()));
+233 -4
View File
@@ -15,18 +15,30 @@
//! fails to load, verifies against the bundled roots exactly as before.
//! `SSL_CERT_FILE` / `SSL_CERT_DIR` stand in for the OS store, as they do
//! for OpenSSL and curl; the bundled roots stay either way.
//!
//! The shared agent builder also honors `ALL_PROXY`, `HTTPS_PROXY`, and
//! `HTTP_PROXY` (and their lowercase forms) so `update` and `install` work
//! behind a corporate proxy (#823). Live-mode localhost HTTP does not use
//! this builder. The `socks-proxy` feature is enabled because ureq 2.x
//! prefers `ALL_PROXY`, which is often `socks5://`. We opt in on this
//! builder only, not globally via ureq's `proxy-from-env` feature.
use std::sync::Arc;
#[cfg(test)]
pub(crate) static PROXY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
use once_cell::sync::Lazy;
use ureq::rustls::pki_types::CertificateDer;
use ureq::rustls::{self, ClientConfig, RootCertStore};
/// `ureq::AgentBuilder::new()` with the engine's trust store installed.
/// Every HTTPS call site builds its agent from this; the plain-HTTP calls
/// to the live server on localhost do not need it.
/// `ureq::AgentBuilder::new()` with the engine's trust store installed and
/// env proxy vars honored. Every HTTPS call site builds its agent from this;
/// the plain-HTTP calls to the live server on localhost do not use it.
pub fn agent_builder() -> ureq::AgentBuilder {
ureq::AgentBuilder::new().tls_config(tls_config())
ureq::AgentBuilder::new()
.tls_config(tls_config())
.try_proxy_from_env(true)
}
fn tls_config() -> Arc<ClientConfig> {
@@ -99,6 +111,223 @@ tB0WGTOG3QIgdJa8gBPU9Y6WsrursItsnUeGTYHKDCZZ6MjlekLFuoc=
fn agent_builds_from_this_hosts_store() {
// Runs the real rustls-native-certs load: it must not panic, and the
// shared config must be accepted by a ureq agent.
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let _agent = agent_builder().build();
}
struct ProxyEnvGuard {
saved: Vec<(String, Option<String>)>,
}
impl ProxyEnvGuard {
fn set(vars: &[(&str, Option<&str>)]) -> Self {
let saved = vars
.iter()
.map(|(key, _)| (key.to_string(), std::env::var(key).ok()))
.collect();
for (key, value) in vars {
match value {
// SAFETY: PROXY_ENV_LOCK serializes every test that
// reads or writes these process-global proxy vars.
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
Self { saved }
}
}
impl Drop for ProxyEnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
// SAFETY: same lock as set(); restore before unlock.
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
}
}
fn accept_until(
listener: std::net::TcpListener,
mut handle: impl FnMut(&mut std::net::TcpStream) -> bool,
) {
use std::time::Duration;
listener
.set_nonblocking(true)
.expect("nonblocking proxy listener");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while std::time::Instant::now() < deadline {
let Ok((mut stream, _)) = listener.accept() else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(2)));
if handle(&mut stream) {
return;
}
}
}
#[test]
fn agent_honors_http_proxy_from_env() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy listener");
let proxy_addr = listener.local_addr().expect("proxy listener addr");
let request = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let request_for_thread = request.clone();
let handle = std::thread::spawn(move || {
accept_until(listener, |stream| {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf).unwrap_or(0);
let chunk = &buf[..n];
if chunk.is_empty()
|| !String::from_utf8_lossy(chunk).contains("proxy-test.invalid")
{
return false;
}
request_for_thread.lock().unwrap().extend_from_slice(chunk);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
true
});
});
let proxy_url = format!("http://127.0.0.1:{}", proxy_addr.port());
let _env_guard = ProxyEnvGuard::set(&[
("ALL_PROXY", None),
("all_proxy", None),
("HTTPS_PROXY", None),
("https_proxy", None),
("HTTP_PROXY", Some(&proxy_url)),
("http_proxy", Some(&proxy_url)),
]);
let agent = agent_builder()
.timeout(Duration::from_secs(2))
.build();
let response = agent.get("http://proxy-test.invalid/").call();
assert!(response.is_ok(), "expected proxy-routed GET to succeed");
handle.join().expect("proxy thread");
let request_bytes = request.lock().unwrap().clone();
let request_text = String::from_utf8_lossy(&request_bytes);
assert!(
request_text.contains("proxy-test.invalid"),
"proxy should receive request for target host, got: {request_text:?}"
);
}
#[test]
fn agent_honors_socks5_all_proxy_from_env() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
fn socks5_then_http(stream: &mut std::net::TcpStream) -> Option<Vec<u8>> {
fn read_n(stream: &mut std::net::TcpStream, n: usize) -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok()?;
Some(buf)
}
let greet = read_n(stream, 2)?;
if greet[0] != 5 {
return None;
}
let _ = read_n(stream, greet[1] as usize)?;
stream.write_all(&[0x05, 0x00]).ok()?;
let req = read_n(stream, 4)?;
if req[0] != 5 || req[1] != 1 {
return None;
}
match req[3] {
1 => {
let _ = read_n(stream, 6)?;
}
3 => {
let len = read_n(stream, 1)?;
let _ = read_n(stream, len[0] as usize + 2)?;
}
4 => {
let _ = read_n(stream, 18)?;
}
_ => return None,
}
stream
.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.ok()?;
let mut chunk = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = stream.read(&mut buf).ok()?;
if n == 0 {
break;
}
chunk.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&chunk).contains("proxy-test.invalid") {
break;
}
}
if !String::from_utf8_lossy(&chunk).contains("proxy-test.invalid") {
return None;
}
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
Some(chunk)
}
let _lock = PROXY_ENV_LOCK.lock().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind socks listener");
let proxy_addr = listener.local_addr().expect("socks listener addr");
let request = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let request_for_thread = request.clone();
let handle = std::thread::spawn(move || {
accept_until(listener, |stream| {
if let Some(chunk) = socks5_then_http(stream) {
*request_for_thread.lock().unwrap() = chunk;
true
} else {
false
}
});
});
let proxy_url = format!("socks5://127.0.0.1:{}", proxy_addr.port());
let _env_guard = ProxyEnvGuard::set(&[
("ALL_PROXY", Some(&proxy_url)),
("all_proxy", Some(&proxy_url)),
("HTTPS_PROXY", None),
("https_proxy", None),
("HTTP_PROXY", None),
("http_proxy", None),
]);
let agent = agent_builder()
.timeout(Duration::from_secs(2))
.build();
let response = agent.get("http://proxy-test.invalid/").call();
assert!(response.is_ok(), "expected SOCKS5-routed GET to succeed");
handle.join().expect("socks thread");
let request_bytes = request.lock().unwrap().clone();
let request_text = String::from_utf8_lossy(&request_bytes);
assert!(
request_text.contains("proxy-test.invalid"),
"SOCKS proxy should receive request for target host, got: {request_text:?}"
);
}
}
+1 -1
View File
@@ -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<String> = walk_dir_reporting(&resolved, &ctx.config.extensions, &mut |dir, err| {
let files: Vec<String> = walk_dir_reporting(&resolved, &mut |dir, err| {
walk_failures.push((dir.to_string(), node_scan_error(dir, err)));
})
.into_iter()
-56
View File
@@ -88,7 +88,6 @@ pub struct DetectionConfig {
pub ignore_values: Vec<IgnoreValueEntry>,
pub design_system_enabled: Option<bool>,
pub advisory_rules: Option<String>,
pub extensions: Vec<String>,
}
impl DetectionConfig {
@@ -132,40 +131,6 @@ 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<String> {
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<String>) -> Vec<String> {
@@ -1156,25 +1121,4 @@ 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);
}
}
+3 -21
View File
@@ -37,11 +37,6 @@ 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;
@@ -51,13 +46,6 @@ fn has_scannable_extension_with(filename: &str, extra_exts: &[String]) -> 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
}
@@ -69,14 +57,13 @@ 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<String> {
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<String> {
let mut files = Vec::new();
@@ -108,8 +95,8 @@ pub fn walk_dir_reporting(
}
let full = jsp::join(&[dir, &name]);
if is_dir {
files.extend(walk_dir_reporting(&full, extra_exts, on_read_error));
} else if has_scannable_extension_with(&name, extra_exts) {
files.extend(walk_dir_reporting(&full, on_read_error));
} else if has_scannable_extension(&name) {
files.push(full);
}
}
@@ -556,11 +543,6 @@ 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]
-1
View File
@@ -991,7 +991,6 @@ pub fn filter_findings(findings: Vec<Finding>, config: &HookConfig) -> Vec<Findi
ignore_values: config.ignore_values.clone(),
design_system_enabled: None,
advisory_rules: None,
extensions: vec![],
};
filter_detection_findings(kept, &dc)
}
+3 -3
View File
@@ -181,7 +181,7 @@ Examples:
- listening & matched: `\n${name} dev server detected on localhost:${port}.\nFor more accurate results, scan the running site:\n npx impeccable detect http://localhost:${port}\n\n`
- listening & !matched: `\n${name} project detected (${basename(configPath)}).\nPort ${port} is in use by another service. Start the ${name} dev server and scan via URL for best results.\n\n`
- not listening: `\n${name} project detected (${basename(configPath)}).\nStart the dev server and scan via URL for best results:\n npx impeccable detect http://localhost:${port}\n\n`
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).
Then `files = walkDir(resolved).filter(f => !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).
- **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`). CLI directory walks pass configured `detector.extensions` as a second suffix match: `name` lowercased; `name.length > ext.length && name.endsWith(ext)`.
- `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`).
- `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},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`.
- `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`.
- `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.
-3
View File
@@ -101,9 +101,6 @@ 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', `<REPO>/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false },
);
@@ -1,7 +0,0 @@
{
"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\": \"<WS>/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\": \"<img> 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\": \"<WS>/app/views/first.html.erb\",\n \"line\": 1,\n \"snippet\": \"<img alt=\\\"probe\\\">\"\n },\n {\n \"antipattern\": \"broken-image\",\n \"name\": \"Broken or placeholder image\",\n \"description\": \"<img> 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\": \"<WS>/app/views/plain.html\",\n \"line\": 0,\n \"snippet\": \"<img> with no src attribute\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -1,7 +0,0 @@
{
"stdout": "[\n {\n \"antipattern\": \"broken-image\",\n \"name\": \"Broken or placeholder image\",\n \"description\": \"<img> 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\": \"<WS>/app/views/plain.html\",\n \"line\": 0,\n \"snippet\": \"<img> with no src attribute\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -1,6 +0,0 @@
{
"detector": {
"extensions": [{ "ext": ".html.erb", "engine": "html" }],
"designSystem": { "enabled": false }
}
}
@@ -1 +0,0 @@
<!doctype html><html><head><style>body { font-family: Inter; }</style></head><body><img alt="probe"></body></html>
@@ -1 +0,0 @@
<!doctype html><html><head><style>body { font-family: Inter; }</style></head><body><img alt="probe"></body></html>