mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
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 '<path>'`), which is what `Error: cannot scan <target>: <message>` 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
bc45026706
commit
ed50cc5ea0
@@ -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`.
|
||||
|
||||
+133
-24
@@ -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<i32,
|
||||
base,
|
||||
cache: DesignSystemCache::new(),
|
||||
stdin_tty,
|
||||
had_operational_failure: false,
|
||||
};
|
||||
|
||||
let mut all: Vec<Finding> = Vec::new();
|
||||
@@ -530,12 +554,33 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
|
||||
targets.clone()
|
||||
};
|
||||
let url_count = paths.iter().filter(|p| URL_RE.is_match(p)).count();
|
||||
let shared = if url_count > 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<i32,
|
||||
}
|
||||
let (primary, advisory) = partition_advisory(&all);
|
||||
let (primary_len, advisory_len) = (primary.len(), advisory.len());
|
||||
// Exit 1 means at least one requested scan could not complete. It takes
|
||||
// precedence over exit 2 because findings from the remaining targets do
|
||||
// not turn a partial scan into a complete one (#711).
|
||||
let exit_code = if ctx.had_operational_failure {
|
||||
1
|
||||
} else if primary_len > 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<i32,
|
||||
let text = format_findings(&all, false, stderr_tty);
|
||||
ctx.io.err(&format!("{text}\n"));
|
||||
}
|
||||
return Ok(if primary_len > 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<Finding>,
|
||||
) -> 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<String> = 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<String> = 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<String> = 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<String>)> = 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<Value> = 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(())
|
||||
|
||||
@@ -76,6 +76,13 @@ pub trait UrlEngine {
|
||||
pub trait SharedBrowser {
|
||||
fn detect_url(&self, url: &str, options: &ScanOptions) -> Result<Vec<Finding>, 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.
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
}
|
||||
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<String>)> {
|
||||
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<String>)> {
|
||||
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<String> = Vec::new();
|
||||
for pattern in IMPORT_SPECIFIER_PATTERNS.iter() {
|
||||
|
||||
@@ -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}'"),
|
||||
},
|
||||
})
|
||||
|
||||
+19
-3
@@ -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 <target>`), an unreadable directory or file
|
||||
in a dir walk, and a per-file scan that throws
|
||||
(`stderr> Error: cannot scan <target>: <message>`). 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.
|
||||
|
||||
|
||||
@@ -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', `<REPO>/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, '<div style="border-left: 4px solid #ff0000">x</div>\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'), '<div style="border-left: 4px solid #ff0000">x</div>\n');
|
||||
const p = path.join(ws, 'b.html');
|
||||
fs.writeFileSync(p, '<div style="border-left: 4px solid #ff0000">x</div>\n');
|
||||
fs.chmodSync(p, 0o000);
|
||||
},
|
||||
args: ['--no-config', '--json', '.'],
|
||||
},
|
||||
{ id: 'detect-unknown-flag', verb: 'detect', args: ['--bogus', `<REPO>/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false },
|
||||
{ id: 'detect-bad-viewport', verb: 'detect', args: ['--viewport', 'wide', `<REPO>/tests/fixtures/antipatterns/blinking-cursor.html`], isolateHome: false },
|
||||
{ id: 'cli-help', verb: 'cli-help', args: [] },
|
||||
|
||||
@@ -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 <name> Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport <WxH> 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 <!-- impeccable-disable overused-font -- exported brand doc -->\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 <name> Only report rules in the given design domain\n (type, layout). Comma-separated.\n --viewport <WxH> 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 <!-- impeccable-disable overused-font -- exported brand doc -->\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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"stdout": "[]\n",
|
||||
"stderr": "Warning: cannot access does-not-exist.html\n",
|
||||
"exit": 0,
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
|
||||
@@ -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\": \"<REPO>/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\": \"<REPO>/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\": \"<REPO>/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\": \"<REPO>/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": {}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "Warning: cannot access does-not-exist.html\n",
|
||||
"exit": 0,
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
|
||||
@@ -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\": \"<WS>/a.html\",\n \"line\": 0,\n \"snippet\": \"border-left: 4px\"\n }\n]\n",
|
||||
"stderr": "Error: cannot scan <WS>/b.html: EACCES: permission denied, open '<WS>/b.html'\n",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stdout": "[]\n",
|
||||
"stderr": "Error: cannot scan locked.html: EACCES: permission denied, open '<WS>/locked.html'\n",
|
||||
"exit": 1,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
Reference in New Issue
Block a user