Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion extension/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isAbsolute, join, resolve } from "node:path";
import { generateInjectionPatterns } from "./scripts/build-injection-patterns";
import { generateRuleDefaults } from "./scripts/build-rule-defaults";
import { generateSiteData } from "./scripts/build-site-data";
import { checkBackgroundPurity } from "./scripts/check-background-purity";
import { loadDefaultOverrides } from "./scripts/load-default-overrides";

const ROOT = import.meta.dir;
Expand Down Expand Up @@ -88,7 +89,7 @@ async function build(): Promise<void> {
// Cheap and idempotent; ensures dev never forgets to rerun codegen.
generateSiteData();
generateInjectionPatterns();
await generateRuleDefaults();
generateRuleDefaults();

// Resolve the optional --defaults / EXTENSION_DEFAULTS_FILE override
// against the codegen output's RULE_DEFAULTS so the validator knows the
Expand Down Expand Up @@ -168,6 +169,12 @@ async function build(): Promise<void> {
recursive: true,
filter: (src) => !src.endsWith(".svg"),
});

// Fails the build if any rule implementation file leaks into the
// background bundle β€” the service worker can't execute DOM-touching rule
// code. See scripts/check-background-purity.ts.
checkBackgroundPurity();

console.log(`Built extension to ${DIST}`);
}

Expand Down
114 changes: 22 additions & 92 deletions extension/bun.lock

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"@types/chrome": "^0.1.42",
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/jsdom": "^28.0.3",
"@types/lodash": "^4.17.24",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
Expand All @@ -74,7 +73,6 @@
"jest": "^30",
"jest-environment-jsdom": "^30",
"js-yaml": "^4.1.0",
"jsdom": "29.1.1",
"knip": "^6.15.0",
"ts-jest": "^29",
"typescript": "^6.0.3",
Expand Down
106 changes: 28 additions & 78 deletions extension/scripts/build-rule-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,87 +2,55 @@
// Licensed under PolyForm Shield 1.0.0 β€” see LICENSE.

// Compiles extension/data/rule-defaults.json into a TypeScript module
// consumed by `src/lib/storage.ts`. Same pattern as build-site-data.ts and
// build-injection-patterns.ts β€” generated output is committed and bundled
// statically, the runtime never reads JSON at startup.
// consumed by `src/lib/storage.ts` and `src/rules/index.ts`. Same pattern as
// build-site-data.ts and build-injection-patterns.ts β€” generated output is
// committed and bundled statically, the runtime never reads JSON at startup.
//
// The JSON is the single source of truth for which rules ship on by default
// in the prebuilt extension. To audit defaults, scan one file; to change
// them, edit that file and rerun `bun run build-rule-defaults`.
// in the prebuilt extension AND for the canonical rule id set (`RuleId` is
// derived from the keys here). Drift between the rule registry and these
// keys is caught at runtime by `__tests__/catalog.test.ts`, which compares
// `RULES.map(r => r.id)` to `RULE_IDS`.
//
// Codegen enforces completeness: every id in `RULE_IDS` must appear in the
// JSON, and the JSON must not mention any unknown id. Mismatch fails the
// build with a message listing offenders β€” adding a new rule without
// picking a default is a build error, not a silent fallback.
// This module has no transitive import of `src/rules` β€” that was the path
// pulling DOM-touching rule files into the service-worker bundle.

import { readFileSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { JSDOM } from "jsdom";
import { RuleDefaultsSchema } from "../data/rule-defaults.schema";

const ROOT = join(import.meta.dir, "..");
const INPUT = join(ROOT, "data", "rule-defaults.json");
const OUTPUT = join(ROOT, "src", "rules", "rule-defaults.generated.ts");

// A handful of rule modules touch DOM constructors at top level (e.g.
// `HTMLInputElement.prototype` in checkout-checkbox-sanitize.ts) to capture
// native setters before any framework can override them. Importing
// `../src/rules` from this Node-context script therefore needs DOM globals
// in scope. Jest already runs tests under jsdom; we reuse the same window
// here so the codegen sees the same surface as `__tests__/catalog.test.ts`.
function ensureDomGlobals(): void {
if ((globalThis as { HTMLElement?: unknown }).HTMLElement !== undefined) {
return;
}
const { window } = new JSDOM("");
for (const name of [
"HTMLElement",
"HTMLInputElement",
"HTMLAnchorElement",
"Element",
"Node",
"document",
"window",
"navigator",
] as const) {
if ((globalThis as Record<string, unknown>)[name] === undefined) {
(globalThis as Record<string, unknown>)[name] = (
window as unknown as Record<string, unknown>
)[name];
}
}
}

function buildOutput(
defaults: Record<string, boolean>,
ruleIds: readonly string[],
): string {
function buildOutput(defaults: Record<string, boolean>): string {
const ids = Object.keys(defaults);
const lines: string[] = [
"// AUTO-GENERATED β€” do not edit by hand.",
"// Source: extension/data/rule-defaults.json",
"// Regenerate with `bun run build-rule-defaults`.",
"",
'import type { RuleId } from "./index";',
"// Source of truth for `RuleId` and `RULE_IDS`. Lives outside `rules/index.ts`",
"// so service-worker code (`lib/storage.ts`, `background.ts`) can import the",
"// id set without pulling in any rule file's top-level DOM access.",
"",
"export const RULE_DEFAULTS: Readonly<Record<RuleId, boolean>> = {",
"export const RULE_DEFAULTS = {",
];
// Emit in RULE_IDS order so the generated file's diff stays stable as the
// registry evolves, regardless of how the JSON happens to be ordered.
for (const id of ruleIds) {
for (const id of ids) {
lines.push(` ${JSON.stringify(id)}: ${defaults[id]},`);
}
lines.push("};", "");
lines.push(
"} as const satisfies Readonly<Record<string, boolean>>;",
"",
"export type RuleId = keyof typeof RULE_DEFAULTS;",
"",
"export const RULE_IDS = Object.keys(RULE_DEFAULTS) as readonly RuleId[];",
"",
);
return lines.join("\n");
}

export async function generateRuleDefaults(): Promise<void> {
ensureDomGlobals();
// Dynamic import after stubbing so the rule registry's transitive DOM
// touches resolve against the jsdom globals above.
const { RULE_IDS } = (await import("../src/rules")) as {
RULE_IDS: readonly string[];
};

export function generateRuleDefaults(): void {
const raw = readFileSync(INPUT, "utf8");
let parsed: unknown;
try {
Expand All @@ -104,30 +72,12 @@ export async function generateRuleDefaults(): Promise<void> {
}

const declared = result.data.defaults;
const declaredKeys = new Set(Object.keys(declared));
const known = new Set<string>(RULE_IDS);

const missing = RULE_IDS.filter((id) => !declaredKeys.has(id));
const unknown = [...declaredKeys].filter((id) => !known.has(id));
if (missing.length > 0 || unknown.length > 0) {
const parts: string[] = [];
if (missing.length > 0) {
parts.push(`missing defaults for: ${missing.join(", ")}`);
}
if (unknown.length > 0) {
parts.push(`unknown rule ids: ${unknown.join(", ")}`);
}
throw new Error(
`${relative(ROOT, INPUT)}: defaults out of sync with rule registry β€” ${parts.join("; ")}`,
);
}

writeFileSync(OUTPUT, buildOutput(declared, RULE_IDS));
writeFileSync(OUTPUT, buildOutput(declared));
console.log(
`Generated ${relative(ROOT, OUTPUT)} from ${RULE_IDS.length} rules.`,
`Generated ${relative(ROOT, OUTPUT)} from ${Object.keys(declared).length} rules.`,
);
}

if (import.meta.main) {
await generateRuleDefaults();
generateRuleDefaults();
}
113 changes: 113 additions & 0 deletions extension/scripts/check-background-purity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 β€” see LICENSE.

// Post-build guard for the background bundle.
//
// The background service worker must NOT import any rule implementation
// file. Rule files touch DOM constructors (HTMLInputElement.prototype,
// MutationObserver, document, …) at module load time, which throws
// ReferenceError in a worker context. Even when the offending top-level
// access is moved into a function, importing rule modules into the worker
// is wasteful β€” it bloats the bundle with code that can never run there.
//
// This script reads each `src/rules/*.ts` rule file, extracts the top-level
// rule object's `label` string (the one inside `} satisfies Rule;`), and
// asserts that none of those strings appear in `dist/background.js`. Labels
// are user-facing English strings unique to rule files, so they're a sound
// canary that survives Bun's minification.
//
// Wired into `build.ts` so a fresh build fails loudly the moment something
// pulls a rule into the worker bundle.

import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";

const ROOT = join(import.meta.dir, "..");
const RULES_DIR = join(ROOT, "src", "rules");
const BUNDLE = join(ROOT, "dist", "background.js");

// Skip aggregator/type modules and generated catalogs. Generated rule files
// (easylist-generic, justdeleteme) don't export a Rule; they're consumed by
// other rule files.
const SKIP_FILES = new Set(["index.ts", "types.ts"]);

interface Canary {
ruleFile: string;
label: string;
}

function extractTopLevelRuleLabel(source: string): string | null {
// Top-level rule labels sit at exactly two-space indentation, either inside
// an inline `} satisfies Rule;` literal or as an option passed to a builder
// helper (e.g. `createSelectorHideRule({ label: "…" })`). Inner `label`
// fields inside arrays or nested objects are at 4+ spaces, and `label:
// string;` type annotations have no quoted value β€” both naturally excluded.
const labelRe = /^ {2}label:\s*"([^"]+)",?$/m;
const match = labelRe.exec(source);
return match?.[1] ?? null;
}

function collectCanaries(): Canary[] {
const canaries: Canary[] = [];
for (const name of readdirSync(RULES_DIR).toSorted()) {
if (!name.endsWith(".ts")) {
continue;
}
if (SKIP_FILES.has(name)) {
continue;
}
if (name.endsWith(".generated.ts")) {
continue;
}
const source = readFileSync(join(RULES_DIR, name), "utf8");
const label = extractTopLevelRuleLabel(source);
if (label) {
canaries.push({ ruleFile: name, label });
}
}
return canaries;
}

export function checkBackgroundPurity(): void {
let bundle: string;
try {
bundle = readFileSync(BUNDLE, "utf8");
} catch (error) {
throw new Error(
`${relative(ROOT, BUNDLE)} not found β€” run the extension build first.`,
{ cause: error },
);
}

const canaries = collectCanaries();
if (canaries.length === 0) {
throw new Error(
`No rule canaries collected from ${relative(ROOT, RULES_DIR)}. ` +
"The label-extraction regex may need updating.",
);
}

const leaks = canaries.filter(({ label }) => bundle.includes(label));
if (leaks.length > 0) {
const sample = leaks
.slice(0, 5)
.map(({ ruleFile, label }) => ` - ${ruleFile} ("${label}")`)
.join("\n");
const tail = leaks.length > 5 ? `\n …and ${leaks.length - 5} more` : "";
throw new Error(
`${relative(ROOT, BUNDLE)} leaks ${leaks.length} rule implementation file(s):\n${sample}${tail}\n\n` +
"The background service worker must not import rule files. Check that " +
"any new lib/* code in the background's import graph uses " +
"`rules/rule-defaults.generated` for RuleId/RULE_IDS rather than " +
"`rules/index.ts`.",
);
}

console.log(
`background.js purity: ok (${canaries.length} canaries, no leaks)`,
);
}

if (import.meta.main) {
checkBackgroundPurity();
}
19 changes: 0 additions & 19 deletions extension/src/lib/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,6 @@
// the freshly-derived defaults instead of any stale stored value from a
// previous case.

// Storage transitively pulls in the full rule catalog, which imports
// `nanoid` and `abort-utils`. Both are pure-ESM; ts-jest with
// `useESM: false` (jest.config.cjs) can't transform them. The catalog
// invariants test mocks the same modules for the same reason.
jest.mock("nanoid", () => ({ nanoid: () => "test-ref" }));
jest.mock("abort-utils", () => ({
ReusableAbortController: class {
abort(): void {
// noop
}
get signal(): AbortSignal {
return new AbortController().signal;
}
},
onAbort: (): (() => void) => () => {
// noop
},
}));

import { RULE_DEFAULTS } from "../../rules/rule-defaults.generated";
import type { getRuleStates as GetRuleStates } from "../storage";

Expand Down
15 changes: 11 additions & 4 deletions extension/src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 β€” see LICENSE.

// `RuleId` is a TYPE-ONLY import from `../rules` so this module's compiled
// JS has no dependency on the rule catalog β€” the rules index pulls in every
// rule implementation file, some of which touch DOM constructors at top
// level, which would crash the service-worker bundle. Runtime values
// (RULE_IDS, RULE_DEFAULTS) come from the generated defaults module, which
// is pure data. The post-build purity check
// (`scripts/check-background-purity.ts`) guards against regressions.
import type { RuleId } from "../rules";
import { RULE_IDS } from "../rules";
import { RULE_DEFAULTS } from "../rules/rule-defaults.generated";
import { RULE_DEFAULTS, RULE_IDS } from "../rules/rule-defaults.generated";
import { createChromeStorageValue } from "./chrome-storage-value";

export type RuleStates = Record<RuleId, boolean>;
Expand Down Expand Up @@ -50,7 +56,7 @@ const OVERRIDES: Partial<RuleStates> = parseOverrides();

const DEFAULT_STATES: RuleStates = Object.fromEntries(
RULE_IDS.map((id) => [id, OVERRIDES[id] ?? RULE_DEFAULTS[id]]),
) as RuleStates;
);

function normalize(raw: unknown): RuleStates {
const result: RuleStates = { ...DEFAULT_STATES };
Expand Down Expand Up @@ -89,4 +95,5 @@ export async function setAllRuleStates(
await ruleStatesStorage.set(normalize(states));
}

export { RULE_IDS, type RuleId } from "../rules";
export type { RuleId } from "../rules";
export { RULE_IDS } from "../rules/rule-defaults.generated";
12 changes: 7 additions & 5 deletions extension/src/options/__tests__/parse-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Copyright (c) 2026 PixieBrix, Inc.
// Licensed under PolyForm Shield 1.0.0 β€” see LICENSE.

// Mock the rules registry so we don't transitively pull in every rule module
// (irrelevant-sections-redact β†’ automation-element-reference β†’ nanoid, whose ESM
// build trips ts-jest's CJS transform).
jest.mock("../../rules", () => ({
RULES: [{ id: "rule-a" }, { id: "rule-b" }],
// parse-config.ts imports `RULE_IDS` from `lib/storage`, which re-exports it
// from `rules/rule-defaults.generated`. Mock the generated module so the test
// runs against a small id set (and so storage.ts's transitive
// `rules/index.ts` import doesn't pull in every rule file β€” some of which
// reach for ESM-only deps that ts-jest's CJS transform can't handle).
jest.mock("../../rules/rule-defaults.generated", () => ({
RULE_DEFAULTS: { "rule-a": true, "rule-b": false },
RULE_IDS: ["rule-a", "rule-b"],
}));

Expand Down
Loading
Loading