Rule packs: downstream crates add rules on all three engines; wasm detect surface

A crate that depends on this workspace can now add rules without forking
it. `impeccable_core::rule_pack::RulePack` (object-safe, Send + Sync +
Debug) carries a pack's registry rows plus three hooks that default to
empty: `check_text` for the text engine, `check_element_dom` and
`check_page_dom` for the browser driver. `impeccable_html::StaticRulePack`
adds `check_document` for the static engine, where the document model
belongs to the html crate and detect cannot name it.

The registry keeps ANTIPATTERNS as the built-in list; `registry::extend`
appends a pack's rows and every lookup consults them after the built-ins,
so a pack can never shadow a built-in id (extend panics on a collision and
is idempotent per slice). `all_antipatterns()` is the built-ins followed by
the registered rows.

Hook order, chosen so built-in output cannot move:

- detect_text: after every matcher, analyzer and the dedupe, before inline
  ignores, so `impeccable-disable` waives pack rules like built-in ones.
- detect_html_source: after the element rules, the design-system merge and
  the page passes, again before inline ignores. One pack pass per HTML
  file: the document hook when set, otherwise the text hook over the raw
  source, so a pack implementing both never reports twice.
- collect_browser_findings: the element hook at the end of the per-element
  loop through the same disabled-rules filter and group, the page hook
  after every built-in page pass with the same el-or-body attribution.

A pack travels on TextOptions / ScanOptions, DetectHtmlOptions
(static_rule_pack plus rule_pack), StaticHtmlEngine, and BrowserConfig
(serde-skipped: a pack is a Rust value, not JSON from the page). The
shipped binary installs none.

`crates/wasm --features detect` exposes the two file engines as JSON
exports for hosts that cannot exec the binary: `detect_text_json` and
`detect_html_source_json`, options `{ inlineIgnores?, designSystem? }`,
returning the findings array `detect --json` prints. `antipatterns_json`
now includes a pack's rows. `set_rule_pack` and `set_static_rule_pack` are
Rust-only, for a crate that links this one as an rlib.

