Analyzes all official BetterDiscord addons (plugins + themes from the store) to answer questions like:
- API usage counts — how much is each
BdApimember used, so the impact of changes, deprecations, and removals in BetterDiscord core can be measured before pruning legacy code. - Remote URL inventory — which hosts addons actually reach, to eventually form a tight CSP.
- Security signals —
innerHTML/outerHTMLassignment,insertAdjacentHTML, ReactdangerouslySetInnerHTMLprops,eval,Functionconstructor,new Worker, hand-rolled<script>elements.
The output is aggregate data for maintainer decision-making, not a linter for addon authors.
bun run analyze # download/refresh addon cache if stale, run all analyses, write results/
bun run clear # delete .cache (forces re-download of all addons on next run)
bunx tsc --noEmit && bunx eslint src scripts # the verification pass; keep both green
bun run scripts/surface.ts ../BetterDiscord # regenerate the BdApi surface manifest (manual, needs a BD checkout)- Addons are cached in
.cache/addons/<author>/<name>.plugin.js|.theme.css(~200 plugins, ~150 themes), refreshed from the store API when the cache's ISO week (Mondays, UTC) is older than the current one — the same boundary CI'sactions/cachepin uses, deliberately not a rolling 7-day window (weekStartinsrc/cache.ts; see Trend history for why). - A dead source URL is recorded, never fatal (2026-08-10). The case that prompted this: an author's GitHub account was suspended, every one of their raw URLs 404'd,
downloadAddonthrew, and the CI run died before analysis, report, or Pages deploy — one addon costing the week's data. Failures are now caught per addon and recorded in.cache/meta.json'sfailuresarray ({addon, url, reason, kept, permanent}), read back throughreadDownloadFailures();src/cache.tsowns what "incomplete corpus" means, the same loader-owns-its-judgements rulesurface.ts/storemeta.tsfollow.keptis the load-bearing distinction.kept: truemeans a copy from an earlier run is still on disk, so the addon stays in the corpus with possibly stale content;kept: falseis a real hole in the denominator. Only the second counts against the tolerance, feedsSnapshot.missingAddons, or claims the report's "absent from every count below".permanentsplits the tolerance, and real data forced it. A 404/410 (or a listing with no URL) means the source is gone; anything else — timeout, DNS, 5xx, 429, and deliberately 403, which on raw.githubusercontent is rate limiting rather than deletion — means it was unreachable. Two bounds onmissing, both enforced byreportFailureson the refresh and gap-fill paths alike, each failing above the stricter of its pair: gone →min(25, 8%), unreachable →min(5, 2%). Failing over a deleted repository changes nothing (it answers 404 next week too, so a strict bound just means never publishing again), while a burst of timeouts means the run measured a bad ten minutes and a re-run genuinely fixes it. The generous bound still exists to catch a mass-404 event — a CDN serving 404 for everything is a different thing wearing the same status code.- The numbers come from 2026-08-10: 10 addons across 2 authors (TheLazySquid ×8, SyndiShanX ×2) 404'd because both authors deleted their repositories outright —
github.com/TheLazySquid/BetterDiscordPluginsand both SyndiShanX repos are gone, not just the pinned commits. A flatmin(5, 2%)bound would have failed that week's CI run; 25 leaves headroom for another author to vanish before a human is forced to look.
- The numbers come from 2026-08-10: 10 addons across 2 authors (TheLazySquid ×8, SyndiShanX ×2) 404'd because both authors deleted their repositories outright —
meta.jsonis written before the tolerance throw on purpose, so a local rerun gap-fills instead of re-fetching 323 files; CI never takes that path (actions/cachedoes not save on a failed job).- Retry policy is explicit (
withRetry, with ky's ownretrydisabled so it cannot silently re-issue a 4xx): 3 attempts with exponential backoff for transport faults (timeouts, DNS, socket resets, 408/429/5xx), immediate failure for every other 4xx. A suspended account answers 404 just as fast the second time, so retrying it only delays recording the gap that the next run picks up. An empty 200 body is treated as a failure — writing it would quietly erase a good cached copy. - Gap-fill (
fillGaps): a corpus is fresh for the whole ISO week, so a Monday failure would otherwise stay missing until the next Monday. Any re-run within the week retries exactly thekept: falseentries and nothing else touches the network. It deliberately leaveslastUpdatedalone — bumping it moves the data week and mints a second history snapshot for one recovered addon, the same trap raw-date keying fell into (see Trend history). - The store list itself still fails the run, and harder than before: an empty or non-array response, or one under half the cached list's length, aborts before
addons.jsonis overwritten. Once a truncated payload has landed it is indistinguishable from the store actually losing addons, and it feeds committed trend data. - Three surfaces, because a silent skip is the failure mode this exists to prevent: GitHub Actions
::warning::/::error::annotations per failure (visible on the run summary instead of buried in a 323-line download log; theerrorlevel annotates without failing the job),Snapshot.missingAddons(so a future dip traces to a data outage instead of reading as an ecosystem change — the same defensive rolemethodologyplays, and it participates inwriteSnapshot's no-op guard), andgapNote()inreport.ts, an uncoloured callout above the KPI tiles naming each addon and reason. It keeps four cases apart, because they call for different actions: absent from the corpus; analyzed from the last good copy of a source that no longer exists (a store listing to fix — it will never refresh on its own); analyzed from an earlier copy after a transient failure; and a line noting when the previous snapshot had gaps, since every Δ compares against it. - CI restores the previous week's corpus (
restore-keys: addon-corpus-, 2026-08-11) instead of starting empty. The week key still misses, so the run refreshes everything as before; the only difference is what a dead source URL costs — with last week's file present the addon degrades to stale content, without it the addon disappears from the corpus. Those 10 dead sources werekeptlocally for exactly this reason and would have been a 10-addon hole in CI. pruneOrphansis the price of that, and it is required rather than tidy: nothing else ever deletes a corpus file, so a delisted addon restored from last week's cache would be analyzed forever (before the restore-keys change, CI's from-scratch download made it a local-only annoyance —Tropical/Slate.theme.css, 323 listed vs 324 on disk). It runs only fromrefresh(), only afterfetchStoreList()has vetted the response — that gating is the entire safety story, since a truncated store payload reaching it is a mass delete. Files that merely failed to download are still listed, so they are never orphans. It also removes an author directory left empty, becauseanalyze()keys results by directory and an empty one would become an author with no addons and inflate the author count.
- Results land in
results/addons.json(per addon),results/authors.json(per author),results/summary.json(global). All are uncommitted snapshots — regenerating them is expected after analysis changes. Trend snapshots live separately in the top-levelhistory/folder, which is committed — see Trend history below. - Theme remote CSS is cached under
.cache/imports/<host>/<path>.cssbysrc/importcache.ts(updateImports(), run in the pipeline right after the addon download). The first run against a fresh corpus fetches every theme's transitive@importgraph over the network (a couple of minutes); every run after that is offline because the fetch is gated on the addon cache'sdataDate. The.cache/importsfolder lives under.cache, so CI's weeklyactions/cachepin already covers it.
Two layers:
- Pipeline —
src/index.ts→src/cache.ts(store download) →src/analyze.ts(runs everyAnalysisinsrc/analyses/per addon, then aggregates per-addon → per-author → summary). AnAnalysisreturns aResultsvalue (Record<string, number>|number|boolean|string[]); aggregation merges by summing numbers/record values and concatenating arrays (mergeResultsinanalyze.ts). - AST engine —
src/ast/: meriyah parse → alias collection (aliases.ts) → single walk runningRules (meriyah.ts, rules insrc/ast/rules/). Rules produce structuredFindings.src/analyses/ast.tsbridges the two layers: one memoizedanalyzeAddoncall per addon, fanned out into several registry analyses.
To add a check: write a Rule in src/ast/rules/ (register it in rules/index.ts), then expose it as an Analysis in src/analyses/ast.ts (export from src/analyses/index.ts). Rules use match/report (per-node, parent provided), visitText (raw source, used for themes), and finalize (whole-file). report may return one Finding or an array.
Two data inputs sit alongside the corpus, each with a dedicated loader that owns every judgement against it (nothing else reads the JSON directly): src/data/bdapi-surface.json — the checked-in manifest of what BdApi declares, loaded by src/surface.ts (see Prunability below); and src/data/discord-css-variables.json — Discord's own current CSS custom-property names, loaded by src/discordvars.ts (see CSS variables below).
collectScopeInfo resolves const Api = BdApi, const W = Api.Webpack, destructuring (incl. renames and window.-rooted chains), and constructor instances — const bd = new BdApi("Name") aliases bd to BdApi, a pattern ~a third of store plugins use — so the bdapi and network-url rules see canonical paths. It is deliberately scope-blind and conservative: any name declared twice, reassigned, or shadowed by a function param/catch/class name anywhere in the file is dropped entirely. Minified code mostly loses its aliases — that's intended; undercounting beats miscounting. Don't "fix" this by making it less conservative without real scope tracking (that's the parked evaluator's job, see below). collectAliases remains as a thin wrapper returning just the map.
It also exposes the two raw binding sets it computes, on RuleContext as declared (bound by any declarator/assignment) and shadowed (bound by a function param, catch clause, or function/class name). Rules keyed on names that can plausibly be local — unlike BdApi, which never is — need them, and the right set differs per rule:
globalsskips a root that is declared or shadowed: a file withconst global = {}orfunction f(process)anywhere makes every mention of that root untrustworthy, so the whole file's hits for it are dropped.react-hazardsskips only shadowed roots. A local declaration is exactly how a real ReactDOM arrives (const ReactDOM = BdApi.Webpack.getModule(…)), so treating declarations as disqualifying there would drop the main case the rule exists for.
- Bun silently deletes statements starting with
declare. A call likedeclare(x, y);is stripped by Bun's transpiler as a TS ambient declaration whiletscparses it as a call — typecheck stays green, code just doesn't run. Don't name functions/callbacksdeclare(or other TS keywords liketype/namespace) if they'll be called in statement position. This actually happened here; seeregisterinsrc/ast/aliases.ts. - meriyah types
CallExpression.calleeasany. Always go throughcalleeOf()insrc/ast/helpers.ts; direct.calleeaccess trips the type-aware eslint rules and loses safety. - ESTree, not swc/babel shapes. String literals are
Literalnodes (there is no"StringLiteral"type), identifiers have.name(not.value). An earlier swc-based generation of this code was removed for exactly this confusion; see git history around commit288616dif archaeology is needed. fs.existsis a Bun-only extension rejected by thefs/promisestypings — use the localexists()helper insrc/cache.ts.- Never key a rule's lookup table on a plain object literal. Plugins define
toString(), and"toString" in MEMBERSis true throughObject.prototype, so an object-literal table hands backFunction.prototype.toStringas the "status" — which then gets string-concatenated into the summary bymergeResults. This shipped intoresults/summary.jsonbefore being caught;lifecycle.tsuses aMap. Anything matching arbitrary source-derived names (member names, field names, module names) needs aMaporObject.hasOwn. typescriptis pinned to 5.7.3 deliberately.scripts/surface.tsneeds the classic compiler API (ts.createSourceFile), andbunx tsc --noEmit— the verification gate — has been green on 5.7.x. Plainbun add -d typescriptresolves to 7.x, the Go port, which exposes nocreateSourceFileat all and silently swaps the compiler behind the gate. Bump it deliberately or not at all.grepmay be aliased tougrepin this shell, whose BRE alternation (\|) behaves differently from GNU grep and can return bogus matches. Prefergrep -Ffor fixed strings, or the dedicated search tools.- The deprecated-API list in
src/ast/rules/bdapi.tsis old-old legacy aliases kept as a sanity check — the store corpus is expected to report zero of them (deprecated-apis: {}in summary.json). Nonzero means either the store regressed or a rule broke.
-
Theme URL extraction lives in the
css-urlvisitTextrule (src/ast/rules/cssurls.ts, aggregated ascss-urlskeyed by hostname). It applies to plugins too, catchingurl()refs inside embedded CSS strings that the ASTremote-urlrule can't see. -
Remote theme CSS is first-class content (handoff-05, done 2026-07-19). Most themes are a thin
@importwrapper whose real CSS lives on*.github.io; before this, every content rule measured the wrapper, not the theme (only 1 of 115 themes even contained an@mediaquery). Nowsrc/importcache.tsresolves each theme's transitive@importgraph (per-theme visited set for cycle safety,MAX_DEPTHcap, discord.com-hosted results dropped as regex/prose noise), fetches every remote file once per run (memoised across themes — the shared BDFDB/ClearVision bases are pulled by dozens), and caches each under.cache/imports/<host>/<path>.css.analyze.tsloads a theme's concatenated remote CSS intoCachedAddon.remote_content;getFindingsinanalyses/ast.tsre-runs the CSS-content rules only (REMOTE_CONTENT_RULES=css-url,class-literals,css-variables— deliberately notmeta, which would reportno-meta-blockon every import) over it, attributed to the importing theme. Attribution is per-theme: content shared by N themes counts N times — fragility is per-theme, which is what the rankings ask; it is stated in the report caption. The oldsrc/analyses/imports.tsno longer fetches — it reads the cached graph and returns the flat (now transitive) URL list, preserving the committedimportshistory series. Landing this jumpedclass-literals/css-urlstotals hard (a measurement change, not an ecosystem one), which the trend machinery annotates (see the methodology note under Trend history). -
src/evaluator/is a constant-folding partial evaluator:core.tsfolds expressions (literals, templates, concat, member access on known objects/arrays, a few pure string methods),interpret.tswalks a program in source order with real nested scopes (shadowing and reassignment behave correctly; source order only approximates execution order — it is not a real interpreter), andstrings.tsholdspartialString()(best-effort string where unresolvable segments degrade to theDYNAMIC_SEGMENT${…}placeholder) plusDYNAMIC_SEGMENTitself — both used to live in the url rules and moved here onceself-updatebecame a second consumer. Its consumers are thenetwork-urlrule (src/ast/rules/networkurls.ts), which evaluates the URL argument at network sinks (fetch,BdApi.Net.fetch,new WebSocket/EventSource, XHR.open,window.open,navigator.sendBeacon) with unresolvable segments degrading to${…}placeholders as long as the host stays static, and thewebpack-targets/patcher-targetsrules (src/ast/rules/webpacktargets.ts). Roughly half of corpusfetchsites resolve — the rest take runtime values through function params, which is correct conservatism, not a bug to fix. -
webpack-targets+patcher-targets(src/ast/rules/webpacktargets.ts, "Discord internals reliance" report card) inventory what the ecosystem pulls out of Discord: webpack lookup arguments (getByKeys/getStore/Filters.by*etc., keyed${kind}:${value}so keys/strings/protoKeys/stores/displayNames share one record) andBdApi.Patcher.before|instead|aftertargets (keyed by method name, patch type in details). Both useinterpretProgramso const arrays and aliases resolve;getModule/getMangled/getWithKeyare deliberately not parsed — their nestedFilters.*calls are separate CallExpressions counted on their own. Unresolvable args collapse to(dynamic). The Patcher method is read asarguments[length - 2](the arg before the trailing callback), not a fixed index: the staticBdApi.Patcher.after(caller, module, method, cb)puts it at index 2, but anew BdApi(name)instance bakes the caller in, giving(module, method, cb)with method at index 1 — the alias tracker collapses both toBdApi.Patcher, so position-relative-to-callback is the only signature-agnostic locator. Undercounts BDFDB/ZLibrary plugins (they route lookups through the library, see handoff-00 known limitations). Sanity magnitudes on the 2026-07 corpus:getModule234 call sites across 137 plugins usingBdApi.Webpack; 230 patcher calls, ~55(dynamic)(genuinely runtime — mangledgetMangledkeys), the rest real methods led byrender/type. -
Environment & fragility (handoff-02, "Environment coupling" + "Hardcoded Discord class names" cards):
globals(src/ast/rules/globals.ts) inventories direct reach into the bridged environment — rootsprocess,Buffer,__dirname,__filename,global,DiscordNative— keyed as root plus one segment (process.env); deeper is noise. Companion torequiresfor the polyfill-retirement story, hence the shared report card. Sanity magnitudes on the 2026-07 corpus:DiscordNative.clipboardin 15 plugins, everything else in ≤4.react-hazards(src/ast/rules/reacthazards.ts) counts React-upgrade breakage risk. It matches on the owner segment (resolved[len-2] === "ReactDOM"), not a fixed chain, so bareReactDOM.render,BdApi.ReactDOM.render, and library-heldInternal.LibraryModules.ReactDOM.render(BDFDB) all count — they are all the real ReactDOM. Bare identifiers count only at call sites, which picks upconst {createRoot} = ReactDOM; createRoot(el)(4 of 12 corpuscreateRootfiles) without double-counting the binding site.createRootis tracked as the healthy signal (ADOPTION), soisHazardMethod()is what splits the report table. 2026-07: 10 createRoot, 9 getInternalInstance, and render/findDOMNode/unmountComponentAtNode only inside 0BDFDB.class-literals(src/ast/rules/classliterals.ts) counts hardcoded hashed Discord classes per addon (anumber, not a record). Plugins go through the AST branch (string literals + template quasis only); themes go throughvisitTexton raw CSS. Never scan plugin source as text — bundler identifiers like__nested_webpack_require_1306889__match otherwise.
-
The hashed-class regex is tuned on evidence; re-verify before touching it. Discord ships two styles and one element carries both (
class="name__2ea32 overflow_b0dfc2"):wrapper_a1b2c3(single_, 6 hex) andname__2ea32(double_, 5-6 hex). On the 2026-07 corpus the double-underscore style is over half of all 2,139 tokens, so a single-underscore-only pattern misses most of the signal. Each constraint earns its place: lowercase-first drops URL paths (/wiki/HD_175167); hex-only suffix avoids ~400 snake_case false positives (utm_source,node_modules); 7-hex matches nothing and single-_+5-hex never occurs; boundary guards stop matches inside longer identifiers. All-letter hashes (_eaaeee,_fedacc) are real classes, so there is deliberately no "must contain a digit" rule.Those two styles are the whole matchable surface — don't widen the pattern for the other formats, all three are settled (maintainer-confirmed, 2026-07):
f4758a8d6346d18b-nonVisualMediaItemContainer(full 16-hex hash + dash) is not a live Discord convention. It survives only inKnew/EmbedMoreImages, as a deadclassList.contains(…)branch sitting next to that same plugin's modern.nonVisualMediaItemContainer_f4758aselector — samef4758aprefix, just the long hash. A stale fallback the author never deleted, not a signal.- Plain unhashed classes (
popout-portal) are exceedingly rare in Discord and indistinguishable from any ordinary CSS class, so matching them means matching everything. name-snowflake(message-content-1526760637869461565) carries the snowflake of the underlying object, so it can't realistically be hardcoded and isn't a fragility signal.
-
Fragility is concentrated, and the ranking is by occurrences. These magnitudes are pre-remote-CSS (they measured only wrapper files): the wrapper-era headline was 16 addons / ~1,700-1,900 tokens for the top ShadowDevilsAvenged themes. With remote content analysed (2026-07-19) the picture is far larger and the ranking changes — 102 addons hardcode classes at all, led by import-only themes whose real CSS was previously invisible (
vozy/surCord62,812,Theo/roundmoledV262,327,DevilBro/EmojiReplace61,393).KingGamingYT/ActivityFeed, which vendors Discord's whole class-name map ("newspaperIcon": "newspaperIcon__97b5e") into its source, is still there but no longer near the top. -
CSS variables (handoff-05 §B, done 2026-07-19):
css-variables(src/ast/rules/cssvariables.ts) is avisitTextrule for both addon types (themes are CSS; plugins embed it). It blanks comments and quoted strings length-preservingly first (so/* --x: */andcontent: "var(--x)"are must-not-matches), then emits two finding kinds via two regexes —{kind: "definition", name, overlap}(a--name:in declaration position) and{kind: "consumption", name}(avar(--name; nestedvar(--x, var(--y))counts both). Four analyses inanalyses/ast.ts, four keys because the report wants differently-ranked tables:css-var-usage,css-var-definitions,css-var-overlap,css-var-outdated. Classification is against the Discord manifest, not inference:src/discordvars.tsloadssrc/data/discord-css-variables.json({source, variables, deprecated}— the loader also tolerates a bare array; 4,945 current names + 44 deprecated names, maintainer-provided) and a definition isoverlapiff its name is invariables; a definition or consumption isoutdatediff its name is indeprecated. The two sets are disjoint. This is the healthy counterpart toclass-literals— a theme on Discord's variables survives class churn — and it shares the fragility report card. 2026-07 corpus: 158 addons consume ≥1 live Discord variable (only 45 by wrappers alone — the rest is remote CSS), 89 themes reskin one (1,463 distinct overlap names, led by--text-link/--text-muted/--background-accent), 6,676 distinct names defined overall (mostly own palettes).variablesis Discord's current names;deprecatedis its former names (maintainer-confirmed). The classic semantic layer the ecosystem leans on —--background-primary,--text-normal,--header-primary,--interactive-normal— is absent fromvariablesbecause Discord renamed/removed it (--text-normal→--text-default), sooverlapcorrectly measures only live reskins. Those former names populatedeprecated(44 of them: the--background-*/--text-*/--header-*/--interactive-*semantic layer, the old--brand-experiment-*scale, the literally-named--deprecated-*, and--info-*-foreground), which drivescss-var-outdatedviaisOutdatedVariable— a definition or consumption of a removed name, the CSS analog of a stale hardcoded class (it resolves to nothing now). Thedeprecatedlist was drafted from the classic semantic layer and filtered against the corpus (every entry is used by a theme and absent fromvariables), then maintainer-confirmed — not inferred at analysis time (consumed-but-never-defined is too noisy: it sweeps in theme-local vars whose definition lives in an unfetched remote file). 2026-07 corpus: 112 addons still touch ≥1 removed variable, led by--header-primary/--interactive-normal/--text-normal. To retire or extend the signal, edit thedeprecatedarray — never a guessed diff.
-
Substring selectors (handoff-08 C, done 2026-07-29):
substring-selectors(src/ast/rules/substringselectors.ts) counts[class*=/[class^=attribute-substring class selectors — the churn-resilient counterpart ofclass-literals(they match the stable name prefix and ignore the hash). Themes viavisitText(inREMOTE_CONTENT_RULES, so remote@importCSS counts per importing theme, standard shared-CSS caveat); plugins via the AST string branch (string literals + template quasis — embeddedaddStyleCSS andquerySelector/matchesargs are the same signal; never raw JS text, per handoff-02). Matching runs on blanked text (the now-exportedblankNonCodefromcssvariables.ts, so CSS comments andcontent:strings are must-not-matches) with the selector value read back from the original at the same offset — the value is itself often quoted, so wholesale blanking would eat it. Only*=/^=count (maintainer, 2026-07): the corpus's other class-attribute operators ($=339,~=106,|=12) are overwhelmingly code-block language matching ([class$="python" i]against highlight.js classes), not churn resilience — and$=can pin the hash itself. No whitespace allowed around the operator, matching thegrep -rlE '\[class[*^]='ground truth this reconciles against. Per-addon occurrence count (substring-selectors); rendered as the resilient-counterpart caption on the Hardcoded classes card plus theresilientSelectorAddonsKPI — the one "up is good" KPI, which is whydeltaLineinreport.tstakes a direction ("down" | "up" | "none") instead of the old lower-is-better boolean. 2026-07 corpus: 89 addons (63 themes / 26 plugins), 3,462 selectors; reconciles exactly — plugin file set equals the 26-file grep set, all 12 wrapper themes report, six themes hand-checked to the occurrence against wrapper+remote content (blanking correctly drops e.g.Izy/Discord Reborn's 8 comment/string hits from its 207 raw). -
Meta health (handoff-03, "Meta health" card):
meta(src/ast/rules/meta.ts) is avisitTextrule for both addon types — themes are CSS, but the meta block and BD's parser are identical. It emits{kind: "field"}findings (→meta-fields, presence per addon) and{kind: "problem"}findings (→meta-problems, keyed<problem>:<field>).- Ground truth is BD itself, not the docs alone. The rule contains a verbatim port of BD's
parseJsDoc(BetterDiscord/src/common/utils/jsdoc.ts) so it sees exactly what BD sees; the required-field list comes from the docs table (name/author/description/version), the block requirement fromaddonmanager.ts(~line 190), and the field list fromtypes/addon/index.ts. Keep the port in sync if BD's parser changes — a drifting copy silently reports the wrong thing. - Docs vs runtime: BD only hard-fails on a missing block (
/**must be on the first line, after BOM strip); it silently falls back for author/version/description (Unknown Author/???/No description). Maintainer's call (2026-07): report the docs' four as required anyway — a???version in the UI is still a defect. - Two parser quirks are load-bearing (both encoded in the synthetic suite). A valueless field never reaches BD under its own name: mid-block
@invitehas no space, sosubstring(1, -1)collapses the name to a literal"@"; as the last line before*/the name instead keeps a trailing newline ("invite\n"). Both are reported asvalueless-fieldrather than invented as fields. - Only fields BD consumes are validated. Unknown fields are author/library conventions BD ignores (DevilBro's
@vartheme settings,@changelog,@colorwayVar) — validating them turned 8 conventions into fake "duplicate" defects. They still appear in the coverage table, marked non-standard.@inviteis deliberately not URL-validated (it's a bare invite code) and neither is@authorId(a snowflake). - 2026-07 corpus: the store is healthy — 322/323 carry all four required fields, and 14 problems across 10 addons (all verified real:
@version Auto Update,https//github.commissing its colon,your-repo-hereleft in). Four of the 14 aresquare/EzLight.theme.css, still on the ancient//META{…}format: its first line ends in*//**/, a degenerate empty/**…*/block, so the parser finds a block with no fields and reports all four required fields missing (notno-meta-block) — matching BD, which loads it with every fallback ("Unknown Author"/???). Notably@updateUrlis on 90 addons (28%) despite BD core not parsing it at all — a de-facto convention owned by updater libraries.
- Ground truth is BD itself, not the docs alone. The rule contains a verbatim port of BD's
-
Self-installing plugins (handoff-03, in the Security card):
self-update(src/ast/rules/selfupdate.ts) finds writes of a.plugin.jspath; theself-updatinganalysis ANDs that with a fetch of a.plugin.jsURL, reusing thenetwork-url/remote-urlfindings rather than re-walking. Both signals are required — that is what makes it precise: on the 2026-07 corpus 28 plugins fetch such a URL with no write path at all (it's just their@sourcelink), and they are correctly excluded. Result: 48 = every DevilBro addon, each shipping the BDFDB downloader (fetch0BDFDB.plugin.js→ write intoBdApi.Plugins.folder), which is a library bootstrap rather than literal self-update. Ground truth: 49 files pairwriteFilewith a.plugin.jsmention; the 49th isDastan/FavoriteMedia, correctly rejected (its.plugin.jsis only@sourcemeta; it writesmediaPath).- The rule matches on the method name (
writeFile/writeFileSync), deliberately not pinning the receiver tofs: the corpus shape isrequire("fs").writeFile(…), whose callee is rooted in a call, somemberChainreturns null. That alone is far too loose, which is why the path argument must independently point at a plugin file — viapartialString(literals/templates/concat/consts) or a subtree scan for.plugin.jsstrings andBdApi.Plugins.folderchains, sincepath.join(…)is a CallExpression the evaluator cannot fold.
- The rule matches on the method name (
-
Prunability (handoff-04, "Removal shortlist" + "Plugin shape" cards): the one pair of checks that answers what can BD delete, rather than what addons use.
- The manifest is the second input.
src/data/bdapi-surface.jsonrecords BdApi's declared surface (18 namespaces, 147 members on BD v1.13.14), generated manually byscripts/surface.ts <bd-path>from a BD checkout and checked in. The analyzer only ever reads the manifest — CI has no BD checkout, and a stale manifest is fine where a broken build is not. Regenerate it when BD's API moves;deprecated-apisand the shortlist are only as current as it is. - Key on exposed names, not classes or files.
AddonAPIis one class exposed twice, asBdApi.PluginsandBdApi.Themes; a file-keyed reading madeaddonapi.tslook 100% unused when every member of it is used. The generator reads exposure fromapi/index.ts'sstaticproperties onclass BdApi, resolvingstatic Plugins = PluginAPI→const PluginAPI = new AddonAPI(...)→ the class. - Three generator constraints, each load-bearing. (1) Follow
extends—class DOM extends BaseDOMdeclares onlyaddStyle/removeStyleitself, so a base-blind walk would reportonAdded/animate/createElement/parseHTMLas never-declared and then as unused. (2) Read both halves of BD's static/getter pairs —static Patcher: Patcheris whatBdApi.Patchergives andget Patcher(): BoundPatcheris whatnew BdApi("x").Patchergives; the alias tracker collapses both onto one chain, so the manifest carries the union. (3) Only the classesindex.tsactually exposes —contextmenu.tsalso containsMenuPatcherand several interfaces, whoseactivate/blur/handleRender/initializeare internals that a whole-file scan mistakes for public API. @ignore(BD's own "not for addons" marker, on every constructor) excludes a member;@deprecatedfeeds thedeprecatedarray, which is wherebdapi.ts'sDEPRECATED_CURRENTnow comes from instead of a hardcoded copy. The old-oldDEPRECATEDlist stays hardcoded on purpose — see the sanity-check note above.- Unused-ness is a corpus fact, not an addon fact, and the per-addon
Analysisshape cannot express it (Record<member, 0>would aggregate into nonsense). It is computed inreport.ts'sassemble()from manifest + summary viaunusedPaths(). Onlyphantom-apisis a real analysis.phantom-apisis classified insrc/analyses/ast.tsrather than in a rule, becausefinalizehas no access to other rules' findings — it reads the already alias-resolvedbdapi-usagefindings, the same reuseself-updatingmakes of the url rules. - 2026-07 corpus: 27 of 150 declared paths uncalled (led by
Webpackat 6:getByRegex,getAllByRegex,getAllBySource,getAllByPrototypeKeys,getProxy,getMangledProxy), andBdApi.versionused by nothing. Eight of the 27 were hand-checked as zero bare-text hits across all 208 plugins. Phantoms are exactly four —BdApi.settings,isSettingEnabled,enableSetting,disableSetting— all inDevilBro/0BDFDBalone and all behind feature-detection guards (typeof BdApi.enableSetting == "function"; an is-array check forsettings). That is dead-but-safe code: report it that way, because "48 plugins call a removed API" would be flatly wrong. - The shortlist's blind spots are named in the caption, not hidden: it is the store corpus only (private plugins are invisible), BDFDB/ZLibrary route calls through the library, and a namespace-level dynamic chain (
BdApi.Patcher.*) credits no member of that namespace.opaqueChainsinreport.tscomputes that last set rather than implying it is empty. lifecycle(src/ast/rules/lifecycle.ts) counts the v1 plugin shape at definition sites:MethodDefinition(kind === "method") andProperty(kind === "init"with a function value). Only mark the get-family deprecated (maintainer, 2026-07):observer/onSwitch/loadare removal candidates, and printing them as deprecated publishes a deprecation BD never made. The headline isobserver(6 plugins): core runs a document-wideMutationObserverand dispatches every mutation to every loaded plugin to serve those 6.- Every tracked name must be one core actually dispatches (maintainer, 2026-07). Two revisions to the handoff's probe table:
unloadis not tracked at all — it appears nowhere inpluginmanager.ts, so it was never a lifecycle member despite ~9 plugins defining it by symmetry withload; andloadis a candidate, not current — it is dispatched (pluginmanager.ts:143) but is redundant now that plugin code runs at require/eval time and in the constructor. The dispatch sites are the ground truth: get-family:127-130,load:143,start/stop:174/:203,onSwitch:225-238,observer:45+:250. 2026-07 counts: getName 55, getAuthor 52, getVersion/getDescription 51, getSettingsPanel 136, start 192, stop 189, onSwitch 9, load 70, observer 6. observeris a false-positive magnet and two shapes prove it. Both are must-not-match cases in the synthetic suite.this.observer = new MutationObserver(...)is an assignment, excluded by the node shape. The one that actually bit:get observer() {return this._observer ??= new MutationObserver(...)}inside an object literal is aPropertywhose value is aFunctionExpression— so thekind === "init"check is what excludes it, and without itprogrammer2514/CollapsibleUI(its own private observer,runtime.observer.observe(...)) counts. The handoff's probe reportedobserver: 7for exactly this reason; the true number is 6. Use the AST, never text:grep -l observerfinds 28 files and 20 construct aMutationObserver.
- The manifest is the second input.
-
Trend history (handoff-03):
src/history.ts.writeSnapshot()runs in the pipeline betweenanalyze()andgenerateReport(), writinghistory/<dataDate>.jsonkeyed by the ISO-week Monday of.cache/meta.jsonlastUpdated(weekStartincache.ts) — not the run date, and not the raw download date either: the series cadence is weekly, so any run within the same data week overwrites that week's snapshot instead of fabricating a data point. (The raw-date keying this started with minted a duplicate on 2026-07-21: local staleness was a rolling 7-day window, so a Tuesday local run re-downloaded the corpus and stamped an off-cadence date beside CI's Monday snapshot.) The top-levelhistory/folder is committed (maintainer's call, 2026-07) and deliberately lives outsideresults/: snapshots are irreplaceable accumulated data (the store API has no time machine), unlike the regenerable artifacts inresults/, and keeping it top-level avoids the fragileresults/*+!results/history/gitignore carve-out this originally used.- A snapshot stores
summaryplus akpisblock, because the interesting numbers are per-addon counts ("how many plugins userequire") thatsummary.jsoncannot express — it sums call counts.deriveKpis()is the single definition, used both to write snapshots and to compute the report's current tiles, so a tile and its delta cannot disagree. previous= newest snapshot with a strictly older dataDate (comparing same-date would always show zero). The report must render with and without history — both paths are tested; with none, tiles fall back to their static captions.- Delta colouring: green only where the direction is good (fewer requires = the campaign working); corpus growth is neutral ink. Nothing renders red. The
deprecatedtable the handoff mentions does not exist (deprecated-apisis{}), so the Δ column went on the BdApi usage table instead, which serves the same "measure the blast radius" purpose. Per-KPI tile sparklines exist but render only once ≥3 snapshots with distinct dataDates are on disk (and never for an all-zero series); until then tiles show deltas/captions only. They were verified against temporary fake snapshots — if you need to test them, do the same and delete the fakes; never commit fabricated history. - Methodology annotation (handoff-05): a snapshot carries a
methodologyversion (METHODOLOGYinhistory.ts; absent ⇒ 1). It is bumped when a measurement change discontinuously shifts a KPI, so the report can neutralise a delta that would otherwise read as a regression. History 2 (2026-07-19) = remote CSS analysed as first-class, which jumps theclass-literals-basedfragileAddons. Whenprevious.methodology !== METHODOLOGY, the report suppresses only the methodology-sensitive tile's delta and sparkline (METHODOLOGY_SENSITIVE, currently justfragileAddons) and shows a neutral caption; every other KPI compares normally. The break path is verified with a temporary older methodology-1 snapshot (delete it after). Note this is defensive: the first History-2 run overwrote the only committed snapshot with History-2 numbers, so the baseline is self-consistent and no live comparison currently straddles the boundary — but a preserved older snapshot would.
- A snapshot stores
-
API bypass (handoff-06, done 2026-07-20): three rules for work that routes around
BdApiand is therefore invisible to every other rule.library-deps(src/ast/rules/librarydeps.ts, "Discord internals reliance" caption) finally sizes the library-indirection blind spot handoff-00 lists as a known limitation. A per-node rule matching an outermost member read whose canonical root is a library global —BDFDB/BDFDB_Global→BDFDB, andZeresPluginLibrary/ZLibrary/PluginLibraryall → one canonicalZeresPluginLibrary(the same deprecated library, maintainer 2026-07). The signal is a global read, never a string mention: textgrep -lFreports ZeresPluginLibrary in 10-11 files, but of those only 1 (Farcrada/RightClickJoin,global.ZeresPluginLibrary.PluginUpdater) actually reads the library — the rest are changelog prose ("no longer relies on ZeresPluginLibrary!"), a delete-it warning (Plugins.get("ZeresPluginLibrary"), a string arg), and a source-URL comment. Guards on bothdeclaredandshadowed(a library name can be a localconst Libraryor theBDFDBclosure param the DevilBro wrapper injects — that param is shadowed, so it drops, andwindow.BDFDB_Globalcarries the detection).global/selfare peeled after guarding (stripGlobal only handles window/globalThis). Library self-files are excluded by basename (0BDFDB.plugin.jsetc.) or they count as dependents of themselves. Findings carry{library, signal}where signal isguard(read in a!/if/?:/whiletest — the bootstrap check) vsread(active use); analyseslibrary-deps(presence per library) andlibrary-dep-signals(<library>:<signal>presence). 2026-07 corpus: 48 of 208 plugins route through a library — BDFDB 47 (every DevilBro plugin, all both guard and read), ZeresPluginLibrary 1. Reconciles againstgrep -rlF BDFDB= 49: 47 real dependents +0BDFDB.plugin.js(the library, excluded by name) +programmer2514/MessageScanAI(a bare// BDFDB compatibilitycomment, no global read, correctly excluded). The report cites this number so the internals tables can honestly say they undercount.raw-dom(src/ast/rules/rawdom.ts) tracks two tags through one mechanism — canonical calleedocument.createElement(after window/globalThis stripping), first arg folding viaevalExpr, tag table aMap(source-derived keys, per the footgun above) — that classify differently:raw-style-elementis the Environment coupling card's "Style injection — API vs hand-rolled" block,raw-script-element(handoff-08 A3, 2026-07-29) is a code-loading sink rendered as thecreateElement("script")row in the Security card's dynamic-code table. Rooting ondocumentis load-bearing: it excludes React'sBDFDB.ReactUtils.createElement("style", …), which builds a virtual element, not a DOM node.doc.createElement/context.document.createElement(iframe documents in locals, minified module roots like LaTeX'sn.default.document) are undercounted, per undercount > miscount. TheBdApi.DOM.addStylecounterpart is already counted bybdapi-usage— the report pairs the two from there rather than double-counting. 2026-07: 3 plugins hand-roll<style>vs 73 usingaddStyle— a rare healthy signal (the API is winning), presented as such — and 2 plugins create<script>elements (0BDFDB's library loader, LaTeX's bundled MathJax loader).raw-patchingwas investigated and dropped — the honest signal is unrecoverable without real scope tracking. Prototype-assignment shape (X.prototype.m = …) hits only 4 files (excl. 0BDFDB); hand-opened, 0 patch a Discord webpack module (whatpatcher-targetswould miss), 1 patches DOM built-ins (Element.prototype/HTMLImageElement.prototypeinKnew/UncompressedImages, self-restored), and 3 are bundled-library internals defining their own prototypes (gif.jsEventEmitter/GIF3, lodashListCache/Hash/MapCache).Object.defineProperty(X.prototype, …)is the same 1 DOM-builtin plugin plus minified MathJax/lodash boilerplate. Telling a webpack-obtained module from a file-declared class needs the parked evaluator; a rule here would put a fabricated ~4-mostly-false number on a maintainer-facing card, so no rule ships. Per handoff-06: "we tried; here's why it needs real scope tracking" is a legitimate outcome.
-
Corpus size — the denominator (handoff-07, done 2026-07-20):
size(src/ast/rules/size.ts) is avisitTextrule,appliesTo: "both", so it needs no AST and runs for themes and unparseable plugins alike (visitText fires before the parse gate inanalyzeAddon, so a parse-failed plugin still yields a size). It emits one finding with{bytes, lines, codeLines}. Deliberately not inREMOTE_CONTENT_RULES— it measures the store file on disk, sobytes/linesreconcile exactly againstwc -c/wc -l(verified on 3 addons). Definitions:bytes= UTF-8 byte length viaBuffer.byteLength(text, "utf8"), nottext.length(UTF-16 code units diverge fromwc -con the non-ASCII this corpus carries — emoji author dirs, CJK comments);lines= newline count (a no-trailing-newline file has one fewer newline than visible lines, which is whatwc -lreports);codeLines= non-blank, non-comment-only lines via a cheap approximation (block comments blanked length-preservingly, and for plugins only//line comments dropped —//is not a CSS comment).codeLinesis labelled approximate and is a lie for minified plugins (a bundle is a handful of enormous lines:quantumsoul/LaTeXis 1.3 MB over 239 lines / 140 "code" lines), which is exactly why every line-based ratio is avoided there.- Analysis:
size→Record<"bytes"|"lines"|"codeLines"|"remoteBytes", number>; aggregation sums each key, sosummary.size.bytesis the total corpus disk size (16.5 MB on the 2026-07 corpus) and becomes the report's denominator.remoteBytesis added in the analysis, not the rule (the rule only sees the store file; the analysis hasaddon.remote_content) — it is a theme's concatenated remote@importCSS byte length, per-addon fuel for the fragility density column below. Its summary sum (45 MB) is not meaningful — shared remote CSS is counted once per importing theme, same per-theme attribution caveat asclass-literals. - Ratios are computed in
report.ts'sassemble(), never in the pipeline — summing a ratio across addons is meaningless and the aggregator would happily do it (the same "global fact vs per-addon fact" tension handoff-04 hits withunused-apis; if theResultstype ever grows a non-summing variant, these two are the evidence — but one batch is not enough reason to refactor it). The fragility ranking (fragileTable) now carries a per-KB column beside raw occurrences: raw = total blast radius and the sort key (keeps theKingGamingYT/ActivityFeed628-token story), per-KB =tokens / (analyzedBytes/1000)whereanalyzedBytes = size.bytes + size.remoteBytes— the content the tokens were actually counted over, so import-only themes get an honest density (vozy 🎀/surCord11.6/KB, not 62,812 ÷ its 3.3 KB wrapper) and genuinely-riddled small themes surface (Eight_P/T13,691 tokens but 24.3/KB). It is bytes-based, so it stays honest for minified plugins where line ratios are a lie. - KPI:
corpusBytesinKpis/deriveKpis(= summary.size.bytes), rendered as a humanized "Corpus size" tile in neutral ink (corpus growth is context, not good/bad news — never coloured, per the delta-colouring rule). It is the trend series' denominator from this point forward: a futurerequiresdrop can now be told apart from the store simply losing plugins. LikeunusedApis, it is absent from snapshots written before it existed — the report degrades (tile falls back to its static caption, no delta, no sparkline) rather than crashing; verified with a temporary older snapshot withcorpusBytesstripped (deleted after).
- Analysis:
-
Store metadata — the users denominator (handoff-08 B, done 2026-07-24): downloads/likes/release dates joined to every analyzed addon, so decision tables read in installs, not just addon counts. The handoff's premise was stale: the full store response was already persisted at
.cache/addons.json(isInvalid()reads it for its count check), so there is no cache-format change and noCachedAddon.storefield —src/storemeta.tsis a dedicated loader (owns every judgement, likesurface.ts/discordvars.ts): joins by<sanitized author dir>/<file_name>viaauthorDirName()incache.ts(1:1 for all 323 files, zero collisions, verified), coerceslikesat the boundary (the API sends it as a string whiledownloadsis a number; theAPIAddontypings were corrected to reality — dates are ISO strings, notDate), and degrades to an empty map, which makes every weighted valuenull→ rendered "—", never a zero-download claim. The join happens at report/KPI time only (assemble()+deriveKpis()); the summingResultsshape is untouched (a summed weighted count is as meaningless as a summed ratio — handoff-07's argument verbatim).- Weighted columns on the lifecycle / requires / library-deps tables (maintainer-confirmed scope; the removal-shortlist card has nothing to weight — its members have zero users by definition). Raw counts stay primary and the sort key; every caption says cumulative lifetime downloads, never active users. 2026-07 corpus:
observer6 plugins but only 356K downloads (~0.7% of installed base);require("fs")52 plugins / 16.6M; the BDFDB+ZLib blind spot sharpened into 15.2M of 54.7M (28%) of every copy ever installed. - Staleness (
stalenessBucket()in storemeta.ts, maintainer call 2026-07): active ≤ 6mo, aging 6–24mo, abandoned > 24mo since last store release, measured against the ISO-week Monday of the data (weekStart), never the run date. 2026-07: 187/80/56 addons, 24.8M/19.4M/10.6M downloads. Staleness alone is never a defect — a stable, finished addon looks abandoned — so nothing colors on it; the card's readings are all conjunctions. The "Maintenance state × fragility" card crosses the buckets with the five per-addon fragility signals (class-literals, css-var-outdated, get-family lifecycle, react-hazard methods, requires) and ships two conjunction tables: outreach (active ∧ deprecated surface: 74 addons, led by DevilBro/Translator 1.8M — deliberately the one downloads-sorted table, it ranks author leverage) and silently degraded (abandoned ∧ deprecated surface: 44 addons / 8.4M — removal costs them nothing further). The observer story inverted on real data: 4 of 6 observer-definers are active, 0 abandoned — removal is a coordination problem, not a free win. The card's sentences are computed from the buckets, not hardcoded, so they stay honest as the data moves. Hand-verified per handoff-08: 3 addons against the store website (dates exact; download drift ≈ the week's velocity), all 6 observer buckets, top-10 outdated-var buckets. - KPIs in
deriveKpis— which now takes the store map + dataDate; both callers pass the same values so a tile and its snapshot cannot disagree:corpusDownloads(54.7M, neutral ink; the snapshot series' delta is download velocity, the honest activity proxydownloadsalone can't be — which is why the series starts now),abandonedShare(17.3%, neutral, rounded at derivation so the snapshot no-op guard stays byte-stable), anddeprecatedSurfaceDownloads(38.0M across 164 addons: get-family ∪ outdated CSS vars ∪ old-old aliases, deliberately the same union as the card — the one colored download KPI, green only when falling). All three degrade on older snapshots per thecorpusBytespattern (verified against the metadata-less 2026-07-14 snapshot). - Fixed while landing this: the report compared history against the raw download date while snapshots key on the ISO-week Monday, so a mid-week locally-downloaded cache admitted its own week's snapshot as "previous" and every delta read zero.
previousand the sparkline trail now filter onweekDate. CI never saw it (Monday downloads make raw date == week key); any local run whose cache was fetched off-Monday did.
- Weighted columns on the lifecycle / requires / library-deps tables (maintainer-confirmed scope; the removal-shortlist card has nothing to weight — its members have zero users by definition). Raw counts stay primary and the sort key; every caption says cumulative lifetime downloads, never active users. 2026-07 corpus:
-
Code-loading sinks + probe citations (handoff-08 A2/A3/A4/D, done 2026-07-29): the eval rule now also counts
new Worker(kindWorker— NewExpression only,Worker(...)withoutnewthrows; shadow-guarded like eval/Function, and gif.js bundles' renamedWorker2/GifWorkernever match the exact name). 2026-07 corpus: 3 uses / 3 plugins, exactly thegrep -rlF 'new Worker'ground truth. Three prose blocks ride on the report rather than rules:@updateUrlcaption (meta card): the count is live frommeta-fields(90 addons, 28% — the two case-variant straysupdateurl/updateURLdeliberately excluded), the host breakdown is a dated 2026-07 probe (mwittrien.github.io54,raw.githubusercontent.com28). BD never reads the field (maintainer-confirmed, 2026-07-21), so noupdate-hostsinventory exists — the caption frames it as demand evidence for a first-party updater, advisory for CSP. If BD ever adopts one, themetarule already parses the values.- "Checked and absent" (security card): cites the clean probes —
child_process, string-argsetTimeout/setInterval,document.write,getTokenlookups, dynamic remoteimport(), all 0 corpus-wide; rawlocalStorage1 plugin on a dead path. Text probes bound the corpus; any that graduate to a rule get built AST-first. - A4 (
/api/vNkeying) was dropped — its premise failed verification (2026-07-29): the handoff's "3 plugins hardcode Discord REST paths" was a misread of the 3-file/api/v[0-9]+probe, which actually hits third-party APIs (translate.yandex.net, minecraft-services.net, doggybootsy.com). Zerodiscord(app).com/apior relative"/api/"strings exist corpus-wide; the lonediscord.comentry innetwork-urlsis an asset SVG fetch (ActivityFeed). Recorded in the security card's clean-probe note; hostname keying stays uniform. Re-probe before reviving.
-
CI (
.github/workflows/analyze.yml): weekly cron (Mon 06:00 UTC) + manual dispatch + pushes touchingsrc/. Every run does the full pipeline and deploysresults/report.htmlto GitHub Pages (repo Settings → Pages → Source must be "GitHub Actions"). The.cachecorpus is pinned per ISO week viaactions/cache(weeks start Monday, same as the cron), so all runs within a week analyze identical data — matching local behavior. Only cron/dispatch runs commithistory/: the push gate protects against a mid-week analysis-code change rewriting the committed Monday snapshot, and against a cache eviction stamping an off-cadence dataDate. A failure of the store list endpoint still fails the run (rerun manually or wait for next week); individual addon downloads no longer do — see "A dead source URL is recorded, never fatal" above. -
notes/is a scratch/context folder, not part of the build and gitignored (tsconfigincludessrc/andscripts/;scripts/holds manual tools that are typechecked and linted but never run by the pipeline).notes/handoff-*.mdcontains the planned-analysis backlog:handoff-00-orientation.mdis the shared how-to-work-here guide; 01 (Discord internals coupling), 02 (environment/fragility signals), and 03 (meta validation + trend history) are independent, session-sized batches. -
Obfuscation detection (
src/ast/rules/obfuscation.ts) is a heuristic scorer: per-signal presence flags aggregate asobfuscation-signals, andobfuscated-pluginscounts those scoring >= 0.4. Entropy thresholds were tuned on the 2026-07 corpus (see the comment in the rule) — a "flagged" plugin means bundled/packed/worth-eyeballing, not malicious. Therequireanalysis (requireskey) exists to drive the require-polyfill removal: it counts which modules plugins request via the polyfilledrequire().
- Bun + TypeScript, ESM, strict tsconfig with type-aware eslint (
@zerebos/eslint-config); 4-space indent, double quotes. Runbunx tsc --noEmitandbunx eslint srcbefore considering work done; an occasional targeted// eslint-disable-next-linewith judgement is acceptable. - Verify analysis changes empirically: run rules across
.cache/addonsand sanity-check counts againstgrep -Fground truth on a couple of specific files, plus a synthetic snippet for edge cases (see the expectations encoded above: parse errors should stay 0 across the corpus).