Files
pbakaus_impeccable/crates/detect/tests/rule_pack.rs
T
Paul BakausandClaude Fable 5.1 1d0493af30 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
2026-09-03 09:37:37 -07:00

128 lines
4.0 KiB
Rust

//! 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");
}