Tests: registry extension and collision in foundation, one test pack per
engine (crates/core, crates/detect, crates/html tests) proving each hook
fires, that the built-in findings are unchanged, and that the waivers and
the disabled-rules list cover pack rules, plus the wasm export shapes.
Workspace tests 346 to 361, oracle 795/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-03 09:37:37 -07:00
co-authored by Claude Fable 5.1
parent e7e46104dc
commit 1d0493af30
27 changed files with 1294 additions and 25 deletions
+4
View File
@@ -342,6 +342,10 @@ The rule logic lives in `crates/core`: every check, the browser rule adapters ov
Order for a new rule: fixture here first, registry row in `crates/foundation/src/registry.rs`, the check in `crates/core` against that fixture, oracle case + golden, `cargo xtask bundle` to refresh the tracked live asset, then `bun run build && bun run test` with a binary present. Rule counts quoted in `README.md` / `README.npm.md` are validated by `generateCounts` against the vendored registry.
### Rule packs (downstream crates adding rules)
A crate that depends on this workspace can add rules without forking it: implement `impeccable_core::rule_pack::RulePack` (text plus the two browser DOM hooks) and, for the static engine, `impeccable_html::StaticRulePack`, call `impeccable_core::rule_pack::install(&PACK)` at startup, and hand the pack to the engine through `TextOptions` / `ScanOptions`, `DetectHtmlOptions`, `StaticHtmlEngine`, or `BrowserConfig`. Every hook runs after the built-ins and before inline ignores, so built-in output with no pack installed is byte-identical, which the oracle enforces. The registry keeps `ANTIPATTERNS` as the built-in list and `registry::extend` appends a pack's rows, panicking on an id collision. `crates/wasm --features detect` exposes the two file engines as JSON exports (`detect_text_json`, `detect_html_source_json`) for hosts that cannot exec the binary; Pristine consumes that path. Full contract in `docs/ENGINE.md` ("Rule packs"). The shipped `impeccable` binary installs no pack, and nothing in this repo should start doing so.
## Evals Framework (separate private repo)
The eval framework lives in a separate private repo at `~/code/impeccable-evals/`. It measures whether the `/impeccable` skill improves or harms AI-generated frontend design by running the same brief through a model with and without the skill loaded.
Generated
+2
View File
@@ -701,6 +701,8 @@ name = "impeccable-wasm"
version = "0.1.0"
dependencies = [
"impeccable-core",
"impeccable-detect",
"impeccable-html",
"impeccable-wasm",
"serde_json",
"wasm-bindgen",
+4 -2
View File
@@ -436,8 +436,10 @@ fn scan_page_inner(
})
.map_err(cdp_err)?;
let config =
snapshot_engine::browser_config(serialize_design_system_for_browser(options.design_system.as_deref()));
let config = snapshot_engine::browser_config(
serialize_design_system_for_browser(options.design_system.as_deref()),
options.rule_pack,
);
// Deterministic pass: capture the page and run the rule core natively over
// the snapshot (hit-test misses answered to a fixpoint). serialize_findings
+8 -2
View File
@@ -180,8 +180,13 @@ pub fn resolve_needs<T>(
}
/// The design-system config the browser rules read, built the way
/// `configure-pure-detect` fed `window.__IMPECCABLE_CONFIG__` to the bundle.
pub fn browser_config(design_system: Value) -> BrowserConfig {
/// `configure-pure-detect` fed `window.__IMPECCABLE_CONFIG__` to the bundle,
/// plus the rule pack the caller installed (`None` in the `impeccable`
/// binary).
pub fn browser_config(
design_system: Value,
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
) -> BrowserConfig {
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
@@ -192,6 +197,7 @@ pub fn browser_config(design_system: Value) -> BrowserConfig {
Some(design_system)
},
line_length_max: None,
rule_pack,
}
}
+4 -1
View File
@@ -114,7 +114,10 @@ pub const CLI_VERSION: &str = "3.6.0";
/// (crates/html). The browser engine (crates/browser) plugs in here once it
/// lands; until then URL scans report the puppeteer message.
fn engines() -> impeccable_detect::Engines<'static> {
static HTML: impeccable_html::StaticHtmlEngine = impeccable_html::StaticHtmlEngine;
static HTML: impeccable_html::StaticHtmlEngine = impeccable_html::StaticHtmlEngine {
// The shipped binary carries the built-in rules only.
static_rule_pack: None,
};
impeccable_detect::Engines {
html: &HTML,
url: Some(url_engine()),
+11
View File
@@ -984,6 +984,11 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
design_system.as_ref(),
&mut design_seen,
));
// Rule-pack element rules run last, so the built-in findings for this
// element keep their order and their position in the group.
if let Some(pack) = config.rule_pack {
findings.extend(pack.check_element_dom(dom, el));
}
let findings: Vec<BrowserFinding> =
findings.into_iter().filter(|f| rule_ok(&f.type_)).collect();
add_browser_findings(dom, &mut groups, el, findings);
@@ -1043,6 +1048,12 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
page_pass(&mut groups, &mut page_level, hits(pc::check_cream_palette(dom)));
page_pass(&mut groups, &mut page_level, scoped_html_pattern_findings(dom));
// Rule-pack page rules run after every built-in page pass, through the
// same attribution as the built-in checks that name their own element.
if let Some(pack) = config.rule_pack {
el_pass(&mut groups, pack.check_page_dom(dom));
}
CollectResult { groups, page_level }
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub mod checks;
pub use impeccable_foundation::{
color, constants, fdlibm_trig, findings, fonts, inline_ignores, js, js_ext_a, js_ext_b, page,
registry,
registry, rule_pack,
};
#[cfg(any(test, feature = "vectors"))]
+207
View File
@@ -0,0 +1,207 @@
//! The browser half of the rule-pack extension point: a test pack's element
//! and page hooks fire in the driver's loop, its findings group and filter
//! like built-in ones, and a run with no pack installed is byte-identical to
//! what the built-ins produce on their own.
use impeccable_core::browser::fake_dom::FakeDom;
use impeccable_core::browser::{driver, BrowserConfig, BrowserFinding, ElFinding, ElId};
use impeccable_core::registry::Antipattern;
use impeccable_core::rule_pack::RulePack;
const MARKER: &str = "TODO(pack)";
static ROWS: &[Antipattern] = &[
Antipattern {
id: "testpack/todo-marker",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Unfinished copy marker",
description: "Text still carries a TODO marker from drafting.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "testpack/page-todo-marker",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Page ships an unfinished copy marker",
description: "Somewhere on the page, text still carries a TODO marker.",
skill_section: None,
skill_guideline: None,
},
];
#[derive(Debug)]
struct TestPack;
static PACK: TestPack = TestPack;
impl RulePack for TestPack {
fn registry(&self) -> &'static [Antipattern] {
ROWS
}
fn check_element_dom(
&self,
dom: &dyn impeccable_core::browser::Dom,
el: ElId,
) -> Vec<BrowserFinding> {
let text = dom.direct_text_nodes(el).concat();
if text.contains(MARKER) {
vec![BrowserFinding::new(
"testpack/todo-marker",
format!("<{}> {}", dom.tag_name(el).to_lowercase(), text.trim()),
)]
} else {
Vec::new()
}
}
fn check_page_dom(&self, dom: &dyn impeccable_core::browser::Dom) -> Vec<ElFinding> {
let hit = dom
.query_all(None, "*")
.unwrap_or_default()
.into_iter()
.find(|el| dom.direct_text_nodes(*el).concat().contains(MARKER));
match hit {
Some(el) => vec![ElFinding {
el: Some(el),
finding: BrowserFinding::new(
"testpack/page-todo-marker",
"1 unfinished copy marker on the page",
),
}],
None => Vec::new(),
}
}
}
fn page() -> (FakeDom, ElId) {
let mut dom = FakeDom::new();
let (_html, body) = dom.with_page();
let p = dom.add(Some(body), "p");
dom.add_text(p, "TODO(pack) write the real headline");
let ok = dom.add(Some(body), "p");
dom.add_text(ok, "Shipping copy.");
(dom, p)
}
fn findings_of(result: &driver::CollectResult, el: ElId) -> Vec<String> {
result
.groups
.iter()
.filter(|g| g.el == el)
.flat_map(|g| g.findings.iter().map(|f| f.type_.clone()))
.collect()
}
#[test]
fn element_and_page_hooks_fire_and_no_pack_is_unchanged() {
let (dom, p) = page();
let built_in = driver::collect_browser_findings(&dom, &BrowserConfig::default());
assert!(
!findings_of(&built_in, p)
.iter()
.any(|id| id.starts_with("testpack/")),
"a pack that is not installed must not be consulted"
);
impeccable_core::rule_pack::install(&PACK);
let with_pack = driver::collect_browser_findings(
&dom,
&BrowserConfig {
rule_pack: Some(&PACK),
..Default::default()
},
);
// The element hook fired, on the element that carries the marker only.
let on_p = findings_of(&with_pack, p);
assert!(
on_p.contains(&"testpack/todo-marker".to_string()),
"{on_p:?}"
);
let marker_hits: Vec<&str> = with_pack
.groups
.iter()
.flat_map(|g| g.findings.iter())
.filter(|f| f.type_ == "testpack/todo-marker")
.map(|f| f.detail.as_str())
.collect();
assert_eq!(marker_hits, vec!["<p> TODO(pack) write the real headline"]);
// The page hook fired, attributed to the element it named.
assert!(findings_of(&with_pack, p).contains(&"testpack/page-todo-marker".to_string()));
// Built-in findings are untouched: same elements in the same order with
// the same rules, the pack's rows appended. A group the pack is alone in
// is new, which is why empty remainders drop out of the comparison.
let strip = |r: &driver::CollectResult| -> Vec<(ElId, Vec<String>)> {
r.groups
.iter()
.map(|g| {
let ids: Vec<String> = g
.findings
.iter()
.map(|f| f.type_.clone())
.filter(|id| !id.starts_with("testpack/"))
.collect();
(g.el, ids)
})
.filter(|(_, ids)| !ids.is_empty())
.collect()
};
assert_eq!(strip(&built_in), strip(&with_pack));
assert_eq!(built_in.page_level, with_pack.page_level);
// Pack rows resolve in the registry, so a serialized finding carries the
// pack's name and description.
let row = impeccable_core::registry::get_antipattern("testpack/todo-marker").unwrap();
assert_eq!(row.name, "Unfinished copy marker");
}
#[test]
fn pack_findings_honor_the_disabled_rules_list() {
let (dom, p) = page();
impeccable_core::rule_pack::install(&PACK);
let result = driver::collect_browser_findings(
&dom,
&BrowserConfig {
rule_pack: Some(&PACK),
extension_mode: true,
disabled_rules: vec!["testpack/todo-marker".to_string()],
..Default::default()
},
);
let on_p = findings_of(&result, p);
assert!(
!on_p.contains(&"testpack/todo-marker".to_string()),
"{on_p:?}"
);
// The page rule is a different id and still reports.
assert!(
on_p.contains(&"testpack/page-todo-marker".to_string()),
"{on_p:?}"
);
}
#[test]
fn skip_scan_skips_the_pack_too() {
let (dom, _p) = page();
impeccable_core::rule_pack::install(&PACK);
let result = driver::collect_browser_findings(
&dom,
&BrowserConfig {
rule_pack: Some(&PACK),
extension_mode: true,
skip_scan: true,
..Default::default()
},
);
assert!(result.groups.is_empty() && result.page_level.is_empty());
}
+5
View File
@@ -244,6 +244,7 @@ impl<'a> Ctx<'a> {
profile: options.profile.as_deref(),
design_system: options.design_system.as_deref(),
inline_ignores: options.inline_ignores,
rule_pack: options.rule_pack,
},
))
}
@@ -271,6 +272,7 @@ impl<'a> Ctx<'a> {
profile: opts.profile.as_deref(),
design_system: opts.design_system.as_deref(),
inline_ignores: opts.inline_ignores,
rule_pack: opts.rule_pack,
},
))
}
@@ -449,6 +451,9 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
design_system: None,
viewport,
profile: None,
// The `impeccable` binary installs no rule pack; a library caller that
// does sets this before handing the options to an engine.
rule_pack: None,
};
let targets: Vec<String> = args
.iter()
+11
View File
@@ -10,6 +10,7 @@ use impeccable_core::findings::{finding, Finding};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::js::{self, ci, number_to_string, string_to_number};
use impeccable_core::page::is_full_page;
use impeccable_core::rule_pack::RulePack;
use crate::design_system::{check_source_design_system, DesignSystem};
use crate::profiler::{profile_findings, profile_step, DetectorProfile, ProfileMeta};
@@ -26,6 +27,8 @@ pub struct TextOptions<'a> {
pub design_system: Option<&'a DesignSystem>,
/// JS `options.inlineIgnores === false` disables the waivers.
pub inline_ignores: bool,
/// An installed rule pack's text hook; `None` runs the built-ins only.
pub rule_pack: Option<&'static dyn RulePack>,
}
const PAGE_ANALYZER_EXTS: &[&str] = &[".html", ".htm", ".astro", ".vue", ".svelte"];
@@ -1552,6 +1555,14 @@ pub fn detect_text(content: &str, file_path: &str, options: &TextOptions) -> Vec
}
}
// A rule pack sees the file after every built-in matcher, analyzer, and
// the dedupe, and before inline ignores: its rows are waivable with
// `impeccable-disable` exactly like built-in rules, and appending keeps
// built-in output byte-identical when no pack is installed.
if let Some(pack) = options.rule_pack {
deduped.extend(pack.check_text(content, file_path, &ext));
}
if options.inline_ignores {
apply_inline_ignores(deduped, Some(content))
} else {
+5
View File
@@ -6,6 +6,7 @@
use std::rc::Rc;
use impeccable_core::findings::Finding;
use impeccable_core::rule_pack::RulePack;
use crate::design_system::DesignSystem;
use crate::profiler::DetectorProfile;
@@ -22,6 +23,10 @@ pub struct ScanOptions {
pub viewport: Option<(u32, u32)>,
/// JS `options.profile` (library callers only; no CLI flag).
pub profile: Option<Rc<DetectorProfile>>,
/// The installed rule pack (`impeccable_core::rule_pack`), passed through
/// to the text engine and on to the HTML engine. `None` in the `impeccable`
/// binary, which ships the built-in rules only.
pub rule_pack: Option<&'static dyn RulePack>,
}
/// An error an engine raises; `detectCli` reports it the way the JS surfaces
+127
View File
@@ -0,0 +1,127 @@
//! The text engine's half of the rule-pack extension point: the pack's
//! `check_text` hook runs on every scanned source file after the built-in
//! matchers, its findings are waivable with `impeccable-disable`, and a run
//! with no pack is identical to the built-in output.
use impeccable_core::findings::{finding_for, Finding};
use impeccable_core::registry::Antipattern;
use impeccable_core::rule_pack::RulePack;
use impeccable_detect::detect_text::{detect_text, TextOptions};
const MARKER: &str = "TODO(pack)";
static ROWS: &[Antipattern] = &[Antipattern {
id: "testpack/todo-marker",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Unfinished copy marker",
description: "Text still carries a TODO marker from drafting.",
skill_section: None,
skill_guideline: None,
}];
#[derive(Debug)]
struct TestPack;
static PACK: TestPack = TestPack;
impl RulePack for TestPack {
fn registry(&self) -> &'static [Antipattern] {
ROWS
}
fn check_text(&self, content: &str, file_path: &str, ext: &str) -> Vec<Finding> {
// `ext` proves the hook is told what kind of file this is.
assert!(ext.is_empty() || ext.starts_with('.'), "ext = {ext:?}");
content
.split('\n')
.enumerate()
.filter(|(_, line)| line.contains(MARKER))
.map(|(i, line)| finding_for(&ROWS[0], file_path, line.trim(), (i + 1) as f64))
.collect()
}
}
/// A file with one built-in finding (the CSS-in-JS side stripe on line 2) and
/// one line for the pack.
const SOURCE: &str = "const Card = styled.div`\n border-left: 4px solid red;\n`;\nexport const copy = \"TODO(pack) write the real headline\";\n";
fn scan(rule_pack: Option<&'static dyn RulePack>) -> Vec<Finding> {
detect_text(
SOURCE,
"/app/Card.tsx",
&TextOptions {
inline_ignores: true,
rule_pack,
..Default::default()
},
)
}
#[test]
fn text_hook_fires_and_no_pack_is_unchanged() {
let built_in = scan(None);
assert!(
!built_in.is_empty(),
"the fixture must trip a built-in rule"
);
assert!(built_in
.iter()
.all(|f| !f.antipattern.starts_with("testpack/")));
impeccable_core::rule_pack::install(&PACK);
let with_pack = scan(Some(&PACK));
// Built-in findings come first, unchanged, in the same order.
assert_eq!(&with_pack[..built_in.len()], &built_in[..]);
let extra = &with_pack[built_in.len()..];
assert_eq!(extra.len(), 1);
assert_eq!(extra[0].antipattern, "testpack/todo-marker");
assert_eq!(extra[0].name, "Unfinished copy marker");
assert_eq!(extra[0].severity, "warning");
assert_eq!(extra[0].category.as_deref(), Some("quality"));
assert_eq!(extra[0].file, "/app/Card.tsx");
assert_eq!(extra[0].line, 4.0);
assert_eq!(
extra[0].snippet,
"export const copy = \"TODO(pack) write the real headline\";"
);
// The pack's rows serialize like built-in rows.
let json = serde_json::to_string(&extra[0]).unwrap();
assert!(
json.starts_with("{\"antipattern\":\"testpack/todo-marker\""),
"{json}"
);
}
#[test]
fn pack_findings_are_waivable_inline() {
impeccable_core::rule_pack::install(&PACK);
let source = format!(
"// impeccable-disable-next-line testpack/todo-marker\nconst copy = \"{MARKER} later\";\n"
);
let waived = detect_text(
&source,
"/app/copy.ts",
&TextOptions {
inline_ignores: true,
rule_pack: Some(&PACK),
..Default::default()
},
);
assert!(waived.is_empty(), "{waived:?}");
let unwaived = detect_text(
&source,
"/app/copy.ts",
&TextOptions {
inline_ignores: false,
rule_pack: Some(&PACK),
..Default::default()
},
);
assert_eq!(unwaived.len(), 1);
assert_eq!(unwaived[0].antipattern, "testpack/todo-marker");
}
+6
View File
@@ -99,6 +99,12 @@ pub struct BrowserConfig {
/// applies `|| 80`).
#[serde(default)]
pub line_length_max: Option<serde_json::Value>,
/// The installed rule pack, when the host linked one in
/// ([`crate::rule_pack`]). Not part of the JSON config: a pack is a Rust
/// value, so it is skipped in both directions and a config parsed from
/// the page carries `None`.
#[serde(skip)]
pub rule_pack: Option<&'static dyn crate::rule_pack::RulePack>,
}
impl BrowserConfig {
+4 -2
View File
@@ -2,8 +2,9 @@
//! the JS runtime semantics the port depends on (number and string
//! formatting, trig, regex fragments), colour maths, the rule registry,
//! inline-ignore handling, page and font helpers, the DOM probe trait with
//! its snapshot implementation and selector engine, and the plain-data types
//! every rule check takes in and hands back.
//! its snapshot implementation and selector engine, the plain-data types
//! every rule check takes in and hands back, and the rule-pack extension
//! point downstream crates add rules through.
//!
//! What is NOT here is the rule logic: the `check_*` and `scan_*` functions
//! and their heuristics live in `impeccable-core`. This crate has no
@@ -22,6 +23,7 @@ pub mod js_ext_a;
pub mod js_ext_b;
pub mod page;
pub mod registry;
pub mod rule_pack;
pub mod rules;
#[cfg(any(test, feature = "vectors"))]
+199 -8
View File
@@ -1,5 +1,13 @@
//! Port of `cli/engine/registry/antipatterns.mjs`: the rule registry, in
//! source order, with every field the JS objects carry.
//! source order, with every field the JS objects carry, plus the extension
//! point rule packs register their own rows through ([`extend`]).
//!
//! [`ANTIPATTERNS`] stays the built-in list, byte-for-byte what the JS
//! shipped. Rows a pack registers live in a separate list that every lookup
//! consults after the built-ins, so an engine with no pack installed behaves
//! exactly as before and a pack can never shadow a built-in id.
use std::sync::{OnceLock, RwLock};
/// One `ANTIPATTERNS` entry. Optional fields are `None` where the JS object
/// has no such key; `advisory` is `true` only where the JS has
@@ -706,9 +714,89 @@ pub const RULE_ENGINE_SUPPORT: &[(&str, &[&str])] = &[
("visual", &["visual-contrast"]),
];
/// JS `getAntipattern(id)`.
/// Rows registered by rule packs, in registration order. One entry per
/// `extend` call; `&'static` all the way down, so a lookup can hand out
/// `&'static Antipattern` without holding the lock.
static EXTRA_ROWS: OnceLock<RwLock<Vec<&'static [Antipattern]>>> = OnceLock::new();
fn extra_rows() -> &'static RwLock<Vec<&'static [Antipattern]>> {
EXTRA_ROWS.get_or_init(|| RwLock::new(Vec::new()))
}
/// A snapshot of the registered slices. Cheap when nothing is registered (an
/// empty `Vec` does not allocate), which is every built-in build.
fn extra_slices() -> Vec<&'static [Antipattern]> {
match extra_rows().read() {
Ok(rows) => rows.clone(),
// The list is append-only `&'static` rows, so a lock poisoned by a
// panic elsewhere is still sound to read; ignoring the poison keeps a
// rejected `extend` from making every later lookup miss the rows that
// did register.
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// Register a rule pack's rows. Every registry lookup then resolves them
/// after the built-ins. Calling it again with the same slice is a no-op, so a
/// pack that installs itself from more than one entry point is safe.
///
/// Callers register at startup, before any scan. There is no way to
/// unregister: a rule pack is a property of the process, not of a run.
///
/// # Panics
/// When a row's id collides with a built-in id or with a row another pack
/// already registered. A duplicate id would make `get_antipattern` answer
/// with whichever row came first, which is not a behavior worth guessing at.
pub fn extend(rows: &'static [Antipattern]) {
let mut registered = match extra_rows().write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if registered.iter().any(|slice| std::ptr::eq(*slice, rows)) {
return;
}
// Collect the complaint first and panic after the guard is dropped: a
// panic while holding the write guard would poison the lock, and a
// rejected registration should leave the registry exactly as it was.
let mut rejected: Option<String> = None;
for row in rows {
if let Some(existing) = ANTIPATTERNS.iter().find(|built_in| built_in.id == row.id) {
rejected = Some(format!(
"registry::extend: rule id {:?} collides with the built-in rule {:?}; \
namespace pack ids (e.g. \"mypack/{}\")",
row.id, existing.name, row.id
));
break;
}
if registered
.iter()
.any(|slice| slice.iter().any(|other| other.id == row.id))
{
rejected = Some(format!(
"registry::extend: rule id {:?} is already registered by another rule pack",
row.id
));
break;
}
}
if let Some(message) = rejected {
drop(registered);
panic!("{message}");
}
registered.push(rows);
}
/// Every rule the process knows: the built-ins in registry order, then each
/// pack's rows in registration order.
pub fn all_antipatterns() -> impl Iterator<Item = &'static Antipattern> {
ANTIPATTERNS
.iter()
.chain(extra_slices().into_iter().flat_map(|slice| slice.iter()))
}
/// JS `getAntipattern(id)`, extended with the rule packs' rows.
pub fn get_antipattern(id: &str) -> Option<&'static Antipattern> {
ANTIPATTERNS.iter().find(|rule| rule.id == id)
all_antipatterns().find(|rule| rule.id == id)
}
/// JS `getAP(id)` from `findings.mjs` (an alias of `getAntipattern`).
@@ -718,8 +806,7 @@ pub fn get_ap(id: &str) -> Option<&'static Antipattern> {
/// JS `ADVISORY_RULE_IDS`: ids of rules with `advisory: true`, in registry order.
pub fn advisory_rule_ids() -> impl Iterator<Item = &'static str> {
ANTIPATTERNS
.iter()
all_antipatterns()
.filter(|rule| rule.advisory)
.map(|rule| rule.id)
}
@@ -731,8 +818,7 @@ pub fn is_advisory_rule(id: &str) -> bool {
/// JS `getRulesForCategory(category)`.
pub fn get_rules_for_category(category: &str) -> Vec<&'static Antipattern> {
ANTIPATTERNS
.iter()
all_antipatterns()
.filter(|rule| rule.category == category)
.collect()
}
@@ -749,7 +835,7 @@ pub fn get_rule_engine_support(engine: &str) -> &'static [&'static str] {
/// JS `RULE_SCOPES`: every scope tag declared by any rule, first-seen order.
pub fn rule_scopes() -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
for rule in ANTIPATTERNS {
for rule in all_antipatterns() {
for scope in rule.scopes.unwrap_or(&[]) {
if !out.contains(scope) {
out.push(scope);
@@ -803,4 +889,109 @@ mod tests {
let ids: std::collections::HashSet<&str> = ANTIPATTERNS.iter().map(|r| r.id).collect();
assert_eq!(ids.len(), ANTIPATTERNS.len());
}
/// Rows a test pack registers: no scopes and not advisory, so the
/// built-in assertions in `registry_shape` hold whatever order the test
/// threads run in.
static PACK_ROWS: &[Antipattern] = &[
Antipattern {
id: "testpack/one",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Test pack rule one",
description: "First row of the registry-extension test pack.",
skill_section: None,
skill_guideline: None,
},
Antipattern {
id: "testpack/two",
category: "testpack-only",
scopes: None,
severity: Some("error"),
advisory: false,
name: "Test pack rule two",
description: "Second row of the registry-extension test pack.",
skill_section: None,
skill_guideline: None,
},
];
static COLLIDING_ROWS: &[Antipattern] = &[Antipattern {
id: "side-tab",
category: "quality",
scopes: None,
severity: None,
advisory: false,
name: "Collides with a built-in",
description: "Registering this must panic.",
skill_section: None,
skill_guideline: None,
}];
#[test]
fn extension_is_visible_to_every_lookup() {
assert!(get_antipattern("testpack/one").is_none());
extend(PACK_ROWS);
// Idempotent per slice.
extend(PACK_ROWS);
extend(PACK_ROWS);
let one = get_antipattern("testpack/one").expect("pack row resolves");
assert_eq!(one.name, "Test pack rule one");
assert_eq!(
get_ap("testpack/two").map(|r| r.severity),
Some(Some("error"))
);
assert!(get_antipattern("testpack/nope").is_none());
// Built-ins still come first and are untouched.
let all: Vec<&str> = all_antipatterns().map(|r| r.id).collect();
assert_eq!(all.len(), ANTIPATTERNS.len() + PACK_ROWS.len());
assert_eq!(all[0], "side-tab");
assert_eq!(
&all[ANTIPATTERNS.len()..],
&["testpack/one", "testpack/two"]
);
// Category and advisory views see the rows.
let quality: Vec<&str> = get_rules_for_category("quality")
.iter()
.map(|r| r.id)
.collect();
assert!(quality.contains(&"testpack/one"));
assert_eq!(
get_rules_for_category("testpack-only")
.iter()
.map(|r| r.id)
.collect::<Vec<_>>(),
vec!["testpack/two"]
);
assert!(!is_advisory_rule("testpack/one"));
// A finding built from a pack row carries the pack's metadata.
let f = crate::findings::finding("testpack/two", "a.tsx", "snip", 3.0);
assert_eq!(f.name, "Test pack rule two");
assert_eq!(f.severity, "error");
assert_eq!(f.category.as_deref(), Some("testpack-only"));
}
#[test]
fn built_in_id_collision_panics() {
let err = std::panic::catch_unwind(|| extend(COLLIDING_ROWS)).unwrap_err();
let message = err
.downcast_ref::<String>()
.map(String::as_str)
.unwrap_or("");
assert!(
message.contains("collides with the built-in rule"),
"{message}"
);
assert!(message.contains("side-tab"), "{message}");
assert!(get_antipattern("side-tab")
.unwrap()
.name
.starts_with("Side-tab"));
}
}
+75
View File
@@ -0,0 +1,75 @@
//! Rule packs: how a crate that depends on the engine adds rules of its own
//! without forking it.
//!
//! A pack is one process-lifetime value (`&'static dyn RulePack`) that carries
//! its own registry rows and implements the hooks it has rules for. The
//! built-in rules are never a pack: they are compiled in and always run. A
//! pack runs after them, on every engine that got a reference to it, and its
//! findings pass through the same waivers and filters as built-in findings.
//!
//! Three steps for a downstream crate:
//!
//! 1. Declare the registry rows as a `static [Antipattern]` and hand them
//! back from [`RulePack::registry`]. Ids should be namespaced
//! (`myproject/my-rule`) so they cannot collide with built-in ids.
//! 2. Call [`install`] once at startup, before any scan. That is what makes
//! `get_antipattern` (and therefore every finding's name, description,
//! category, and severity) resolve the pack's ids.
//! 3. Pass the pack into the engine being run: `TextOptions.rule_pack` /
//! `ScanOptions.rule_pack` (text engine), `DetectHtmlOptions`
//! (`rule_pack` plus `impeccable_html::StaticRulePack`), or
//! `BrowserConfig.rule_pack` (the in-page / snapshot driver).
//!
//! The trait is object-safe and every hook has a default that answers empty,
//! so a pack implements only the engines it has rules for.
use crate::browser::{BrowserFinding, Dom, ElFinding, ElId};
use crate::findings::Finding;
use crate::registry::Antipattern;
/// A set of extra rules, plus the hooks they run on.
///
/// `Send + Sync` because a pack is shared across whatever threads the host
/// runs scans on; `Debug` because the option types that carry a pack
/// reference (notably `BrowserConfig`) derive `Debug`. `#[derive(Debug)]` on a
/// unit struct is enough.
pub trait RulePack: Send + Sync + std::fmt::Debug {
/// The pack's registry rows. [`install`] hands these to
/// [`crate::registry::extend`].
fn registry(&self) -> &'static [Antipattern];
/// Text/source engine, once per file, after the built-in matchers and
/// page analyzers and before inline ignores. `ext` is the lowercased
/// extension with its dot (`".tsx"`), empty for a file without one.
fn check_text(&self, content: &str, file_path: &str, ext: &str) -> Vec<Finding> {
let _ = (content, file_path, ext);
Vec::new()
}
/// Browser rules over the DOM probe, once per element in the driver's
/// element loop (same skipped elements as the built-ins), after the
/// built-in element rules. Findings run through the same disabled-rule
/// filter and group under the same element.
fn check_element_dom(&self, dom: &dyn Dom, el: ElId) -> Vec<BrowserFinding> {
let _ = (dom, el);
Vec::new()
}
/// Browser page-level rules, after the built-in page passes. A finding
/// with `el: None` is attributed to `document.body`, like the built-in
/// page checks that name their own target.
fn check_page_dom(&self, dom: &dyn Dom) -> Vec<ElFinding> {
let _ = dom;
Vec::new()
}
}
/// Register a pack's rows in the registry. Idempotent for the same pack, and
/// safe to call before or after the engines are wired.
///
/// # Panics
/// When a row's id collides with a built-in id or with an already registered
/// pack's id (see [`crate::registry::extend`]).
pub fn install(pack: &'static dyn RulePack) {
crate::registry::extend(pack.registry());
}
+2
View File
@@ -1636,6 +1636,7 @@ impl HookScanOptions {
design_system: self.design_system.clone(),
viewport: None,
profile: None,
rule_pack: None,
}
}
}
@@ -1661,6 +1662,7 @@ pub fn detector_detect_text(
profile: None,
design_system: scan.design_system.as_deref(),
inline_ignores: true,
rule_pack: None,
};
detect_text(content, file_path, &opts)
}
+46
View File
@@ -50,6 +50,21 @@ pub trait DesignSystemHook {
/// engine: em-dash overuse, marketing buzzwords, aphoristic cadence.
pub type TextContentAnalyzers<'a> = &'a dyn Fn(&str, &str) -> Vec<Finding>;
/// The static engine's half of a rule pack (`impeccable_core::rule_pack`):
/// rules written against the parsed page. The [`StaticDocument`] model is
/// this crate's, so this hook cannot live on the engine-wide `RulePack`
/// trait; a pack implements both and hands the same value to both fields of
/// [`DetectHtmlOptions`].
///
/// Findings come back as full [`Finding`] values, built with
/// `impeccable_core::findings::finding_for(row, file_path, snippet, line)` so
/// they carry the pack's own registry metadata.
pub trait StaticRulePack: Send + Sync + std::fmt::Debug {
/// Runs once per HTML file, after every built-in pass and before inline
/// ignores.
fn check_document(&self, doc: &StaticDocument, file_path: &str) -> Vec<Finding>;
}
/// The `options` object `detectHtml` reads.
#[derive(Default, Clone, Copy)]
pub struct DetectHtmlOptions<'a> {
@@ -65,6 +80,14 @@ pub struct DetectHtmlOptions<'a> {
/// Sink for the JS `process.stderr.write` notices (unreadable linked
/// stylesheets); `None` drops them.
pub warn: Option<&'a dyn Fn(&str)>,
/// A rule pack's static-document hook: rules over the parsed page.
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
/// pass: `static_rule_pack` when it is set, otherwise this one over the
/// raw HTML source (which is how a text-only pack still covers `.html`
/// files, the way the built-in text-content analyzers do). A pack that
/// implements both therefore never reports twice for the same file.
pub rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
}
/// Errors of the static engine.
@@ -331,6 +354,29 @@ pub fn detect_html_source(
}
}
// A rule pack sees the page after every built-in pass (element rules, the
// design-system merge, the page-level checks) and before inline ignores:
// appending keeps built-in output byte-identical when no pack is
// installed, and pack findings are waivable like built-in ones.
if let Some(pack) = options.static_rule_pack {
let pack_findings = profile::findings(
profile,
Meta::new("page", "rule-pack", fp),
|f: &Finding| f.antipattern.as_str(),
|| pack.check_document(&doc, fp),
);
findings.extend(pack_findings);
} else if let Some(pack) = options.rule_pack {
let ext = impeccable_detect::detect_text::ext_from_file_path(fp);
let pack_findings = profile::findings(
profile,
Meta::new("source", "rule-pack", fp),
|f: &Finding| f.antipattern.as_str(),
|| pack.check_text(html, fp, &ext),
);
findings.extend(pack_findings);
}
if options.inline_ignores_disabled {
findings
} else {
+1 -1
View File
@@ -17,6 +17,6 @@ pub mod static_engine;
pub use engine::{
detect_html, detect_html_source, DesignSystemHook, DetectHtmlOptions, HtmlEngineError,
TextContentAnalyzers,
StaticRulePack, TextContentAnalyzers,
};
pub use static_engine::{DetectDesignSystemHook, DetectorProfileSink, StaticHtmlEngine};
+10 -1
View File
@@ -36,8 +36,15 @@ use crate::profile::{ProfileEvent, ProfileSink};
use crate::quality::pf0;
/// The static HTML engine as `impeccable detect` sees it.
///
/// `static_rule_pack` is the rule pack's static-document hook, set by
/// whichever binary builds `Engines`; the `impeccable` binary leaves it
/// `None`. The pack's engine-wide hooks travel on `ScanOptions` instead,
/// because `detect` owns those options and cannot name this crate's trait.
#[derive(Debug, Default, Clone, Copy)]
pub struct StaticHtmlEngine;
pub struct StaticHtmlEngine {
pub static_rule_pack: Option<&'static dyn crate::engine::StaticRulePack>,
}
impl HtmlEngine for StaticHtmlEngine {
fn detect_html(
@@ -71,6 +78,8 @@ impl HtmlEngine for StaticHtmlEngine {
text_content_analyzers: Some(&analyzers),
profile: profile_sink.as_ref().map(|s| s as &dyn ProfileSink),
warn: Some(&warn),
static_rule_pack: self.static_rule_pack,
rule_pack: options.rule_pack,
};
detect_html(Path::new(path), &html_options).map_err(|e| {
EngineError::new(match e {
+143
View File
@@ -0,0 +1,143 @@
//! The static HTML engine's half of the rule-pack extension point: the
//! document hook runs on the parsed page after every built-in pass, the
//! engine-wide text hook covers HTML files when no document hook is set, and
//! a run with no pack is identical to the built-in output.
use impeccable_core::findings::{finding_for, Finding};
use impeccable_core::registry::Antipattern;
use impeccable_core::rule_pack::RulePack;
use impeccable_html::dom::StaticDocument;
use impeccable_html::{detect_html_source, DetectHtmlOptions, StaticRulePack};
use std::path::Path;
const MARKER: &str = "TODO(pack)";
static ROWS: &[Antipattern] = &[Antipattern {
id: "testpack/todo-marker",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Unfinished copy marker",
description: "Text still carries a TODO marker from drafting.",
skill_section: None,
skill_guideline: None,
}];
#[derive(Debug)]
struct TestPack;
static PACK: TestPack = TestPack;
impl RulePack for TestPack {
fn registry(&self) -> &'static [Antipattern] {
ROWS
}
fn check_text(&self, content: &str, file_path: &str, _ext: &str) -> Vec<Finding> {
content
.split('\n')
.enumerate()
.filter(|(_, line)| line.contains(MARKER))
.map(|(i, line)| finding_for(&ROWS[0], file_path, line.trim(), (i + 1) as f64))
.collect()
}
}
impl StaticRulePack for TestPack {
fn check_document(&self, doc: &StaticDocument, file_path: &str) -> Vec<Finding> {
doc.query_selector_all("*")
.into_iter()
.filter(|el| el.direct_text().contains(MARKER))
.map(|el| {
finding_for(
&ROWS[0],
file_path,
&format!("<{}> {}", el.tag_lower(), el.direct_text().trim()),
0.0,
)
})
.collect()
}
}
/// A full page with one built-in finding (the side-tab stripe) plus a line
/// the pack is about.
const PAGE: &str = r#"<!DOCTYPE html>
<html><head><title>t</title><style>
.card { border-left: 4px solid #6366f1; background: #fff; }
</style></head>
<body>
<div class="card">A card</div>
<p>TODO(pack) write the real headline</p>
</body></html>
"#;
fn scan(options: &DetectHtmlOptions<'_>) -> Vec<Finding> {
detect_html_source(PAGE, Path::new("/app/index.html"), options)
}
#[test]
fn document_hook_fires_and_no_pack_is_unchanged() {
let built_in = scan(&DetectHtmlOptions::default());
assert!(
!built_in.is_empty(),
"the fixture must trip a built-in rule"
);
impeccable_core::rule_pack::install(&PACK);
let with_pack = scan(&DetectHtmlOptions {
static_rule_pack: Some(&PACK),
rule_pack: Some(&PACK),
..Default::default()
});
// Built-in findings come first, unchanged, in the same order.
assert_eq!(&with_pack[..built_in.len()], &built_in[..]);
// Exactly one pack finding: the document hook wins over the text hook, so
// a pack that implements both does not report the same file twice.
let extra = &with_pack[built_in.len()..];
assert_eq!(extra.len(), 1);
assert_eq!(extra[0].antipattern, "testpack/todo-marker");
assert_eq!(extra[0].snippet, "<p> TODO(pack) write the real headline");
assert_eq!(extra[0].line, 0.0);
}
#[test]
fn text_hook_covers_html_when_no_document_hook_is_set() {
impeccable_core::rule_pack::install(&PACK);
let with_text_only = scan(&DetectHtmlOptions {
rule_pack: Some(&PACK),
..Default::default()
});
let pack_findings: Vec<&Finding> = with_text_only
.iter()
.filter(|f| f.antipattern.starts_with("testpack/"))
.collect();
assert_eq!(pack_findings.len(), 1);
// The text hook reports the source line, not the element.
assert_eq!(pack_findings[0].line, 7.0);
assert_eq!(
pack_findings[0].snippet,
"<p>TODO(pack) write the real headline</p>"
);
}
#[test]
fn pack_findings_are_waivable_inline() {
impeccable_core::rule_pack::install(&PACK);
let page = PAGE.replace(
"<body>",
"<body>\n<!-- impeccable-disable testpack/todo-marker -->",
);
let findings = detect_html_source(
&page,
Path::new("/app/index.html"),
&DetectHtmlOptions {
static_rule_pack: Some(&PACK),
..Default::default()
},
);
assert!(findings
.iter()
.all(|f| !f.antipattern.starts_with("testpack/")));
}
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -16,14 +16,22 @@ default = []
# `cargo xtask bundle --pure` builds a bundle that carries them; native
# `cargo test -p impeccable-wasm` covers them either way.
pure-exports = ["impeccable-core/vectors"]
# The file-scanning engines as JSON-in / JSON-out exports (exports_detect.rs):
# `detect_text_json` and `detect_html_source_json`, for hosts that cannot exec
# the binary (Cloudflare Workers and other wasm sandboxes). Off in the in-page
# bundle, which scans the live DOM instead and would only pay the parser's
# weight for nothing.
detect = ["dep:impeccable-detect", "dep:impeccable-html"]
[dependencies]
impeccable-core = { workspace = true }
impeccable-detect = { workspace = true, optional = true }
impeccable-html = { workspace = true, optional = true }
wasm-bindgen = "0.2"
serde_json = { workspace = true }
[dev-dependencies]
impeccable-wasm = { path = ".", features = ["pure-exports"] }
impeccable-wasm = { path = ".", features = ["pure-exports", "detect"] }
# wasm-pack runs wasm-opt on the release build. `--all-features` is required:
# the bundled wasm-opt predates the wasm features rustc emits by default
+148
View File
@@ -0,0 +1,148 @@
//! The `detect` feature: the two file-scanning engines as JSON-in / JSON-out
//! wasm exports, for hosts that cannot exec the `impeccable` binary
//! (Cloudflare Workers, other wasm sandboxes).
//!
//! Both take the same options object, all keys optional:
//!
//! ```json
//! {
//! "inlineIgnores": true,
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } }
//! }
//! ```
//!
//! - `inlineIgnores` (default `true`): apply the `impeccable-disable` waivers
//! found in the source, exactly as the CLI does. `false` reports waived
//! findings too.
//! - `designSystem`: the DESIGN.md inputs, not a pre-normalized object (the
//! JS API's `options.designSystem` carried `Set`s and `Map`s, which JSON
//! cannot). `frontmatter` is the parsed DESIGN.md frontmatter, `sidecar`
//! the parsed `design.json`; the export normalizes them the way
//! `loadDesignSystemForCwd` does. Omit it and no design-system rules run.
//!
//! Both return the findings array the CLI's `--json` prints, in the same
//! order and with the same keys:
//!
//! ```json
//! [{ "antipattern": "side-tab", "name": "...", "description": "...",
//! "severity": "warning", "category": "slop", "file": "src/Card.tsx",
//! "line": 12, "snippet": "border-left: 4px solid #6366f1" }]
//! ```
//!
//! `advisory: true` appears on advisory rules only. Bad options JSON is not
//! an error: it falls back to the defaults, like the CLI ignoring an
//! unreadable config.
//!
//! A rule pack ([`impeccable_core::rule_pack`]) reaches these exports through
//! [`crate::set_rule_pack`] and [`set_static_rule_pack`], which a downstream
//! wasm crate calls from Rust before its own exports run. There is no
//! JS-facing setter: a pack is compiled in, not passed at the boundary.
use std::path::Path;
use std::sync::OnceLock;
use impeccable_detect::design_system::{normalize_design_system, DesignSystem};
use impeccable_detect::detect_text::{detect_text, TextOptions};
use impeccable_html::{detect_html_source, DesignSystemHook, DetectHtmlOptions, StaticRulePack};
use serde_json::Value;
use wasm_bindgen::prelude::*;
static STATIC_PACK: OnceLock<&'static dyn StaticRulePack> = OnceLock::new();
/// Install the static-document half of a rule pack for
/// [`detect_html_source_json`]. Rust-only, called once at startup;
/// [`crate::set_rule_pack`] installs the engine-wide half (and the pack's
/// registry rows).
pub fn set_static_rule_pack(pack: &'static dyn StaticRulePack) {
let _ = STATIC_PACK.set(pack);
}
/// The installed static-document hook, if any.
pub fn installed_static_rule_pack() -> Option<&'static dyn StaticRulePack> {
STATIC_PACK.get().copied()
}
struct Options {
inline_ignores: bool,
design_system: Option<DesignSystem>,
}
fn parse_options(options_json: &str) -> Options {
let parsed: Value = serde_json::from_str(options_json).unwrap_or(Value::Null);
let inline_ignores = parsed
.get("inlineIgnores")
.and_then(Value::as_bool)
.unwrap_or(true);
let design_system = parsed.get("designSystem").and_then(|ds| {
let frontmatter = ds.get("frontmatter").and_then(Value::as_object);
let sidecar = ds.get("sidecar");
if frontmatter.is_none() && sidecar.is_none() {
return None;
}
Some(normalize_design_system(
frontmatter,
sidecar,
ds.get("sourcePath").and_then(Value::as_str),
ds.get("sidecarPath").and_then(Value::as_str),
false,
))
});
Options {
inline_ignores,
design_system,
}
}
fn findings_json(findings: &[impeccable_core::findings::Finding]) -> String {
serde_json::to_string(findings).unwrap_or_else(|_| "[]".into())
}
/// The text/source engine (`impeccable detect` on anything but HTML): CSS,
/// JSX, TSX, Vue, Svelte, Astro, and plain source. `file_path` names the file
/// in the findings and picks the matchers by extension.
#[wasm_bindgen]
pub fn detect_text_json(content: &str, file_path: &str, options_json: &str) -> String {
let options = parse_options(options_json);
let findings = detect_text(
content,
file_path,
&TextOptions {
profile: None,
design_system: options.design_system.as_ref(),
inline_ignores: options.inline_ignores,
rule_pack: crate::installed_rule_pack(),
},
);
findings_json(&findings)
}
/// The static HTML engine over HTML already in memory: the parsed page, the
/// CSS cascade, the element and page rules, and the text-content analyzers.
/// `file_path` names the file in the findings; linked stylesheets are
/// resolved relative to it, which in wasm means only inline `<style>` blocks
/// contribute (there is no filesystem).
#[wasm_bindgen]
pub fn detect_html_source_json(html: &str, file_path: &str, options_json: &str) -> String {
let options = parse_options(options_json);
let analyzers = |content: &str, path: &str| {
impeccable_detect::detect_text::run_text_content_analyzers(content, path, None)
};
let hook = options
.design_system
.as_ref()
.map(|ds| impeccable_html::DetectDesignSystemHook { design_system: ds });
let findings = detect_html_source(
html,
Path::new(file_path),
&DetectHtmlOptions {
inline_ignores_disabled: !options.inline_ignores,
design_system: hook.as_ref().map(|h| h as &dyn DesignSystemHook),
text_content_analyzers: Some(&analyzers),
profile: None,
warn: None,
static_rule_pack: installed_static_rule_pack(),
rule_pack: crate::installed_rule_pack(),
},
);
findings_json(&findings)
}
+29 -4
View File
@@ -3,23 +3,48 @@
//! over the pure `impeccable_core` functions (JSON in / JSON out).
pub mod dom_source;
#[cfg(feature = "detect")]
pub mod exports_detect;
pub mod exports_driver;
#[cfg(feature = "pure-exports")]
pub mod exports_pure;
pub mod exports_visual;
pub mod js_dom;
use dom_source::with_dom;
use impeccable_core::browser::driver;
use impeccable_core::browser::BrowserConfig;
use dom_source::with_dom;
use impeccable_core::rule_pack::RulePack;
use std::sync::OnceLock;
use wasm_bindgen::prelude::*;
static RULE_PACK: OnceLock<&'static dyn RulePack> = OnceLock::new();
/// Install a rule pack: registers its rows in the registry and hands its
/// hooks to every export below. Rust-only, on purpose — a pack is a compiled
/// dependency, not something JS passes in — so the caller is a downstream
/// crate that links this one as an rlib and calls this before its own exports
/// run. Later calls are ignored, the first pack wins.
///
/// The static HTML engine's half of a pack goes through
/// [`exports_detect::set_static_rule_pack`] (feature `detect`).
pub fn set_rule_pack(pack: &'static dyn RulePack) {
impeccable_core::rule_pack::install(pack);
let _ = RULE_PACK.set(pack);
}
/// The installed pack, if any.
pub fn installed_rule_pack() -> Option<&'static dyn RulePack> {
RULE_PACK.get().copied()
}
/// `collectBrowserFindings()`: `config_json` is `{ extensionMode,
/// disabledRules, designSystem, lineLengthMax }`; returns
/// `{ groups: [{ el, findings }], pageLevel: [...] }`.
#[wasm_bindgen]
pub fn collect_browser_findings(config_json: &str) -> String {
let config: BrowserConfig = serde_json::from_str(config_json).unwrap_or_default();
let mut config: BrowserConfig = serde_json::from_str(config_json).unwrap_or_default();
config.rule_pack = installed_rule_pack();
let out = with_dom(|dom| driver::collect_browser_findings(dom, &config));
serde_json::to_string(&out).unwrap_or_else(|_| "{\"groups\":[],\"pageLevel\":[]}".into())
}
@@ -31,10 +56,10 @@ pub fn scoped_ignore_active(el: u32, rule_id: &str) -> bool {
}
/// The rule registry as JSON: `[{ id, name, category, severity, advisory, description }]`.
/// Built-ins in registry order, then any installed rule pack's rows.
#[wasm_bindgen]
pub fn antipatterns_json() -> String {
let rows: Vec<serde_json::Value> = impeccable_core::registry::ANTIPATTERNS
.iter()
let rows: Vec<serde_json::Value> = impeccable_core::registry::all_antipatterns()
.map(|ap| {
serde_json::json!({
"id": ap.id,
+140
View File
@@ -0,0 +1,140 @@
//! The `detect` feature's exports, exercised natively (a `#[wasm_bindgen]`
//! function is a plain Rust function off the wasm target): the JSON shapes,
//! the options object, and a rule pack installed through `set_rule_pack`.
use impeccable_core::findings::{finding_for, Finding};
use impeccable_core::registry::Antipattern;
use impeccable_core::rule_pack::RulePack;
use impeccable_wasm::exports_detect::{detect_html_source_json, detect_text_json};
use serde_json::Value;
static ROWS: &[Antipattern] = &[Antipattern {
id: "testpack/todo-marker",
category: "quality",
scopes: None,
severity: Some("warning"),
advisory: false,
name: "Unfinished copy marker",
description: "Text still carries a TODO marker from drafting.",
skill_section: None,
skill_guideline: None,
}];
#[derive(Debug)]
struct TestPack;
static PACK: TestPack = TestPack;
impl RulePack for TestPack {
fn registry(&self) -> &'static [Antipattern] {
ROWS
}
fn check_text(&self, content: &str, file_path: &str, _ext: &str) -> Vec<Finding> {
content
.split('\n')
.enumerate()
.filter(|(_, line)| line.contains("TODO(pack)"))
.map(|(i, line)| finding_for(&ROWS[0], file_path, line.trim(), (i + 1) as f64))
.collect()
}
}
const SOURCE: &str = ".card { border-left: 4px solid #6366f1; }\n";
const PAGE: &str = "<!DOCTYPE html>\n<html><head><title>t</title><style>\n.card { border-left: 4px solid #6366f1; }\n</style></head>\n<body><div class=\"card\">A card</div></body></html>\n";
fn ids(json: &str) -> Vec<String> {
serde_json::from_str::<Vec<Value>>(json)
.expect("findings JSON")
.into_iter()
.map(|f| f["antipattern"].as_str().unwrap_or_default().to_string())
.collect()
}
#[test]
fn text_export_shape() {
let json = detect_text_json(SOURCE, "src/Card.css", "{}");
let findings: Vec<Value> = serde_json::from_str(&json).unwrap();
assert!(!findings.is_empty());
let first = &findings[0];
for key in [
"antipattern",
"name",
"description",
"severity",
"category",
"file",
"line",
"snippet",
] {
assert!(first.get(key).is_some(), "missing {key} in {first}");
}
assert_eq!(first["file"], "src/Card.css");
// Bad options JSON falls back to the defaults instead of failing.
assert_eq!(
ids(&detect_text_json(SOURCE, "src/Card.css", "not json")),
ids(&json)
);
assert_eq!(
ids(&detect_text_json(SOURCE, "src/Card.css", "")),
ids(&json)
);
}
#[test]
fn html_export_shape() {
let json = detect_html_source_json(PAGE, "index.html", "{}");
let findings: Vec<Value> = serde_json::from_str(&json).unwrap();
assert!(!findings.is_empty(), "{json}");
assert_eq!(findings[0]["file"], "index.html");
}
#[test]
fn inline_ignores_option() {
let waived =
".card { /* impeccable-disable-line side-tab */ border-left: 4px solid #6366f1; }\n";
assert!(ids(&detect_text_json(waived, "a.css", "{}")).is_empty());
assert!(!ids(&detect_text_json(
waived,
"a.css",
"{\"inlineIgnores\":false}"
))
.is_empty());
}
#[test]
fn design_system_option_is_the_design_md_inputs() {
let source = "h1 { font-family: 'Comic Sans MS'; }\n";
let options = serde_json::json!({
"designSystem": {
"frontmatter": { "typography": { "display": { "fontFamily": "Fraunces, serif" } } }
}
})
.to_string();
let with_ds = ids(&detect_text_json(source, "a.css", &options));
assert!(
with_ds.iter().any(|id| id == "design-system-font"),
"{with_ds:?}"
);
assert!(!ids(&detect_text_json(source, "a.css", "{}"))
.iter()
.any(|id| id == "design-system-font"));
}
#[test]
fn installed_pack_reaches_the_exports() {
let source = format!("{SOURCE}/* TODO(pack) real palette */\n");
assert!(!ids(&detect_text_json(&source, "a.css", "{}"))
.iter()
.any(|id| id.starts_with("testpack/")));
impeccable_wasm::set_rule_pack(&PACK);
let with_pack = ids(&detect_text_json(&source, "a.css", "{}"));
assert!(
with_pack.iter().any(|id| id == "testpack/todo-marker"),
"{with_pack:?}"
);
// The registry export carries the pack's row after the built-ins.
let rows: Vec<Value> = serde_json::from_str(&impeccable_wasm::antipatterns_json()).unwrap();
assert_eq!(rows[0]["id"], "side-tab");
assert_eq!(rows[rows.len() - 1]["id"], "testpack/todo-marker");
}
+90
View File
@@ -105,6 +105,96 @@ matrices that pre-built it.
Run `cargo xtask bundle` after touching `crates/core`, `crates/wasm`, or
`browser-bundle/`, and commit the refreshed live asset.
## Rule packs
The built-in rules are compiled in and always run. A **rule pack** is how a
crate that depends on this workspace adds rules of its own without forking it:
one process-lifetime value carrying its own registry rows plus the hooks it
has rules for. With no pack installed nothing changes, which the oracle
enforces byte-for-byte.
The traits:
- `impeccable_core::rule_pack::RulePack` (object-safe, `Send + Sync + Debug`)
with three hooks, each defaulting to empty: `check_text(content, file_path,
ext)` for the text engine, `check_element_dom(dom, el)` and
`check_page_dom(dom)` for the browser engines.
- `impeccable_html::StaticRulePack` with `check_document(doc, file_path)`.
The `StaticDocument` model belongs to `crates/html`, and `detect` cannot
name a type from a crate that depends on it, so the static engine's hook is
a separate trait. A pack that covers HTML implements both.
Three steps for the downstream crate: declare `static ROWS: &[Antipattern]`
with namespaced ids (`mypack/my-rule`) and return them from `registry()`;
call `impeccable_core::rule_pack::install(&PACK)` once at startup, which is
what makes `get_antipattern` resolve the pack's ids and therefore what gives
its findings a name, description, category, and severity; then pass the pack
to the engine being run.
Where a pack reference travels:
| Engine | Field |
|---|---|
| text | `TextOptions.rule_pack`, `ScanOptions.rule_pack` |
| static HTML | `DetectHtmlOptions.static_rule_pack` and `.rule_pack`; `StaticHtmlEngine.static_rule_pack` for the `Engines` seam |
| browser / snapshot | `BrowserConfig.rule_pack` (`#[serde(skip)]`: a pack is a Rust value, never JSON from the page) |
Where each hook runs, and why there:
- **Text engine** (`detect_text`): after every built-in matcher, style-block
and CSS-in-JS pass, the design-system scan, the dedupe, and the page
analyzers, and before inline ignores. Appending last keeps built-in output
identical, and being inside the waiver step means `impeccable-disable`
covers a pack's rules the same way it covers built-in ones.
- **Static HTML engine** (`detect_html_source`): after the element rules, the
design-system merge, the page-level checks and the pattern checks, again
just before inline ignores. An HTML file gets exactly one pack pass:
`static_rule_pack` when it is set, otherwise `rule_pack.check_text` over
the raw HTML source, which is how a text-only pack still covers `.html`
files. A pack that implements both never reports the same file twice.
- **Browser driver** (`collect_browser_findings`): `check_element_dom` runs
at the end of the driver's per-element loop, through the same
disabled-rules filter and grouped onto the same element as the built-in
findings; `check_page_dom` runs after every built-in page pass, attributed
like the built-in checks that name their own element (`el: None` means
`document.body`). `skipScan` skips the pack too.
The registry keeps `ANTIPATTERNS` as the built-in list and consults the
registered rows after it (`registry::extend`, `registry::all_antipatterns`).
`extend` is idempotent per slice and panics on an id collision, so a pack can
never shadow a built-in rule. Registration is append-only and has no undo:
a pack is a property of the process, not of a run.
### The wasm `detect` feature
`crates/wasm` builds with `--features detect` for hosts that cannot exec the
binary (Cloudflare Workers and other wasm sandboxes). It adds two exports
over the file-scanning engines, JSON in and JSON out:
- `detect_text_json(content, file_path, options_json)`
- `detect_html_source_json(html, file_path, options_json)`
Both take `{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? } }`
and return the findings array `impeccable detect --json` prints, same keys and
same order. `designSystem` carries the DESIGN.md inputs rather than a
normalized object, because the JS API's normalized form used `Set`s and
`Map`s that JSON cannot hold. Unparseable options fall back to the defaults.
`antipatterns_json()` lists the built-ins followed by any pack's rows.
A pack reaches those exports through `impeccable_wasm::set_rule_pack` and
`exports_detect::set_static_rule_pack`, both Rust-only: the consumer is a
crate that links `impeccable-wasm` as an rlib, registers its pack, and runs
`wasm-pack` over itself. There is deliberately no JS-facing setter.
```bash
cargo build -p impeccable-wasm --features detect --target wasm32-unknown-unknown --release
```
Pristine (the PR design-review bot) is the first consumer: its `rules/` crate
carries `pristine/*` rules on all three hooks and reaches the engine through
this feature, replacing the `detectText` call it makes into the npm
`impeccable@3` package today.
## Releases
Two release kinds touch the runtime, in this order: