Compare commits

..
Author SHA1 Message Date
Paul Bakaus ca40a4900f Test gitignore paths independently
Prepared with AI assistance by OpenAI Codex.
2026-09-21 13:49:57 -07:00
Paul Bakaus 107de24e64 Fix nested Impeccable gitignore patterns
Prepared with AI assistance by OpenAI Codex.
2026-09-21 13:42:11 -07:00
6 changed files with 41 additions and 348 deletions
+19 -20
View File
@@ -358,31 +358,30 @@ As you run commands, Impeccable writes working files under `.impeccable/`: criti
```gitignore
# impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# The **/ prefix covers .impeccable at the repo root or in a nested workspace.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, surfaces/*.md, critique/*.md.
.impeccable/config.local.json
.impeccable/hook.cache.json
.impeccable/hook.pending.json
.impeccable/*.png
.impeccable/review/
.impeccable/questions/
.impeccable/live/server.json
.impeccable/live/sessions/
.impeccable/live/previews/
.impeccable/live/annotations/
.impeccable/live/cache/
.impeccable/live/manual-edit-apply-transaction.json
.impeccable/live/manual-edit-events.jsonl
.impeccable/live/manual-edit-evidence/
.impeccable/live/pending-manual-edits.json
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/live/*.png
**/.impeccable/config.local.json
**/.impeccable/hook.cache.json
**/.impeccable/hook.pending.json
**/.impeccable/*.png
**/.impeccable/review/
**/.impeccable/questions/
**/.impeccable/live/server.json
**/.impeccable/live/sessions/
**/.impeccable/live/previews/
**/.impeccable/live/annotations/
**/.impeccable/live/cache/
**/.impeccable/live/manual-edit-apply-transaction.json
**/.impeccable/live/manual-edit-events.jsonl
**/.impeccable/live/manual-edit-evidence/
**/.impeccable/live/pending-manual-edits.json
**/.impeccable/live/deferred-svelte-component-accepts.json
**/.impeccable/live/*.png
# impeccable-ignore-end
```
The block is wrapped in `# impeccable-ignore-start` / `# impeccable-ignore-end` markers so you can recognize and refresh it later. Patterns are unanchored on purpose: in a monorepo the active project (and its `.impeccable/` directory) often lives under a nested workspace path like `apps/web/`, and a root-anchored pattern would miss it.
The block is wrapped in `# impeccable-ignore-start` / `# impeccable-ignore-end` markers so you can recognize and refresh it later. The `**/` prefix makes each pattern match whether the active project's `.impeccable/` directory is at the repository root or under a nested workspace path like `apps/web/`.
**Keep these tracked** (they are shared project artifacts, do not add them to `.gitignore`):
@@ -1,138 +0,0 @@
//! `ai-color-palette` against a project's own documented palette, through a
//! real browser.
//!
//! The FakeDom tests in `impeccable-core` pin the rule's decision; this one
//! pins the thing only a browser can answer: that the `oklch()` a DESIGN.md
//! declares and the `oklch()` Chrome computes for an element are the same
//! color by the time the rule sees them. A `file://` target loads the
//! DESIGN.md that governs the page's directory, so the whole path runs — the
//! allowlist is parsed from the markdown, the page is rendered, and the
//! browser element sweep decides.
//!
//! Skips cleanly without an installed browser or a built binary, the way
//! `differential.rs` does.
//!
//! Env:
//! - `IMPECCABLE_BIN` — the binary (default `target/debug/impeccable`).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("workspace root")
}
/// A DESIGN.md whose palette is all `oklch()`, including a verdigris that
/// sits inside the rule's cyan band and a violet inside its purple one.
const DESIGN_MD: &str = "---\n\
name: Instruments\n\
colors:\n\
\x20 paper: \"oklch(97.8% 0 0)\"\n\
\x20 ink: \"oklch(13% 0 0)\"\n\
\x20 instrument: \"oklch(24% 0 0)\"\n\
\x20 patina: \"oklch(70% 0.12 188)\"\n\
\x20 iris: \"oklch(58% 0.2 300)\"\n\
---\n\n\
The palette above is the whole system.\n";
fn page(swatch_color: &str, gradient: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
<title>Instruments</title></head>\
<body style=\"background: oklch(97.8% 0 0); color: oklch(13% 0 0); font-family: Arial, sans-serif\">\
<div style=\"background: oklch(24% 0 0); padding: 24px\">\
<span style=\"color: {swatch_color}; font-size: 16px\">Live</span>\
</div>\
<section style=\"background-image: {gradient}; height: 200px\"></section>\
</body></html>"
)
}
fn rules(bin: &Path, dir: &Path, file: &str) -> Vec<String> {
let url = format!("file://{}", dir.join(file).display());
let out = Command::new(bin)
.arg("detect")
.arg("--json")
.arg(&url)
.current_dir(dir)
.output()
.expect("run detect");
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let parsed: Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("detect {url} did not print JSON ({e}): {stdout}"));
parsed
.as_array()
.expect("findings array")
.iter()
.filter_map(|f| f.get("antipattern")?.as_str().map(str::to_string))
.collect()
}
#[test]
fn ai_palette_respects_a_documented_oklch_palette() {
let env: HashMap<String, String> = std::env::vars().collect();
if impeccable_browser::discovery::find_browser(&env).is_err() {
eprintln!("skip: no installed browser found");
return;
}
let bin = std::env::var("IMPECCABLE_BIN")
.map(PathBuf::from)
.unwrap_or_else(|_| workspace_root().join("target/debug/impeccable"));
if !bin.exists() {
eprintln!(
"skip: {} missing (cargo build -p impeccable, or set IMPECCABLE_BIN)",
bin.display()
);
return;
}
let dir = std::env::temp_dir().join(format!("impeccable-ds-palette-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
// A project marker, so the DESIGN.md walk-up stops here rather than
// climbing out of the temp directory.
std::fs::write(dir.join("package.json"), "{\"name\":\"ds-palette-fixture\"}\n").unwrap();
std::fs::write(dir.join("DESIGN.md"), DESIGN_MD).unwrap();
std::fs::write(
dir.join("declared.html"),
page(
"oklch(70% 0.12 188)",
"linear-gradient(oklch(58% 0.2 300), oklch(70% 0.12 188))",
),
)
.unwrap();
std::fs::write(
dir.join("undeclared.html"),
page(
"rgb(0, 229, 255)",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
),
)
.unwrap();
let declared = rules(&bin, &dir, "declared.html");
let undeclared = rules(&bin, &dir, "undeclared.html");
let _ = std::fs::remove_dir_all(&dir);
// Every color on this page is a token the DESIGN.md declares, so neither
// the palette rule nor the drift rule has anything to say.
assert!(
!declared.iter().any(|r| r == "ai-color-palette"),
"declared tokens reported as a generic AI palette: {declared:?}"
);
assert!(
!declared.iter().any(|r| r == "design-system-color"),
"declared tokens reported as drift: {declared:?}"
);
// The same shapes in colors the DESIGN.md never declared still fire.
assert!(
undeclared.iter().any(|r| r == "ai-color-palette"),
"undeclared neon and violet gradient went unreported: {undeclared:?}"
);
}
+1 -72
View File
@@ -1369,7 +1369,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
findings.extend(hits(ec::check_element_colors_dom(dom, el)));
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
let palette = ec::check_element_ai_palette_dom(dom, el, design_system.as_ref());
let palette = ec::check_element_ai_palette_dom(dom, el);
// An ignored subtree gets no vote in the page-wide reading. A cyan
// tell inside `data-impeccable-ignore="ai-color-palette"` would
// otherwise open the two-hue gate and charge neon ink somewhere else
@@ -1882,77 +1882,6 @@ mod tests {
assert!(types(&out).contains(&"design-system-font".to_string()));
}
/// End to end through the collector: a page whose colors are all its own
/// documented oklch tokens must not report `ai-color-palette`, while a
/// color the DESIGN.md never declared still reports both rules.
#[test]
fn ai_palette_respects_the_design_system_palette() {
// oklch(24% 0 0) instrument face carrying oklch(70% 0.12 188) verdigris.
let make_dom = |text_color: &str, second_color: &str| {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let panel = d.add(Some(body), "div");
d.set_styles(panel, &[("backgroundColor", "rgb(58, 58, 58)")]);
d.el_mut(panel).check_visibility = Some(true);
let label = d.add(Some(panel), "span");
d.add_text(label, "Live");
d.set_styles(
label,
&[
("color", text_color),
("backgroundColor", "rgba(0, 0, 0, 0)"),
("fontFamily", "Inter, sans-serif"),
],
);
d.el_mut(label).check_visibility = Some(true);
let second = d.add(Some(panel), "span");
d.add_text(second, "Status");
d.set_styles(second, &[("color", second_color), ("fontFamily", "Inter, sans-serif")]);
d.el_mut(second).check_visibility = Some(true);
d
};
let types = |out: &CollectResult| -> Vec<String> {
out.groups
.iter()
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
};
let design_system = json!({
"present": true,
"hasFonts": true, "allowedFonts": ["Inter"],
"hasColors": true,
"allowedColors": [
{ "r": 15, "g": 182, "b": 172 },
{ "r": 168, "g": 85, "b": 247 },
{ "r": 58, "g": 58, "b": 58 }
]
});
let with_ds = BrowserConfig {
design_system: Some(design_system),
..Default::default()
};
let without_ds = BrowserConfig::default();
// No DESIGN.md: two unexplained hues form a palette, not one accent.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)", "rgb(168, 85, 247)"), &without_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()));
// Declared token: neither the palette rule nor the drift rule fires.
let out = collect_browser_findings(&make_dom("rgb(15, 182, 172)", "rgb(168, 85, 247)"), &with_ds);
assert!(!types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(!types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
// A declared purple does not open the two-hue gate for undeclared cyan.
let out = collect_browser_findings(&make_dom("rgb(0, 229, 255)", "rgb(168, 85, 247)"), &with_ds);
assert!(!types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
// Two undeclared hues still report both rules.
let out = collect_browser_findings(&make_dom("rgb(0, 229, 255)", "rgb(220, 0, 255)"), &with_ds);
assert!(types(&out).contains(&"ai-color-palette".to_string()), "{:?}", types(&out));
assert!(types(&out).contains(&"design-system-color".to_string()), "{:?}", types(&out));
}
#[test]
fn disabled_values_parse_and_normalize_like_the_js() {
// JS `.filter(e => e && typeof e === 'object' && e.rule && e.value)`:
+3 -110
View File
@@ -11,7 +11,6 @@ use super::dom::{
class_attr, class_attr_or_prop, closest_or_none, direct_text, has_direct_text_longer_than,
matches_or_false, pf0, safe_id, style_px, tag_lower, Dom, ElId, ElStyle, Rect,
};
use super::driver::{browser_colors_close, DesignSystemConfig};
use super::BrowserFinding;
use crate::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
@@ -717,30 +716,6 @@ pub fn check_element_glow_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
})
}
/// True when the scan was given a DESIGN.md and that file declares this
/// color as one of the project's own.
///
/// `ai-color-palette` is a rule about the *unchosen* palette: the purple and
/// the cyan a model reaches for when nobody picked one. A color the author
/// wrote down in DESIGN.md was picked, so it is not that default whatever
/// its hue, and a site whose whole palette is its own documented tokens must
/// not trip the rule on every element that wears one. With no design system
/// there is nothing to consult and every color stays in scope, which is the
/// behavior every scan without a DESIGN.md keeps.
///
/// The tolerance is `browser_colors_close`, the same one the
/// `design-system-color` rule matches computed colors with, so a token the
/// design-system rule calls declared is declared here too.
fn is_declared_design_color(ds: Option<&DesignSystemConfig>, c: &Rgba) -> bool {
let Some(ds) = ds else { return false };
if !ds.has_colors {
return false;
}
ds.allowed_colors
.iter()
.any(|allowed| browser_colors_close(c, allowed))
}
/// The two hues the AI palette is built out of. A page that uses one of them
/// has an accent; a page that uses both has the palette.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -789,17 +764,10 @@ pub struct AiPaletteReading {
/// `#2fb8a6` on near-black lit 18 places on the bench's base, and every one of
/// them was the same deliberate accent (REN-405). Two different tell hues on
/// one page is the palette the rule is named for.
pub fn check_element_ai_palette_dom(
dom: &dyn Dom,
el: ElId,
design_system: Option<&DesignSystemConfig>,
) -> AiPaletteReading {
pub fn check_element_ai_palette_dom(dom: &dyn Dom, el: ElId) -> AiPaletteReading {
let mut reading = AiPaletteReading::default();
let bg_image = dom.style(el, "backgroundImage");
for c in parse_gradient_colors(Some(&bg_image)) {
if is_declared_design_color(design_system, &c) {
continue;
}
if has_chroma(Some(&c), Some(50.0)) {
if let Some(tell) = TellHue::of(get_hue(Some(&c))) {
reading.tells.push(tell);
@@ -814,8 +782,7 @@ pub fn check_element_ai_palette_dom(
}
}
}
let text_color = parse_rgb_or_any(&dom.style(el, "color"))
.filter(|c| !is_declared_design_color(design_system, c));
let text_color = parse_rgb_or_any(&dom.style(el, "color"));
if let Some(tc) = text_color {
if has_chroma(Some(&tc), Some(80.0)) {
if let Some(tell) = TellHue::of(get_hue(Some(&tc))) {
@@ -1603,81 +1570,7 @@ mod tests {
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
let reading = check_element_ai_palette_dom(&d, hero, None);
assert_eq!(reading.hits.len(), 1);
assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background");
assert!(reading.ink.is_none());
assert_eq!(reading.tells, vec![TellHue::Purple]);
}
/// A DESIGN.md palette built out of the project's own oklch tokens is not
/// the generic assistant default, however cyan or violet the tokens are.
/// The verdigris-on-instrument pair here is the shape that fired 80 times
/// on one site whose whole palette is documented.
fn design_system_with(colors: &[(f64, f64, f64)]) -> DesignSystemConfig {
DesignSystemConfig {
has_colors: true,
allowed_colors: colors
.iter()
.map(|&(r, g, b)| Rgba { r, g, b, a: None })
.collect(),
..DesignSystemConfig::default()
}
}
#[test]
fn ai_palette_skips_colors_the_design_system_declares() {
let (mut d, body) = page();
// oklch(24% 0 0) instrument face, oklch(70% 0.12 188) verdigris text.
let panel = d.add(Some(body), "div");
d.set_style(panel, "backgroundColor", "rgb(58, 58, 58)");
let label = d.add(Some(panel), "span");
d.set_style(label, "color", "rgb(15, 182, 172)");
// With no DESIGN.md the teal contributes ink to the page-wide reading.
let reading = check_element_ai_palette_dom(&d, label, None);
assert!(reading.hits.is_empty());
assert_eq!(reading.ink.unwrap().snippet, "Cyan neon text on dark background");
assert_eq!(reading.tells, vec![TellHue::Cyan]);
// Declared in DESIGN.md, so it is the project's palette, not the default.
let ds = design_system_with(&[(15.0, 182.0, 172.0)]);
let declared = check_element_ai_palette_dom(&d, label, Some(&ds));
assert!(declared.hits.is_empty());
assert!(declared.ink.is_none());
assert!(declared.tells.is_empty());
// A design system that declares some other color leaves the rule alone.
let other = design_system_with(&[(200.0, 40.0, 30.0)]);
assert!(check_element_ai_palette_dom(&d, label, Some(&other)).ink.is_some());
// `hasColors: false` is a DESIGN.md with no palette section: no allowlist
// to consult, so the rule keeps its unconstrained behavior.
let empty = DesignSystemConfig::default();
assert!(check_element_ai_palette_dom(&d, label, Some(&empty)).ink.is_some());
}
#[test]
fn ai_palette_gradient_skips_declared_stops_but_not_undeclared_ones() {
let (mut d, body) = page();
let hero = d.add(Some(body), "section");
d.set_style(
hero,
"backgroundImage",
"linear-gradient(rgb(168, 85, 247), rgb(59, 130, 246))",
);
d.set_style(hero, "color", "rgb(0, 0, 0)");
// The violet stop is a declared token, so this gradient is the project's.
let ds = design_system_with(&[(168.0, 85.0, 247.0), (59.0, 130.0, 246.0)]);
let declared = check_element_ai_palette_dom(&d, hero, Some(&ds));
assert!(declared.hits.is_empty());
assert!(declared.ink.is_none());
assert!(declared.tells.is_empty());
// Declaring only the blue stop leaves the violet one in scope.
let partial = design_system_with(&[(59.0, 130.0, 246.0)]);
let reading = check_element_ai_palette_dom(&d, hero, Some(&partial));
let reading = check_element_ai_palette_dom(&d, hero);
assert_eq!(reading.hits.len(), 1);
assert_eq!(reading.hits[0].snippet, "Purple/violet gradient background");
assert!(reading.ink.is_none());
File diff suppressed because one or more lines are too long
+16 -6
View File
@@ -26,15 +26,25 @@ describe('README gitignore block', () => {
writeFileSync(join(tmp, '.gitignore'), block);
execFileSync('git', ['init'], { cwd: tmp });
const ignored = execFileSync('git', [
'check-ignore',
for (const rel of [
'.impeccable/review/desktop.png',
'.impeccable/questions/fb63f8a6.log',
], { cwd: tmp, encoding: 'utf-8' });
assert.match(ignored, /\.impeccable\/review\/desktop\.png/);
assert.match(ignored, /\.impeccable\/questions\/fb63f8a6\.log/);
'apps/web/.impeccable/review/desktop.png',
'apps/web/.impeccable/questions/fb63f8a6.log',
]) {
const ignored = execFileSync('git', ['check-ignore', rel], {
cwd: tmp,
encoding: 'utf-8',
});
assert.equal(ignored.trim(), rel, `${rel} should be ignored independently`);
}
for (const rel of ['.impeccable/config.json', '.impeccable/critique/report.md']) {
for (const rel of [
'.impeccable/config.json',
'.impeccable/critique/report.md',
'apps/web/.impeccable/config.json',
'apps/web/.impeccable/critique/report.md',
]) {
const result = spawnSync('git', ['check-ignore', rel], { cwd: tmp });
assert.notEqual(result.status, 0, `${rel} should not be ignored`);
}