diff --git a/extension/src/lib/RuleList.tsx b/extension/src/lib/RuleList.tsx index df1b55c..e531bf8 100644 --- a/extension/src/lib/RuleList.tsx +++ b/extension/src/lib/RuleList.tsx @@ -1,19 +1,21 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -import type { Rule, RuleId } from "../rules"; +import type { CatalogRule, RuleId } from "../rules"; import { RULES } from "../rules"; import type { RuleAvailabilityStates } from "./availability"; import { RULE_GROUPS } from "./rule-groups"; import type { RuleStates } from "./storage"; import { setRuleEnabled } from "./storage"; -const RULES_BY_ID = new Map(RULES.map((rule) => [rule.id, rule])); +const RULES_BY_ID = new Map( + RULES.map((rule) => [rule.id, rule]), +); // The catalog invariant test (`places every rule in exactly one group`) // guarantees every group id resolves; this throws loudly if anyone ever // breaks that invariant by adding a rule without updating `RULE_GROUPS`. -function ruleById(id: RuleId): Rule { +function ruleById(id: RuleId): CatalogRule { const rule = RULES_BY_ID.get(id); if (!rule) { throw new Error(`Rule ${id} listed in RULE_GROUPS but not in RULES`); @@ -43,7 +45,7 @@ export function RuleList({ {group.ruleIds.map((ruleId) => { const rule = ruleById(ruleId); const snapshot = availability[rule.id]; - const unavailable = !snapshot?.available; + const unavailable = !snapshot.available; return (
  • Unavailable )} - {unavailable && snapshot?.reason && ( + {unavailable && snapshot.reason && (

    {snapshot.reason}

    )}

    {rule.description}

    diff --git a/extension/src/lib/__tests__/rule-engine.test.ts b/extension/src/lib/__tests__/rule-engine.test.ts index f0951aa..ac1854e 100644 --- a/extension/src/lib/__tests__/rule-engine.test.ts +++ b/extension/src/lib/__tests__/rule-engine.test.ts @@ -1,7 +1,7 @@ /** * @jest-environment jsdom */ -import type { Rule } from "../../rules/types"; +import type { AvailabilitySnapshot, Rule } from "../../rules/types"; // Storage and RULES are mocked because the real engine pulls in the full rule // catalog (and via that, the EasyList stylesheet — multi-MB of CSS text). The @@ -79,8 +79,19 @@ import { subscribeEffectiveEnforcement } from "../effective-enforcement"; import { isTopFrame } from "../frame"; import { revealAll } from "../placeholder"; import { start } from "../rule-engine"; +import type { RuleStates } from "../storage"; import { getRuleStates, subscribe } from "../storage"; +// The engine is unit-tested against a synthetic 3-rule catalog (FAKE_RULES), so +// these fixtures use ids outside the real `RuleId` union. Route them through +// these helpers to satisfy the strict `RuleStates` / `RuleAvailabilityStates` +// shapes without scattering casts at every call site. +const asStates = (states: Record): RuleStates => + states as RuleStates; +const asAvailability = ( + availability: Record, +): RuleAvailabilityStates => availability as RuleAvailabilityStates; + const getRuleStatesMock = getRuleStates as jest.MockedFunction< typeof getRuleStates >; @@ -99,17 +110,17 @@ const subscribeAvailabilityMock = >; const revealAllMock = revealAll as jest.MockedFunction; -const ALL_AVAILABLE: RuleAvailabilityStates = { +const ALL_AVAILABLE = asAvailability({ "all-frame-rule": { available: true }, "top-only-rule": { available: true }, "unavailable-rule": { available: false, reason: "unavailable" }, -}; +}); -const allEnabled = { +const allEnabled = asStates({ "all-frame-rule": true, "top-only-rule": true, "unavailable-rule": true, -}; +}); function setFrame(isTop: boolean): void { // jsdom locks `window.top` as a non-configurable getter, so we mock the @@ -182,10 +193,9 @@ describe("rule engine — reconciliation", () => { } it("applies a rule when storage flips it on", async () => { - getRuleStatesMock.mockResolvedValue({ - ...allEnabled, - "all-frame-rule": false, - }); + getRuleStatesMock.mockResolvedValue( + asStates({ ...allEnabled, "all-frame-rule": false }), + ); const { onStorageChange } = await startAndCaptureListeners(); expect(allFrameRule.apply).not.toHaveBeenCalled(); @@ -199,7 +209,7 @@ describe("rule engine — reconciliation", () => { const { onStorageChange } = await startAndCaptureListeners(); expect(allFrameRule.apply).toHaveBeenCalledTimes(1); - onStorageChange({ ...allEnabled, "all-frame-rule": false }); + onStorageChange(asStates({ ...allEnabled, "all-frame-rule": false })); expect(revealAllMock).toHaveBeenCalledWith("all-frame-rule"); expect(allFrameRule.teardown).toHaveBeenCalledTimes(1); @@ -243,10 +253,12 @@ describe("rule engine — reconciliation", () => { const { onAvailabilityChange } = await startAndCaptureListeners(); expect(allFrameRule.apply).toHaveBeenCalledTimes(1); - onAvailabilityChange({ - ...ALL_AVAILABLE, - "all-frame-rule": { available: false, reason: "now unavailable" }, - }); + onAvailabilityChange( + asAvailability({ + ...ALL_AVAILABLE, + "all-frame-rule": { available: false, reason: "now unavailable" }, + }), + ); expect(allFrameRule.teardown).toHaveBeenCalledTimes(1); }); @@ -256,10 +268,12 @@ describe("rule engine — reconciliation", () => { // unavailable-rule started unavailable → never applied at start(). expect(unavailableRule.apply).not.toHaveBeenCalled(); - onAvailabilityChange({ - ...ALL_AVAILABLE, - "unavailable-rule": { available: true }, - }); + onAvailabilityChange( + asAvailability({ + ...ALL_AVAILABLE, + "unavailable-rule": { available: true }, + }), + ); expect(unavailableRule.apply).toHaveBeenCalledTimes(1); }); @@ -288,7 +302,7 @@ describe("rule engine — missing document.body", () => { (allFrameRule.teardown as jest.Mock).mockClear(); document.body.remove(); - onStorageChange({ ...allEnabled, "all-frame-rule": false }); + onStorageChange(asStates({ ...allEnabled, "all-frame-rule": false })); expect(allFrameRule.apply).not.toHaveBeenCalled(); expect(allFrameRule.teardown).not.toHaveBeenCalled(); diff --git a/extension/src/lib/__tests__/storage.test.ts b/extension/src/lib/__tests__/storage.test.ts index a19eda5..441706c 100644 --- a/extension/src/lib/__tests__/storage.test.ts +++ b/extension/src/lib/__tests__/storage.test.ts @@ -11,6 +11,7 @@ // the freshly-derived defaults instead of any stale stored value from a // previous case. +import type { RuleId } from "../../rules/rule-metadata"; import { RULE_DEFAULTS as RAW_RULE_DEFAULTS } from "../../rules/rule-metadata"; import type * as Storage from "../storage"; @@ -66,10 +67,10 @@ describe("storage default states", () => { // defaults happen to lean. const trueRule = Object.entries(RULE_DEFAULTS).find( ([, value]) => value, - )?.[0]; + )?.[0] as RuleId | undefined; const falseRule = Object.entries(RULE_DEFAULTS).find( ([, value]) => !value, - )?.[0]; + )?.[0] as RuleId | undefined; if (trueRule === undefined || falseRule === undefined) { throw new Error( "Expected RULE_DEFAULTS to include at least one true and one false default for this test.", @@ -88,7 +89,7 @@ describe("storage default states", () => { if (id === trueRule || id === falseRule) { continue; } - expect(states[id]).toBe(value); + expect(states[id as RuleId]).toBe(value); } }); @@ -100,7 +101,7 @@ describe("storage default states", () => { }); it("ignores non-boolean override values without throwing", async () => { - const someRule = Object.keys(RULE_DEFAULTS)[0] as string; + const someRule = Object.keys(RULE_DEFAULTS)[0] as RuleId; process.env.EXTENSION_DEFAULT_OVERRIDES = JSON.stringify({ [someRule]: "yes", }); @@ -136,8 +137,8 @@ describe("storage default states", () => { describe("normalize via getRuleStates", () => { // Pick stable rule ids off the live catalog so the test doesn't tie itself // to the iteration order of RULE_DEFAULTS. - function pickRuleIds(): { first: string; second: string } { - const ids = Object.keys(RULE_DEFAULTS); + function pickRuleIds(): { first: RuleId; second: RuleId } { + const ids = Object.keys(RULE_DEFAULTS) as RuleId[]; const first = ids[0]; const second = ids[1]; if (!first || !second) { @@ -164,7 +165,11 @@ describe("normalize via getRuleStates", () => { it("fills in missing rule ids from defaults when stored state is partial", async () => { const { getRuleStates, ruleStatesStorage } = await loadStorage(); const { first } = pickRuleIds(); - await ruleStatesStorage.set({ [first]: !RULE_DEFAULTS[first] }); + // A single-key object types as `{ [x: string]: boolean }`; cast to the full + // shape (the stub stores it verbatim, then `normalize()` fills the rest). + await ruleStatesStorage.set({ + [first]: !RULE_DEFAULTS[first], + } as Storage.RuleStates); const states = await getRuleStates(); expect(states[first]).toBe(!RULE_DEFAULTS[first]); @@ -173,7 +178,7 @@ describe("normalize via getRuleStates", () => { if (id === first) { continue; } - expect(states[id]).toBe(value); + expect(states[id as RuleId]).toBe(value); } }); }); @@ -181,7 +186,7 @@ describe("normalize via getRuleStates", () => { describe("setRuleEnabled", () => { it("flips one rule while preserving the others", async () => { const { getRuleStates, setRuleEnabled } = await loadStorage(); - const target = Object.keys(RULE_DEFAULTS)[0]; + const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined; if (!target) { throw new Error("RULE_DEFAULTS must have at least one entry"); } @@ -195,13 +200,13 @@ describe("setRuleEnabled", () => { if (id === target) { continue; } - expect(states[id]).toBe(value); + expect(states[id as RuleId]).toBe(value); } }); it("notifies subscribers when state changes", async () => { const { setRuleEnabled, subscribe } = await loadStorage(); - const target = Object.keys(RULE_DEFAULTS)[0]; + const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined; if (!target) { throw new Error("RULE_DEFAULTS must have at least one entry"); } @@ -220,7 +225,7 @@ describe("setRuleEnabled", () => { describe("setAllRuleStates", () => { it("normalizes partial input before writing — missing ids fill from defaults", async () => { const { getRuleStates, setAllRuleStates } = await loadStorage(); - const target = Object.keys(RULE_DEFAULTS)[0]; + const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined; if (!target) { throw new Error("RULE_DEFAULTS must have at least one entry"); } @@ -233,22 +238,24 @@ describe("setAllRuleStates", () => { if (id === target) { continue; } - expect(states[id]).toBe(value); + expect(states[id as RuleId]).toBe(value); } }); it("drops non-boolean values via normalize before writing", async () => { const { getRuleStates, setAllRuleStates } = await loadStorage(); - const target = Object.keys(RULE_DEFAULTS)[0]; + const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined; if (!target) { throw new Error("RULE_DEFAULTS must have at least one entry"); } - // Cast to bypass the typed shape — normalize() inside setAllRuleStates - // is responsible for dropping the corrupt string value. + // `"junk"` is a deliberately corrupt (non-boolean) value; normalize() + // inside setAllRuleStates is responsible for dropping it. (A computed + // `[target]` key widens the literal away, so this needs no cast to pass the + // `Partial` shape.) await setAllRuleStates({ [target]: "junk", - } as unknown as Partial); + }); // junk replaced with default → stored map equals defaults. await expect(getRuleStates()).resolves.toEqual(RULE_DEFAULTS); @@ -258,7 +265,7 @@ describe("setAllRuleStates", () => { describe("subscribe", () => { it("stops invoking the listener after the returned cleanup runs", async () => { const { setRuleEnabled, subscribe } = await loadStorage(); - const target = Object.keys(RULE_DEFAULTS)[0]; + const target = Object.keys(RULE_DEFAULTS)[0] as RuleId | undefined; if (!target) { throw new Error("RULE_DEFAULTS must have at least one entry"); } diff --git a/extension/src/lib/availability.ts b/extension/src/lib/availability.ts index 0da0b11..322f729 100644 --- a/extension/src/lib/availability.ts +++ b/extension/src/lib/availability.ts @@ -49,7 +49,9 @@ export async function getRuleAvailabilityStates(): Promise [rule.id, await resolveAvailability(rule)] as const, ), ); - return Object.fromEntries(entries); + // Entries cover every `RuleId` (keyed off `RULES`); `Object.fromEntries` + // widens the key back to `string`, so cast to the exact map shape. + return Object.fromEntries(entries) as RuleAvailabilityStates; } // Subscribe to changes in any rule's reactive availability. The listener is diff --git a/extension/src/lib/background/lifecycle.ts b/extension/src/lib/background/lifecycle.ts index b273c7c..37da65d 100644 --- a/extension/src/lib/background/lifecycle.ts +++ b/extension/src/lib/background/lifecycle.ts @@ -9,6 +9,9 @@ // doesn't own — session-store writes and the content broadcast (content scripts // can't observe the session area, so the background bridges every change). +// `rules/rule-metadata` (not `rules/index`) keeps this worker module free of +// rule-file DOM access — see `check-background-purity.ts`. +import type { RuleId } from "../../rules/rule-metadata"; import { debugTraceStorage } from "../debug-trace"; import { appendEvent as appendDebugTraceEvent, @@ -32,7 +35,7 @@ const DETECTION_KIND_TO_RULE_ID = { "roach-motel": "roach-motel-annotate", "webdriver-probe": "webdriver-probe-annotate", "closed-shadow-root": "closed-shadow-root-annotate", -} as const satisfies Record; +} as const satisfies Record; export function startBackgroundLifecycle(tracker: TabTracker): void { chrome.tabs.onRemoved.addListener((tabId) => { @@ -199,9 +202,9 @@ export function startBackgroundLifecycle(tracker: TabTracker): void { } for (const [kind, ruleId] of Object.entries(DETECTION_KIND_TO_RULE_ID) as [ DetectionKind, - string, + RuleId, ][]) { - if (previous[ruleId] === true && next[ruleId] === false) { + if (previous[ruleId] && !next[ruleId]) { tracker.clearDetectionsOfKind(kind); } } diff --git a/extension/src/lib/inline-text-redact.ts b/extension/src/lib/inline-text-redact.ts index 39bf1e8..0d07c0d 100644 --- a/extension/src/lib/inline-text-redact.ts +++ b/extension/src/lib/inline-text-redact.ts @@ -41,8 +41,11 @@ import type { RuleId } from "./storage"; import { createSubtreeWatcher } from "./subtree-watcher"; import { walkTextNodeGroupsChunked } from "./yielding-text-walk"; -export interface InlineTextRedactRuleOptions { - id: RuleId; +export interface InlineTextRedactRuleOptions { + // Generic over the literal id so `rule.id` keeps its narrow `RuleId` literal + // (inferred from the call site) rather than widening to `RuleId`. The rule + // catalog's compile-time agreement check in `rules/index.ts` relies on it. + id: Id; label: string; description: string; // Skip text nodes shorter than this — cheap per-node early-out before @@ -56,11 +59,14 @@ export interface InlineTextRedactRuleOptions { // Tighter than `Rule` — the factory always installs a teardown, so callers // (notably tests that call `rule.teardown()` directly in `afterEach`) don't // need to widen with `?.()` or assertion. -export type InlineTextRedactRule = Rule & { teardown: () => void }; +export type InlineTextRedactRule = Rule & { + id: Id; + teardown: () => void; +}; -export function defineInlineTextRedactRule( - options: InlineTextRedactRuleOptions, -): InlineTextRedactRule { +export function defineInlineTextRedactRule( + options: InlineTextRedactRuleOptions, +): InlineTextRedactRule { const { id, label, description, minLength, collectMatches } = options; const lifecycle = new ReusableAbortController(); @@ -110,7 +116,7 @@ export function defineInlineTextRedactRule( unsubscribeRouteChange?.(); unsubscribeRouteChange = null; }, - } satisfies InlineTextRedactRule; + } satisfies InlineTextRedactRule; } // Bucket a chunk of group-tagged text nodes into runs of consecutive diff --git a/extension/src/lib/page-world-hooks.ts b/extension/src/lib/page-world-hooks.ts index a956452..da0a734 100644 --- a/extension/src/lib/page-world-hooks.ts +++ b/extension/src/lib/page-world-hooks.ts @@ -39,7 +39,7 @@ function ruleEnabledAndEnforced(ruleId: RuleId): () => Promise { getRuleStates(), getEnforcementEnabled(), ]); - return enforcementEnabled && Boolean(states[ruleId]); + return enforcementEnabled && states[ruleId]; }; } diff --git a/extension/src/lib/rule-engine.ts b/extension/src/lib/rule-engine.ts index 0177359..e828729 100644 --- a/extension/src/lib/rule-engine.ts +++ b/extension/src/lib/rule-engine.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -import type { Rule } from "../rules"; +import type { CatalogRule } from "../rules"; import { RULES } from "../rules"; import type { RuleAvailabilityStates } from "./availability"; import { @@ -155,11 +155,11 @@ function injectStyles(): void { } function isApplicableHere( - rule: Rule, + rule: CatalogRule, topFrame: boolean, availability: RuleAvailabilityStates, ): boolean { - if (!availability[rule.id]?.available) { + if (!availability[rule.id].available) { return false; } // topFrameOnly rules target page-wide concepts (site footer, cookie/newsletter @@ -213,14 +213,12 @@ function applyEnabled( // (storage toggle, enforcement switch, availability flip) routes through the // same diff logic. function effectivelyApplied( - rule: Rule, + rule: CatalogRule, states: RuleStates, topFrame: boolean, availability: RuleAvailabilityStates, ): boolean { - return ( - isApplicableHere(rule, topFrame, availability) && Boolean(states[rule.id]) - ); + return isApplicableHere(rule, topFrame, availability) && states[rule.id]; } function reconcile( @@ -267,9 +265,12 @@ function applyDisplayMode(mode: PlaceholderDisplayMode): void { document.documentElement.setAttribute(PLACEHOLDER_MODE_ATTR, mode); } -const ALL_DISABLED: RuleStates = Object.fromEntries( +// `Object.fromEntries` types its result as `{ [k: string]: false }`; every +// `RuleId` is covered because the entries come from `RULES`, so the cast to the +// exact `RuleStates` shape is sound. +const ALL_DISABLED = Object.fromEntries( RULES.map((rule) => [rule.id, false]), -); +) as RuleStates; function mask(states: RuleStates, enforcementEnabled: boolean): RuleStates { return enforcementEnabled ? states : ALL_DISABLED; diff --git a/extension/src/lib/selector-hide-rule.ts b/extension/src/lib/selector-hide-rule.ts index 24ec2c6..111aeb0 100644 --- a/extension/src/lib/selector-hide-rule.ts +++ b/extension/src/lib/selector-hide-rule.ts @@ -27,8 +27,11 @@ export interface SiteRule { selectors: string[]; } -interface SelectorHideRuleOptions { - id: RuleId; +interface SelectorHideRuleOptions { + // Generic over the literal id so `rule.id` keeps its narrow `RuleId` literal + // (inferred from the call site) instead of widening to `RuleId` — the rule + // catalog's compile-time agreement check in `rules/index.ts` depends on it. + id: Id; label: string; description: string; // Text shown on the placeholder's reveal button. Required unless @@ -53,16 +56,16 @@ interface SelectorHideRuleOptions { topFrameOnly?: boolean; } -export interface SelectorHideRule { - rule: Rule; +export interface SelectorHideRule { + rule: Rule & { id: Id }; // Exposed so each rule file can re-export it for tests that assert // URL-gated selector composition. selectorsFor: (url: string) => string[]; } -export function createSelectorHideRule( - options: SelectorHideRuleOptions, -): SelectorHideRule { +export function createSelectorHideRule( + options: SelectorHideRuleOptions, +): SelectorHideRule { const { id, label, @@ -252,7 +255,7 @@ export function createSelectorHideRule( } } - const rule: Rule = watchSubtrees + const rule: Rule & { id: Id } = watchSubtrees ? { id, label, diff --git a/extension/src/lib/storage.ts b/extension/src/lib/storage.ts index e9c7b7f..9067345 100644 --- a/extension/src/lib/storage.ts +++ b/extension/src/lib/storage.ts @@ -54,9 +54,11 @@ function parseOverrides(): Partial { const OVERRIDES: Partial = parseOverrides(); -const DEFAULT_STATES: RuleStates = Object.fromEntries( +// `RULE_IDS.map` covers every `RuleId`; `Object.fromEntries` widens the key to +// `string`, so cast back to the exact `RuleStates` shape. +const DEFAULT_STATES = Object.fromEntries( RULE_IDS.map((id) => [id, OVERRIDES[id] ?? RULE_DEFAULTS[id]]), -); +) as RuleStates; function normalize(raw: unknown): RuleStates { const result: RuleStates = { ...DEFAULT_STATES }; diff --git a/extension/src/options/parse-config.ts b/extension/src/options/parse-config.ts index 4768ce9..4448b34 100644 --- a/extension/src/options/parse-config.ts +++ b/extension/src/options/parse-config.ts @@ -2,10 +2,16 @@ // Licensed under PolyForm Shield 1.0.0 — see LICENSE. import { isValidPattern } from "../lib/site-denylist"; -import type { RuleStates } from "../lib/storage"; +import type { RuleId, RuleStates } from "../lib/storage"; import { RULE_IDS } from "../lib/storage"; -const RULE_ID_SET = new Set(RULE_IDS); +const RULE_ID_SET: ReadonlySet = new Set(RULE_IDS); + +// Narrowing membership test so a validated key indexes `RuleStates` as a +// `RuleId` rather than a bare `string`. +function isRuleId(key: string): key is RuleId { + return RULE_ID_SET.has(key); +} // Reserved non-rule keys the Options-page *Apply configuration* round-trip // understands. Mirrors the reserved-keys set in the build-time defaults @@ -56,7 +62,7 @@ export function parseConfig(input: string): ParseResult { } continue; } - if (!RULE_ID_SET.has(key)) { + if (!isRuleId(key)) { errors.push(`Unknown rule: ${key}`); continue; } diff --git a/extension/src/rules/__tests__/ads-hide.test.ts b/extension/src/rules/__tests__/ads-hide.test.ts index cdc6ac3..e40ddc3 100644 --- a/extension/src/rules/__tests__/ads-hide.test.ts +++ b/extension/src/rules/__tests__/ads-hide.test.ts @@ -28,7 +28,7 @@ beforeEach(() => { }); afterEach(() => { - adsHideRule.teardown?.(); + adsHideRule.teardown(); jest.useRealTimers(); }); @@ -142,7 +142,7 @@ describe("adsHideRule EasyList stylesheet", () => { it("removes the EasyList stylesheet on teardown()", () => { adsHideRule.apply(document.body); expect(document.querySelector(`#${EASYLIST_STYLE_ID}`)).not.toBeNull(); - adsHideRule.teardown?.(); + adsHideRule.teardown(); expect(document.querySelector(`#${EASYLIST_STYLE_ID}`)).toBeNull(); }); diff --git a/extension/src/rules/__tests__/chat-widget-hide.test.ts b/extension/src/rules/__tests__/chat-widget-hide.test.ts index 25a64bb..f1137b7 100644 --- a/extension/src/rules/__tests__/chat-widget-hide.test.ts +++ b/extension/src/rules/__tests__/chat-widget-hide.test.ts @@ -15,7 +15,7 @@ beforeEach(() => { }); afterEach(() => { - chatWidgetHideRule.teardown?.(); + chatWidgetHideRule.teardown(); }); describe("chatWidgetHideRule", () => { @@ -140,7 +140,7 @@ describe("chatWidgetHideRule", () => { it("teardown removes the stylesheet so later additions stay visible", () => { chatWidgetHideRule.apply(document.body); - chatWidgetHideRule.teardown?.(); + chatWidgetHideRule.teardown(); const container = document.createElement("div"); container.id = "intercom-container"; diff --git a/extension/src/rules/ads-hide.ts b/extension/src/rules/ads-hide.ts index 53e49e2..fff8134 100644 --- a/extension/src/rules/ads-hide.ts +++ b/extension/src/rules/ads-hide.ts @@ -150,9 +150,9 @@ const { rule: baseRule } = createSelectorHideRule({ const baseApply = baseRule.apply; const baseTeardown = baseRule.teardown; -export const adsHideRule: Rule = { +export const adsHideRule = { ...baseRule, - apply(root) { + apply(root: ParentNode) { injectEasyListStylesheet(); baseApply(root); }, @@ -160,4 +160,4 @@ export const adsHideRule: Rule = { baseTeardown?.(); removeEasyListStylesheet(); }, -}; +} satisfies Rule; diff --git a/extension/src/rules/chat-widget-hide.ts b/extension/src/rules/chat-widget-hide.ts index 4c37051..703cb8e 100644 --- a/extension/src/rules/chat-widget-hide.ts +++ b/extension/src/rules/chat-widget-hide.ts @@ -30,7 +30,7 @@ import { injectHideStylesheet } from "../lib/css-hide-stylesheet"; import { registerCssFirstSelectors } from "../lib/rule-count"; import type { Rule } from "./types"; -const RULE_ID = "chat-widget-hide"; +const RULE_ID = "chat-widget-hide" as const; const STYLE_ID = "abs-chat-widget-hide"; const SELECTORS: readonly string[] = [ @@ -75,7 +75,7 @@ const UNION_SELECTOR = SELECTORS.join(","); let stylesheet: HideStylesheet | null = null; let unregisterCount: (() => void) | null = null; -export const chatWidgetHideRule: Rule = { +export const chatWidgetHideRule = { id: RULE_ID, label: "Remove Chat Widgets", description: "Remove live-chat widgets (Intercom, Drift, Zendesk, etc.).", @@ -85,7 +85,10 @@ export const chatWidgetHideRule: Rule = { // descending into it. Running this rule inside vendor iframes would also // try to delete the vendor's own UI. topFrameOnly: true, - apply() { + // Param matches `Rule.apply` even though this rule ignores the root (it + // injects a document-level stylesheet). Without it, `satisfies Rule` would + // infer `() => void` and callers passing a root would fail to type-check. + apply(_root: ParentNode) { // Idempotent — `injectHideStylesheet` re-uses the existing