From 9748eb2d7128a1f91f56128fd40932d68e3dd23f Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Mon, 22 Jun 2026 11:13:43 -0400 Subject: [PATCH] Disable unicorn/no-break-in-nested-loop (#279) Final ratchet candidate from #279. Not a clean ratchet: the rule treats a `switch` inside a loop as a "nested loop", so ~9 of the 27 sites are idiomatic switch-case `break`s (required syntax, not loop control) it would force into pointless function extractions, plus a batch of test/mock sites. The rule can't be narrowed to exclude `switch` or those sites, so it's turned off rather than left as a noisy warning. The genuinely improvable production sites were cleaned up directly: - subtree-watcher.ts: extract dispatchAddedNode() (guard continues -> returns) - rule-count.ts: extract traceUnionMatches() helper - schema-trust-sanitize.ts: extract nearestItemscope() ancestor walk - encoded-payload-redact.ts: break-on-first-decode loop -> .some() - disguised-ad-flag.ts: break-on-first-match loop -> .some() - build.ts: extract findEnvValueInContent() for the dotenv line parser - selector-token-index.ts: guard continue -> optional chaining - countdown-timer-redact.ts: guard continue -> inverted condition Co-Authored-By: Claude Opus 4.8 (1M context) --- extension/build.ts | 48 ++++++++------ extension/eslint.config.js | 11 +++- extension/src/lib/rule-count.ts | 43 ++++++++----- extension/src/lib/selector-token-index.ts | 7 +-- extension/src/lib/subtree-watcher.ts | 63 ++++++++++--------- extension/src/rules/countdown-timer-redact.ts | 5 +- extension/src/rules/disguised-ad-flag.ts | 10 +-- extension/src/rules/encoded-payload-redact.ts | 21 +++---- extension/src/rules/schema-trust-sanitize.ts | 29 +++++---- 9 files changed, 131 insertions(+), 106 deletions(-) diff --git a/extension/build.ts b/extension/build.ts index 0df8fc7..78d2802 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -38,6 +38,33 @@ function parseDefaultsFlag(argv: readonly string[]): string | undefined { return undefined; } +// Parse a dotenv-style file body for `name`, returning the (de-quoted) value +// or undefined if the key isn't present. +function findEnvValueInContent( + content: string, + name: string, +): string | undefined { + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const eq = line.indexOf("="); + if (eq === -1 || line.slice(0, eq).trim() !== name) { + continue; + } + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return value; + } + return undefined; +} + function readEnvValue(name: string): string { if (process.env[name]) { return process.env[name] ?? ""; @@ -49,25 +76,8 @@ function readEnvValue(name: string): string { } catch { continue; } - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - const eq = line.indexOf("="); - if (eq === -1) { - continue; - } - if (line.slice(0, eq).trim() !== name) { - continue; - } - let value = line.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } + const value = findEnvValueInContent(content, name); + if (value !== undefined) { return value; } } diff --git a/extension/eslint.config.js b/extension/eslint.config.js index 1ec94e5..5088284 100644 --- a/extension/eslint.config.js +++ b/extension/eslint.config.js @@ -279,8 +279,15 @@ export default tseslint.config( // so enabling it means breaking shadow-DOM scans or scoped-disabling // ~10+ correct sites. Not worth it. See #279. "unicorn/prefer-scoped-selector": "off", - // Warn — ratchet to error in #279: - "unicorn/no-break-in-nested-loop": "warn", + // Off — not a clean ratchet (see #279). The rule treats a `switch` + // inside a loop as a "nested loop", so ~9 sites are idiomatic + // switch-case `break`s (required syntax, not loop control) that it + // would force into pointless function extractions. The genuinely + // improvable production sites — guard inversions, `.some()`, and small + // helper extractions — were cleaned up directly, but the rule itself + // can't be narrowed to exclude `switch` or the remaining test/mock + // sites, so leaving it on would be all churn for no further upside. + "unicorn/no-break-in-nested-loop": "off", // no-global-object-property-assignment stays at its recommended `error` // for production code; it's disabled only for tests (which legitimately // assign globals to set up mocks) in the test-files block below. diff --git a/extension/src/lib/rule-count.ts b/extension/src/lib/rule-count.ts index 4aec60a..2acf859 100644 --- a/extension/src/lib/rule-count.ts +++ b/extension/src/lib/rule-count.ts @@ -114,29 +114,38 @@ function currentCounts(): Record { if (matches.length > 0) { counts[ruleId] = (counts[ruleId] ?? 0) + matches.length; } - if (!traceCssMatches) { - continue; - } - for (const element of matches) { - if (reg.traced.has(element)) { - continue; - } - reg.traced.add(element); - const html = element.outerHTML; - recordRuleApplication({ - ruleId, - kind: "hide", - selector: reg.union, - beforeHtml: html, - afterHtml: html, - cssOnly: true, - }); + if (traceCssMatches) { + traceUnionMatches(ruleId, reg, matches); } } } return counts; } +// Emit a debug-trace `rule-application` event per newly-matched element for a +// CSS-first union. See currentCounts() for why these are `cssOnly`. +function traceUnionMatches( + ruleId: string, + reg: CssFirstUnion, + matches: NodeListOf, +): void { + for (const element of matches) { + if (reg.traced.has(element)) { + continue; + } + reg.traced.add(element); + const html = element.outerHTML; + recordRuleApplication({ + ruleId, + kind: "hide", + selector: reg.union, + beforeHtml: html, + afterHtml: html, + cssOnly: true, + }); + } +} + function shallowEqual( a: Record, b: Record, diff --git a/extension/src/lib/selector-token-index.ts b/extension/src/lib/selector-token-index.ts index 4b70f4f..454363a 100644 --- a/extension/src/lib/selector-token-index.ts +++ b/extension/src/lib/selector-token-index.ts @@ -137,12 +137,7 @@ function dispatchToRules(roots: Element[]): void { ? new Set(registrations.keys()) : findTriggeredRules(root); for (const ruleId of triggered) { - const registration = registrations.get(ruleId); - - if (!registration) { - continue; - } - registration.dispatchScan(root); + registrations.get(ruleId)?.dispatchScan(root); } } } diff --git a/extension/src/lib/subtree-watcher.ts b/extension/src/lib/subtree-watcher.ts index 531f524..67fa07c 100644 --- a/extension/src/lib/subtree-watcher.ts +++ b/extension/src/lib/subtree-watcher.ts @@ -192,11 +192,41 @@ function enqueueAttributeMutation( } } +// Dispatch a single added node into the router. The shared filters +// (nodeType, IGNORE_TAGS, isConnected) run once per node regardless of +// subscriber count — the whole point of the router. +function dispatchAddedNode(router: Router, added: Node): void { + if (added.nodeType !== Node.ELEMENT_NODE) { + return; + } + const element = added as Element; + if (IGNORE_TAGS.has(element.tagName)) { + return; + } + // React-style reconciliation routinely adds-then-removes a node in + // the same tick. By the time MutationObserver fires (microtask + // afterward), the addition record is still there but the node is + // already detached. drainSubscriber() filters by isConnected too — + // checking at enqueue avoids buffering churn in pending during a + // 5k-node route swap with high in-tick removal rate. + if (!element.isConnected) { + return; + } + enqueueForAllSubscribers(router, element); + // A freshly-inserted host element may already have a populated + // open shadow root (custom elements that build their shadow in + // the constructor, lit/stencil components, etc.). adoptShadowRoot + // observes the root and dispatches its initial children — without + // this, mutation observation would only catch FUTURE additions + // into the shadow, never the content present at insertion time. + for (const shadow of discoverShadowRootsIn(element)) { + adoptShadowRoot(router, shadow); + } +} + function fanOut(router: Router, mutations: MutationRecord[]): void { // Walk every added node once and dispatch into each subscriber's pending - // set. The shared filters (nodeType, IGNORE_TAGS, isConnected) run once - // per node regardless of subscriber count — the whole point of the - // router. Per-subscriber filters (skipPlaceholderSubtrees) still run + // set. Per-subscriber filters (skipPlaceholderSubtrees) still run // per (node, subscriber) pair, but they're cheap classlist reads. for (const mutation of mutations) { if (mutation.type === "attributes") { @@ -208,32 +238,7 @@ function fanOut(router: Router, mutations: MutationRecord[]): void { continue; } for (const added of mutation.addedNodes) { - if (added.nodeType !== Node.ELEMENT_NODE) { - continue; - } - const element = added as Element; - if (IGNORE_TAGS.has(element.tagName)) { - continue; - } - // React-style reconciliation routinely adds-then-removes a node in - // the same tick. By the time MutationObserver fires (microtask - // afterward), the addition record is still there but the node is - // already detached. drainSubscriber() filters by isConnected too — - // checking at enqueue avoids buffering churn in pending during a - // 5k-node route swap with high in-tick removal rate. - if (!element.isConnected) { - continue; - } - enqueueForAllSubscribers(router, element); - // A freshly-inserted host element may already have a populated - // open shadow root (custom elements that build their shadow in - // the constructor, lit/stencil components, etc.). adoptShadowRoot - // observes the root and dispatches its initial children — without - // this, mutation observation would only catch FUTURE additions - // into the shadow, never the content present at insertion time. - for (const shadow of discoverShadowRootsIn(element)) { - adoptShadowRoot(router, shadow); - } + dispatchAddedNode(router, added); } } diff --git a/extension/src/rules/countdown-timer-redact.ts b/extension/src/rules/countdown-timer-redact.ts index 9fdd474..4027e86 100644 --- a/extension/src/rules/countdown-timer-redact.ts +++ b/extension/src/rules/countdown-timer-redact.ts @@ -169,10 +169,9 @@ const watcher = createSubtreeWatcher({ const candidates: Candidate[] = []; for (const root of roots) { for (const candidate of findCandidates(root)) { - if (trackedElements.has(candidate.element)) { - continue; + if (!trackedElements.has(candidate.element)) { + candidates.push(candidate); } - candidates.push(candidate); } } scheduleReconcile(candidates); diff --git a/extension/src/rules/disguised-ad-flag.ts b/extension/src/rules/disguised-ad-flag.ts index 1599838..58de3a8 100644 --- a/extension/src/rules/disguised-ad-flag.ts +++ b/extension/src/rules/disguised-ad-flag.ts @@ -300,13 +300,9 @@ function isArticleShaped(element: Element, labelElement: Element): boolean { if (child.querySelector(HEADING_SELECTOR) !== null) { continue; } - let hasNestedCandidate = false; - for (const nested of child.querySelectorAll("p, span, div, li")) { - if (candidateSet.has(nested)) { - hasNestedCandidate = true; - break; - } - } + const hasNestedCandidate = [ + ...child.querySelectorAll("p, span, div, li"), + ].some((nested) => candidateSet.has(nested)); if (hasNestedCandidate) { continue; } diff --git a/extension/src/rules/encoded-payload-redact.ts b/extension/src/rules/encoded-payload-redact.ts index 677d844..1ddad29 100644 --- a/extension/src/rules/encoded-payload-redact.ts +++ b/extension/src/rules/encoded-payload-redact.ts @@ -721,21 +721,20 @@ function collectSubstitutionCiphers( ) { continue; } - for (const decoder of SUBSTITUTION_DECODERS) { - if ( + const decodes = SUBSTITUTION_DECODERS.some( + (decoder) => tryCipherDecode( candidate, decoder, SUB_RULES.substitutionCipher.minCommonWords, - ) !== null - ) { - matches.push({ - start: m.index, - end: m.index + candidate.length, - label: "[encoded payload hidden]", - }); - break; - } + ) !== null, + ); + if (decodes) { + matches.push({ + start: m.index, + end: m.index + candidate.length, + label: "[encoded payload hidden]", + }); } } } diff --git a/extension/src/rules/schema-trust-sanitize.ts b/extension/src/rules/schema-trust-sanitize.ts index b321b3e..b13968d 100644 --- a/extension/src/rules/schema-trust-sanitize.ts +++ b/extension/src/rules/schema-trust-sanitize.ts @@ -177,6 +177,20 @@ function microdataTypeNames(itemtype: string): string[] { // Find the descendant `[itemprop=NAME]` that belongs to this item's // scope — i.e. is not nested inside a deeper `[itemscope]` first. The // microdata spec scopes itemprops to their nearest ancestor itemscope. +// Nearest ancestor with an `[itemscope]` attribute, or null if none. +function nearestItemscope(element: Element): Element | null { + for ( + let parent = element.parentElement; + parent !== null; + parent = parent.parentElement + ) { + if (parent.hasAttribute("itemscope")) { + return parent; + } + } + return null; +} + function scopedDescendants(scope: Element, itempropName: string): Element[] { const out: Element[] = []; // Caller is the rule's internal code path; itempropName is one of a @@ -190,18 +204,9 @@ function scopedDescendants(scope: Element, itempropName: string): Element[] { if (candidate === scope) { continue; } - // Walk up to find the nearest itemscope ancestor; if it isn't us, - // the itemprop belongs to a nested scope. - let parent = candidate.parentElement; - let nearestScope: Element | null = null; - while (parent !== null) { - if (parent.hasAttribute("itemscope")) { - nearestScope = parent; - break; - } - parent = parent.parentElement; - } - if (nearestScope === scope) { + // The itemprop belongs to us only if our scope is its nearest + // itemscope ancestor; otherwise it's claimed by a deeper scope. + if (nearestItemscope(candidate) === scope) { out.push(candidate); } }