From ed50cc5ea071d7bcc5b43fbfabc1751ec81d6a6c Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Thu, 3 Sep 2026 12:56:48 -0700 Subject: [PATCH] Port: fail URL scans when the browser is unavailable (#711) Upstream sha f2f9958be1e6a4ecb1fbd5ef1ae1b7d9c53e0d24 (Fix: fail URL scans when the browser is unavailable). `detect` gains an operational-failure flag. Exit 1 now means at least one requested target could not be scanned, and it takes precedence over exit 2, because findings from the targets that did scan do not turn a partial scan into a complete one. The flag is set by an unreachable path, an unreadable directory or file in a dir walk, a per-file scan that throws, a URL scan that throws, and a shared-browser setup failure. - `walk_dir_reporting` and `build_import_graph_reporting` take a read-error callback; the plain wrappers stay for callers that do not report. A file the graph could not read is skipped for the scan too. - `SharedBrowser::ensure_launched` is the eager half of `createBrowserDetector()`: the CLI brings the browser up before the loop so a launch failure prints one `Error:` line and every URL target is skipped, instead of the lazy launch reporting once per URL. - The static engine and the text path spell a permission failure the way Node does (`EACCES: permission denied, open ''`), which is what `Error: cannot scan : ` prints. - Usage text and docs/CLI-CONTRACT.md carry the exit-status block. Verified against origin/main's JS: missing target, missing target alongside a flagging file, unreadable file, unreadable file beside a readable sibling, unreadable directory, unreadable nested directory, a clean scan, and a browser-unavailable scan of one and of two URLs all agree on exit code, stdout and stderr (the browser-not-found wording is the pre-existing puppeteer-vs-discovery difference). Oracle: `detect-missing-file` and `detect-missing-file-json` re-recorded at exit 1, plus new `detect-missing-file-with-findings`, `detect-unreadable-file-json` and `detect-unreadable-file-in-dir`, each cross-checked against origin/main. `detect-help` carries the new block. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- crates/browser/src/lib.rs | 19 +++ crates/detect/src/cli.rs | 157 +++++++++++++++--- crates/detect/src/engines.rs | 7 + crates/detect/src/file_system.rs | 40 ++++- crates/html/src/static_engine.rs | 3 + docs/CLI-CONTRACT.md | 22 ++- tests/oracle/cases/detect.mjs | 26 +++ tests/oracle/golden/detect-help.json | 2 +- .../golden/detect-missing-file-json.json | 2 +- .../detect-missing-file-with-findings.json | 7 + tests/oracle/golden/detect-missing-file.json | 2 +- .../golden/detect-unreadable-file-in-dir.json | 7 + .../golden/detect-unreadable-file-json.json | 7 + 13 files changed, 264 insertions(+), 37 deletions(-) create mode 100644 tests/oracle/golden/detect-missing-file-with-findings.json create mode 100644 tests/oracle/golden/detect-unreadable-file-in-dir.json create mode 100644 tests/oracle/golden/detect-unreadable-file-json.json diff --git a/crates/browser/src/lib.rs b/crates/browser/src/lib.rs index 8bd0d1066..98d44b34b 100644 --- a/crates/browser/src/lib.rs +++ b/crates/browser/src/lib.rs @@ -133,6 +133,25 @@ impl SharedBrowser for SharedBrowserHandle<'_> { b.close(); } } + + fn ensure_launched(&self) -> Result<(), EngineError> { + if let Some(msg) = self.launch_error.borrow().as_ref() { + return Err(EngineError::new(msg.clone())); + } + if self.browser.borrow().is_some() { + return Ok(()); + } + match self.engine.launch() { + Ok(b) => { + *self.browser.borrow_mut() = Some(b); + Ok(()) + } + Err(e) => { + *self.launch_error.borrow_mut() = Some(e.message.clone()); + Err(e) + } + } + } } /// JS detect-url.mjs `credentials` from `splitScanUrl`. diff --git a/crates/detect/src/cli.rs b/crates/detect/src/cli.rs index 97e2ef17f..b4dcb0f03 100644 --- a/crates/detect/src/cli.rs +++ b/crates/detect/src/cli.rs @@ -15,10 +15,11 @@ use crate::design_system::{load_design_system_for_target, DesignSystemCache}; use crate::detect_text::{detect_text, TextOptions}; use crate::engines::{EngineError, Engines, ScanOptions}; use crate::file_system::{ - build_import_graph, detect_framework_config, is_html_path, is_port_listening, walk_dir, + build_import_graph_reporting, detect_framework_config, is_html_path, is_port_listening, + walk_dir_reporting, }; use crate::jsp; -use crate::util::{exists, re, read_text, D}; +use crate::util::{exists, re, D}; pub const USAGE: &str = "Usage: impeccable detect [options] [file-or-dir-or-url...] @@ -47,6 +48,12 @@ Output streams: Human-readable findings go to stderr so stdout stays available for structured output. Use --json for JSON on stdout, or redirect text with 2> findings.txt. +Exit status: + 0 Scan completed with no primary findings (advisories may still be listed) + 1 At least one requested target could not be scanned + 2 Scan completed with primary findings + Operational failure takes precedence when a multi-target scan is partial. + Project config: Respects .impeccable/config.json and .impeccable/config.local.json detector settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, @@ -231,9 +238,18 @@ struct Ctx<'a> { base: ScanOptions, cache: DesignSystemCache, stdin_tty: bool, + /// JS `hadOperationalFailure`: at least one requested target could not be + /// scanned, which forces exit 1 (#711). + had_operational_failure: bool, } impl<'a> Ctx<'a> { + /// JS: main.mjs#reportLocalScanFailure + fn report_local_scan_failure(&mut self, target: &str, message: &str) { + self.had_operational_failure = true; + self.io.err(&format!("Error: cannot scan {target}: {message}\n")); + } + fn scan_options_for(&mut self, local_path: Option<&str>) -> ScanOptions { let (Some(local_path), true) = (local_path, self.design_system_enabled) else { return self.base.clone(); @@ -263,11 +279,18 @@ impl<'a> Ctx<'a> { .html .detect_html(file_path, options, &mut *self.io.stderr); } - let content = read_text(file_path).ok_or_else(|| { - EngineError::new(format!( - "ENOENT: no such file or directory, open '{file_path}'" - )) - })?; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(e) => { + // JS `fs.readFileSync` throws with Node's errno message. + return Err(EngineError::new(match e.kind() { + std::io::ErrorKind::PermissionDenied => { + format!("EACCES: permission denied, open '{file_path}'") + } + _ => format!("ENOENT: no such file or directory, open '{file_path}'"), + })); + } + }; Ok(detect_text( &content, file_path, @@ -518,6 +541,7 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result = Vec::new(); @@ -530,12 +554,33 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result 1 { + let mut shared = if url_count > 1 { engines.url.and_then(|u| u.open_shared()) } else { None }; - let result = scan_targets(&mut ctx, &paths, shared.as_deref(), &mut all); + // JS: `await createBrowserDetector()` throws before the loop; the + // failure is reported once and every URL target is skipped (#711). + let mut browser_setup_failed = false; + if let Some(s) = shared.as_deref() { + if let Err(e) = s.ensure_launched() { + browser_setup_failed = true; + ctx.had_operational_failure = true; + ctx.io.err(&format!("Error: {}\n", e.message)); + } + } + if browser_setup_failed { + if let Some(s) = shared.take() { + s.close(); + } + } + let result = scan_targets( + &mut ctx, + &paths, + shared.as_deref(), + browser_setup_failed, + &mut all, + ); if let Some(s) = shared { s.close(); } @@ -550,6 +595,16 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result 0 { + 2 + } else { + 0 + }; if !all.is_empty() { if json_mode { let text = format_findings(&all, true, stderr_tty); @@ -571,12 +626,31 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result 0 { 2 } else { 0 }); + return Ok(exit_code); } if json_mode { ctx.io.out("[]\n"); } - Ok(0) + Ok(exit_code) +} + +/// The `error.message` Node hands `reportLocalScanFailure` for a failed +/// `readdirSync` / `readFileSync`. +fn node_scan_error(path: &str, err: &std::io::Error) -> String { + let syscall = if std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false) { + "scandir" + } else { + "open" + }; + match err.kind() { + std::io::ErrorKind::NotFound => { + format!("ENOENT: no such file or directory, {syscall} '{path}'") + } + std::io::ErrorKind::PermissionDenied => { + format!("EACCES: permission denied, {syscall} '{path}'") + } + _ => format!("{err}"), + } } /// An engine error outside a per-URL try: the JS lets it propagate to @@ -590,10 +664,14 @@ fn scan_targets( ctx: &mut Ctx, paths: &[String], shared: Option<&dyn crate::engines::SharedBrowser>, + browser_setup_failed: bool, all: &mut Vec, ) -> Result<(), Exit> { for target in paths { if URL_RE.is_match(target) { + if browser_setup_failed { + continue; + } let url_options = if FILE_URL_RE.is_match(target) { let local = file_url_to_local_path(target); ctx.scan_options_for(local.as_deref()) @@ -611,12 +689,16 @@ fn scan_targets( }; match result { Ok(f) => all.extend(f), - Err(e) => ctx.io.err(&format!("Error: {}\n", e.message)), + Err(e) => { + ctx.had_operational_failure = true; + ctx.io.err(&format!("Error: {}\n", e.message)); + } } continue; } let resolved = jsp::resolve(&ctx.cwd, &[target]); let Ok(stat) = std::fs::metadata(&resolved) else { + ctx.had_operational_failure = true; ctx.io.err(&format!("Warning: cannot access {target}\n")); continue; }; @@ -649,10 +731,18 @@ fn scan_targets( } } let cwd = ctx.cwd.clone(); - let files: Vec = walk_dir(&resolved) - .into_iter() - .filter(|f| !should_ignore_detection_file(f, &cwd, &ctx.config)) - .collect(); + // 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| { + walk_failures.push((dir.to_string(), node_scan_error(dir, err))); + }) + .into_iter() + .filter(|f| !should_ignore_detection_file(f, &cwd, &ctx.config)) + .collect(); + for (dir, message) in walk_failures { + ctx.report_local_scan_failure(&dir, &message); + } let html_count = files.iter().filter(|f| is_html_path(f)).count(); if files.len() > 50 && ctx.stdin_tty && !ctx.json_mode && !ctx.quiet_mode { ctx.io.err(&format!( @@ -667,7 +757,15 @@ fn scan_targets( return Err(Exit(0)); } } - let graph = build_import_graph(&files); + let mut unreadable_files: Vec = Vec::new(); + let mut read_failures: Vec<(String, String)> = Vec::new(); + let graph = build_import_graph_reporting(&files, &mut |file, err| { + unreadable_files.push(file.to_string()); + read_failures.push((file.to_string(), node_scan_error(file, err))); + }); + for (file, message) in read_failures { + ctx.report_local_scan_failure(&file, &message); + } let mut imported_by_map: Vec<(String, Vec)> = Vec::new(); for (importer, imports) in &graph { for imported in imports { @@ -681,10 +779,18 @@ fn scan_targets( } } for file in &files { + if unreadable_files.contains(file) { + continue; + } let opts = ctx.scan_options_for(Some(file)); - let mut file_findings = ctx - .detect_local_file(file, &opts) - .map_err(|e| fatal(ctx.io, e))?; + let mut file_findings = match ctx.detect_local_file(file, &opts) { + Ok(f) => f, + Err(e) => { + let message = e.message.clone(); + ctx.report_local_scan_failure(file, &message); + continue; + } + }; if let Some((_, importers)) = imported_by_map.iter().find(|(k, _)| k == file) { if !importers.is_empty() { let names: Vec = importers @@ -705,10 +811,13 @@ fn scan_targets( continue; } let opts = ctx.scan_options_for(Some(&resolved)); - let f = ctx - .detect_local_file(&resolved, &opts) - .map_err(|e| fatal(ctx.io, e))?; - all.extend(f); + match ctx.detect_local_file(&resolved, &opts) { + Ok(f) => all.extend(f), + Err(e) => { + let message = e.message.clone(); + ctx.report_local_scan_failure(target, &message); + } + } } } Ok(()) diff --git a/crates/detect/src/engines.rs b/crates/detect/src/engines.rs index 0e9743410..a68da684d 100644 --- a/crates/detect/src/engines.rs +++ b/crates/detect/src/engines.rs @@ -76,6 +76,13 @@ pub trait UrlEngine { pub trait SharedBrowser { fn detect_url(&self, url: &str, options: &ScanOptions) -> Result, EngineError>; fn close(&self); + /// The eager half of `createBrowserDetector()`: bring the browser up now, + /// so a launch failure is reported once before the loop and every URL + /// target is skipped, exactly as the JS `await createBrowserDetector()` + /// throw does (#711). Engines with nothing to launch keep the default. + fn ensure_launched(&self) -> Result<(), EngineError> { + Ok(()) + } } /// The engines available to one `detect` run. diff --git a/crates/detect/src/file_system.rs b/crates/detect/src/file_system.rs index 6ecf90a41..492770088 100644 --- a/crates/detect/src/file_system.rs +++ b/crates/detect/src/file_system.rs @@ -57,9 +57,22 @@ 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 |_, _| {}) +} + +/// 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, + on_read_error: &mut dyn FnMut(&str, &std::io::Error), +) -> Vec { let mut files = Vec::new(); - let Ok(rd) = std::fs::read_dir(dir) else { - return files; + let rd = match std::fs::read_dir(dir) { + Ok(rd) => rd, + Err(e) => { + on_read_error(dir, &e); + return files; + } }; let mut entries: Vec<(String, bool)> = Vec::new(); for entry in rd.flatten() { @@ -82,7 +95,7 @@ pub fn walk_dir(dir: &str) -> Vec { } let full = jsp::join(&[dir, &name]); if is_dir { - files.extend(walk_dir(&full)); + files.extend(walk_dir_reporting(&full, on_read_error)); } else if has_scannable_extension(&name) { files.push(full); } @@ -134,12 +147,25 @@ pub fn resolve_import(specifier: &str, from_dir: &str, file_set: &[String]) -> O /// JS: file-system.mjs#buildImportGraph. `(file, imports)` pairs in file /// order; each import list is insertion-ordered and deduplicated (JS `Set`). pub fn build_import_graph(files: &[String]) -> Vec<(String, Vec)> { + build_import_graph_reporting(files, &mut |_, _| {}) +} + +/// JS: file-system.mjs#buildImportGraph(files, onReadError). A file that +/// cannot be read is reported and left out of the graph; the caller skips it +/// for the scan too (#711). +pub fn build_import_graph_reporting( + files: &[String], + on_read_error: &mut dyn FnMut(&str, &std::io::Error), +) -> Vec<(String, Vec)> { let mut graph = Vec::new(); for file in files { - // JS readFileSync throws on an unreadable file and the whole scan - // aborts; a file that vanished between walk and read is rare enough - // that treating it as empty is the friendlier port. - let content = read_text(file).unwrap_or_default(); + let content = match std::fs::read_to_string(file) { + Ok(c) => c, + Err(e) => { + on_read_error(file, &e); + continue; + } + }; let dir = jsp::dirname(file); let mut imports: Vec = Vec::new(); for pattern in IMPORT_SPECIFIER_PATTERNS.iter() { diff --git a/crates/html/src/static_engine.rs b/crates/html/src/static_engine.rs index d77d5f658..694993a51 100644 --- a/crates/html/src/static_engine.rs +++ b/crates/html/src/static_engine.rs @@ -88,6 +88,9 @@ impl HtmlEngine for StaticHtmlEngine { std::io::ErrorKind::NotFound => { format!("ENOENT: no such file or directory, open '{path}'") } + std::io::ErrorKind::PermissionDenied => { + format!("EACCES: permission denied, open '{path}'") + } _ => format!("{source}, open '{path}'"), }, }) diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index f5c5761f8..1e8ea429e 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -134,6 +134,12 @@ Output streams: Human-readable findings go to stderr so stdout stays available for structured output. Use --json for JSON on stdout, or redirect text with 2> findings.txt. +Exit status: + 0 Scan completed with no primary findings (advisories may still be listed) + 1 At least one requested target could not be scanned + 2 Scan completed with primary findings + Operational failure takes precedence when a multi-target scan is partial. + Project config: Respects .impeccable/config.json and .impeccable/config.local.json detector settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, @@ -179,15 +185,25 @@ Examples: - **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. -5. Partition `{primary, advisory}` by `f.advisory === true`. +5. Partition `{primary, advisory}` by `f.advisory === true || f.severity === 'advisory'`. + +Any target that cannot be scanned sets `hadOperationalFailure` (#711): a URL +whose browser setup or scan throws, a path `statSync` cannot reach +(`stderr> Warning: cannot access `), an unreadable directory or file +in a dir walk, and a per-file scan that throws +(`stderr> Error: cannot scan : `). A multi-URL scan whose +shared browser fails to launch prints its `Error:` once and skips every URL +target. **Output and exit codes**: - `allFindings.length > 0`: - json: `stdout> JSON.stringify(allFindings, null, 2) + '\n'` (all findings, advisory ones flagged). - quiet: `stderr> ${primary.length} anti-pattern${n===1?'':'s'} found.\n`; if advisory: `stderr> dim(`${adv} advisory note${adv===1?'':'s'} (not counted).`) + '\n'`. - text: `stderr> formatFindings(all,false) + '\n'`. - - `exit(primary.length > 0 ? 2 : 0)`. -- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit 0`. + - `exit(hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0))`. +- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit(hadOperationalFailure ? 1 : 0)`. +- Exit 1 takes precedence over exit 2: findings from the targets that did scan + do not turn a partial scan into a complete one (#711). - Any other exit: `1` for arg errors above; uncaught exceptions propagate to `cli.js` catch (`exit 1`). - `dim(text)` = `process.stderr.isTTY ? '\x1b[2m' + text + '\x1b[0m' : text`. This is the **only** ANSI styling in detect output. diff --git a/tests/oracle/cases/detect.mjs b/tests/oracle/cases/detect.mjs index ed3c75048..9147b9784 100644 --- a/tests/oracle/cases/detect.mjs +++ b/tests/oracle/cases/detect.mjs @@ -56,6 +56,32 @@ export default function cases() { { id: 'detect-no-args', verb: 'detect', args: [] }, { id: 'detect-missing-file', verb: 'detect', args: ['--no-config', 'does-not-exist.html'] }, { id: 'detect-missing-file-json', verb: 'detect', args: ['--no-config', '--json', 'does-not-exist.html'] }, + // #711: a target that cannot be scanned forces exit 1, and that takes + // precedence over findings from the targets that did scan. + { + id: 'detect-missing-file-with-findings', verb: 'detect', + args: ['--no-config', '--json', `/tests/fixtures/antipatterns/layout.html`, 'does-not-exist.html'], + isolateHome: false, + }, + { + id: 'detect-unreadable-file-json', verb: 'detect', + setup: (ws) => { + const p = path.join(ws, 'locked.html'); + fs.writeFileSync(p, '
x
\n'); + fs.chmodSync(p, 0o000); + }, + args: ['--no-config', '--json', 'locked.html'], + }, + { + id: 'detect-unreadable-file-in-dir', verb: 'detect', + setup: (ws) => { + fs.writeFileSync(path.join(ws, 'a.html'), '
x
\n'); + const p = path.join(ws, 'b.html'); + fs.writeFileSync(p, '
x
\n'); + fs.chmodSync(p, 0o000); + }, + args: ['--no-config', '--json', '.'], + }, { id: 'detect-unknown-flag', verb: 'detect', args: ['--bogus', `/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false }, { id: 'detect-bad-viewport', verb: 'detect', args: ['--viewport', 'wide', `/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false }, { id: 'cli-help', verb: 'cli-help', args: [] }, diff --git a/tests/oracle/golden/detect-help.json b/tests/oracle/golden/detect-help.json index 353741b86..00b3c4bdb 100644 --- a/tests/oracle/golden/detect-help.json +++ b/tests/oracle/golden/detect-help.json @@ -1,5 +1,5 @@ { - "stdout": "Usage: impeccable detect [options] [file-or-dir-or-url...]\n\nScan files or URLs for UI anti-patterns and design quality issues.\n\nOptions:\n --json Output results as JSON\n --quiet In text mode, only print the final findings count\n --scope Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport Browser viewport for URL scans (default 1280x800),\n e.g. --viewport 390x844 for a mobile-width pass\n --no-config Do not apply project config, detector ignores, inline\n ignore comments, or DESIGN.md\n --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments\n --no-design-system Do not load local DESIGN.md / .impeccable/design.json context\n --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)\n --help Show this help message\n\nAdvisory findings:\n Some rules are advisory: detected and listed in a separate section, but never\n counted as failures and never changing the exit code. They stay out of the\n failure count so they never block automation. --no-advisory hides them.\n\nOutput streams:\n Human-readable findings go to stderr so stdout stays available for structured\n output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.\n\nProject config:\n Respects .impeccable/config.json and .impeccable/config.local.json detector\n settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,\n and detector.designSystem.enabled.\n\nInline ignores:\n In-file comments waive a finding where it lives and travel with the file:\n \n .brand { font-family: Inter } /* impeccable-disable-line overused-font */\n // impeccable-disable-next-line bounce-easing: intentional bounce\n impeccable-disable applies to the whole file; -line / -next-line are scoped.\n List one or more rule ids (comma-separated), or omit them / use * for all.\n\nDetection modes:\n HTML files Static HTML/CSS analysis (default, catches linked CSS)\n Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)\n URLs Puppeteer full browser rendering (auto-detected;\n http(s):// and file:// URLs; accessible linked CSS included)\n\nExamples:\n impeccable detect src/\n impeccable detect index.html\n impeccable detect https://example.com\n impeccable detect --json .\n impeccable detect --no-config src/\n", + "stdout": "Usage: impeccable detect [options] [file-or-dir-or-url...]\n\nScan files or URLs for UI anti-patterns and design quality issues.\n\nOptions:\n --json Output results as JSON\n --quiet In text mode, only print the final findings count\n --scope Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport Browser viewport for URL scans (default 1280x800),\n e.g. --viewport 390x844 for a mobile-width pass\n --no-config Do not apply project config, detector ignores, inline\n ignore comments, or DESIGN.md\n --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments\n --no-design-system Do not load local DESIGN.md / .impeccable/design.json context\n --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse)\n --help Show this help message\n\nAdvisory findings:\n Some rules are advisory: detected and listed in a separate section, but never\n counted as failures and never changing the exit code. They stay out of the\n failure count so they never block automation. --no-advisory hides them.\n\nOutput streams:\n Human-readable findings go to stderr so stdout stays available for structured\n output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.\n\nExit status:\n 0 Scan completed with no primary findings (advisories may still be listed)\n 1 At least one requested target could not be scanned\n 2 Scan completed with primary findings\n Operational failure takes precedence when a multi-target scan is partial.\n\nProject config:\n Respects .impeccable/config.json and .impeccable/config.local.json detector\n settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,\n and detector.designSystem.enabled.\n\nInline ignores:\n In-file comments waive a finding where it lives and travel with the file:\n \n .brand { font-family: Inter } /* impeccable-disable-line overused-font */\n // impeccable-disable-next-line bounce-easing: intentional bounce\n impeccable-disable applies to the whole file; -line / -next-line are scoped.\n List one or more rule ids (comma-separated), or omit them / use * for all.\n\nDetection modes:\n HTML files Static HTML/CSS analysis (default, catches linked CSS)\n Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)\n URLs Puppeteer full browser rendering (auto-detected;\n http(s):// and file:// URLs; accessible linked CSS included)\n\nExamples:\n impeccable detect src/\n impeccable detect index.html\n impeccable detect https://example.com\n impeccable detect --json .\n impeccable detect --no-config src/\n", "stderr": "", "exit": 0, "signal": null, diff --git a/tests/oracle/golden/detect-missing-file-json.json b/tests/oracle/golden/detect-missing-file-json.json index a5b447fe2..baafa1dcd 100644 --- a/tests/oracle/golden/detect-missing-file-json.json +++ b/tests/oracle/golden/detect-missing-file-json.json @@ -1,7 +1,7 @@ { "stdout": "[]\n", "stderr": "Warning: cannot access does-not-exist.html\n", - "exit": 0, + "exit": 1, "signal": null, "files": {} } diff --git a/tests/oracle/golden/detect-missing-file-with-findings.json b/tests/oracle/golden/detect-missing-file-with-findings.json new file mode 100644 index 000000000..5961c5443 --- /dev/null +++ b/tests/oracle/golden/detect-missing-file-with-findings.json @@ -0,0 +1,7 @@ +{ + "stdout": "[\n {\n \"antipattern\": \"nested-cards\",\n \"name\": \"Nested cards\",\n \"description\": \"Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/layout.html\",\n \"line\": 0,\n \"snippet\": \"Card inside card (div)\"\n },\n {\n \"antipattern\": \"nested-cards\",\n \"name\": \"Nested cards\",\n \"description\": \"Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/layout.html\",\n \"line\": 0,\n \"snippet\": \"Card inside card (div)\"\n },\n {\n \"antipattern\": \"nested-cards\",\n \"name\": \"Nested cards\",\n \"description\": \"Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/layout.html\",\n \"line\": 0,\n \"snippet\": \"Card inside card (div)\"\n },\n {\n \"antipattern\": \"nested-cards\",\n \"name\": \"Nested cards\",\n \"description\": \"Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/tests/fixtures/antipatterns/layout.html\",\n \"line\": 0,\n \"snippet\": \"Card inside card (div)\"\n }\n]\n", + "stderr": "Warning: cannot access does-not-exist.html\n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/detect-missing-file.json b/tests/oracle/golden/detect-missing-file.json index 08a0f559b..2eaaae774 100644 --- a/tests/oracle/golden/detect-missing-file.json +++ b/tests/oracle/golden/detect-missing-file.json @@ -1,7 +1,7 @@ { "stdout": "", "stderr": "Warning: cannot access does-not-exist.html\n", - "exit": 0, + "exit": 1, "signal": null, "files": {} } diff --git a/tests/oracle/golden/detect-unreadable-file-in-dir.json b/tests/oracle/golden/detect-unreadable-file-in-dir.json new file mode 100644 index 000000000..4f3b689d9 --- /dev/null +++ b/tests/oracle/golden/detect-unreadable-file-in-dir.json @@ -0,0 +1,7 @@ +{ + "stdout": "[\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"/a.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px\"\n }\n]\n", + "stderr": "Error: cannot scan /b.html: EACCES: permission denied, open '/b.html'\n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/detect-unreadable-file-json.json b/tests/oracle/golden/detect-unreadable-file-json.json new file mode 100644 index 000000000..4a65db806 --- /dev/null +++ b/tests/oracle/golden/detect-unreadable-file-json.json @@ -0,0 +1,7 @@ +{ + "stdout": "[]\n", + "stderr": "Error: cannot scan locked.html: EACCES: permission denied, open '/locked.html'\n", + "exit": 1, + "signal": null, + "files": {} +}