mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
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:
co-authored by
Claude Fable 5.1
parent
e7e46104dc
commit
1d0493af30
@@ -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
@@ -701,6 +701,8 @@ name = "impeccable-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"impeccable-core",
|
||||
"impeccable-detect",
|
||||
"impeccable-html",
|
||||
"impeccable-wasm",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user