Skip to content

Latest commit

 

History

History
137 lines (113 loc) · 56.5 KB

File metadata and controls

137 lines (113 loc) · 56.5 KB

AGENTS.md

What this project is

Analyzes all official BetterDiscord addons (plugins + themes from the store) to answer questions like:

  • API usage counts — how much is each BdApi member 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 signalsinnerHTML/outerHTML assignment, insertAdjacentHTML, React dangerouslySetInnerHTML props, eval, Function constructor, new Worker, hand-rolled <script> elements.

The output is aggregate data for maintainer decision-making, not a linter for addon authors.

Running

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's actions/cache pin uses, deliberately not a rolling 7-day window (weekStart in src/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, downloadAddon threw, 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's failures array ({addon, url, reason, kept, permanent}), read back through readDownloadFailures(); src/cache.ts owns what "incomplete corpus" means, the same loader-owns-its-judgements rule surface.ts/storemeta.ts follow.
    • kept is the load-bearing distinction. kept: true means a copy from an earlier run is still on disk, so the addon stays in the corpus with possibly stale content; kept: false is a real hole in the denominator. Only the second counts against the tolerance, feeds Snapshot.missingAddons, or claims the report's "absent from every count below".
    • permanent splits 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 on missing, both enforced by reportFailures on 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/BetterDiscordPlugins and both SyndiShanX repos are gone, not just the pinned commits. A flat min(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.
    • meta.json is 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/cache does not save on a failed job).
    • Retry policy is explicit (withRetry, with ky's own retry disabled 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 the kept: false entries and nothing else touches the network. It deliberately leaves lastUpdated alone — 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.json is 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; the error level 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 role methodology plays, and it participates in writeSnapshot's no-op guard), and gapNote() in report.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 were kept locally for exactly this reason and would have been a 10-addon hole in CI.
    • pruneOrphans is 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 from refresh(), only after fetchStoreList() 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, because analyze() 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-level history/ folder, which is committed — see Trend history below.
  • Theme remote CSS is cached under .cache/imports/<host>/<path>.css by src/importcache.ts (updateImports(), run in the pipeline right after the addon download). The first run against a fresh corpus fetches every theme's transitive @import graph over the network (a couple of minutes); every run after that is offline because the fetch is gated on the addon cache's dataDate. The .cache/imports folder lives under .cache, so CI's weekly actions/cache pin already covers it.

Architecture

Two layers:

  1. Pipelinesrc/index.tssrc/cache.ts (store download) → src/analyze.ts (runs every Analysis in src/analyses/ per addon, then aggregates per-addon → per-author → summary). An Analysis returns a Results value (Record<string, number> | number | boolean | string[]); aggregation merges by summing numbers/record values and concatenating arrays (mergeResults in analyze.ts).
  2. AST enginesrc/ast/: meriyah parse → alias collection (aliases.ts) → single walk running Rules (meriyah.ts, rules in src/ast/rules/). Rules produce structured Findings. src/analyses/ast.ts bridges the two layers: one memoized analyzeAddon call 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).

Alias tracking

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:

  • globals skips a root that is declared or shadowed: a file with const global = {} or function f(process) anywhere makes every mention of that root untrustworthy, so the whole file's hits for it are dropped.
  • react-hazards skips 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.

Footguns

  • Bun silently deletes statements starting with declare. A call like declare(x, y); is stripped by Bun's transpiler as a TS ambient declaration while tsc parses it as a call — typecheck stays green, code just doesn't run. Don't name functions/callbacks declare (or other TS keywords like type/namespace) if they'll be called in statement position. This actually happened here; see register in src/ast/aliases.ts.
  • meriyah types CallExpression.callee as any. Always go through calleeOf() in src/ast/helpers.ts; direct .callee access trips the type-aware eslint rules and loses safety.
  • ESTree, not swc/babel shapes. String literals are Literal nodes (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 commit 288616d if archaeology is needed.
  • fs.exists is a Bun-only extension rejected by the fs/promises typings — use the local exists() helper in src/cache.ts.
  • Never key a rule's lookup table on a plain object literal. Plugins define toString(), and "toString" in MEMBERS is true through Object.prototype, so an object-literal table hands back Function.prototype.toString as the "status" — which then gets string-concatenated into the summary by mergeResults. This shipped into results/summary.json before being caught; lifecycle.ts uses a Map. Anything matching arbitrary source-derived names (member names, field names, module names) needs a Map or Object.hasOwn.
  • typescript is pinned to 5.7.3 deliberately. scripts/surface.ts needs the classic compiler API (ts.createSourceFile), and bunx tsc --noEmit — the verification gate — has been green on 5.7.x. Plain bun add -d typescript resolves to 7.x, the Go port, which exposes no createSourceFile at all and silently swaps the compiler behind the gate. Bump it deliberately or not at all.
  • grep may be aliased to ugrep in this shell, whose BRE alternation (\|) behaves differently from GNU grep and can return bogus matches. Prefer grep -F for fixed strings, or the dedicated search tools.
  • The deprecated-API list in src/ast/rules/bdapi.ts is 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.

Current state / roadmap

  • Theme URL extraction lives in the css-url visitText rule (src/ast/rules/cssurls.ts, aggregated as css-urls keyed by hostname). It applies to plugins too, catching url() refs inside embedded CSS strings that the AST remote-url rule can't see.

  • Remote theme CSS is first-class content (handoff-05, done 2026-07-19). Most themes are a thin @import wrapper 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 @media query). Now src/importcache.ts resolves each theme's transitive @import graph (per-theme visited set for cycle safety, MAX_DEPTH cap, 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.ts loads a theme's concatenated remote CSS into CachedAddon.remote_content; getFindings in analyses/ast.ts re-runs the CSS-content rules only (REMOTE_CONTENT_RULES = css-url, class-literals, css-variables — deliberately not meta, which would report no-meta-block on 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 old src/analyses/imports.ts no longer fetches — it reads the cached graph and returns the flat (now transitive) URL list, preserving the committed imports history series. Landing this jumped class-literals/css-urls totals 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.ts folds expressions (literals, templates, concat, member access on known objects/arrays, a few pure string methods), interpret.ts walks 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), and strings.ts holds partialString() (best-effort string where unresolvable segments degrade to the DYNAMIC_SEGMENT ${…} placeholder) plus DYNAMIC_SEGMENT itself — both used to live in the url rules and moved here once self-update became a second consumer. Its consumers are the network-url rule (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 the webpack-targets/patcher-targets rules (src/ast/rules/webpacktargets.ts). Roughly half of corpus fetch sites 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) and BdApi.Patcher.before|instead|after targets (keyed by method name, patch type in details). Both use interpretProgram so const arrays and aliases resolve; getModule/getMangled/getWithKey are deliberately not parsed — their nested Filters.* calls are separate CallExpressions counted on their own. Unresolvable args collapse to (dynamic). The Patcher method is read as arguments[length - 2] (the arg before the trailing callback), not a fixed index: the static BdApi.Patcher.after(caller, module, method, cb) puts it at index 2, but a new BdApi(name) instance bakes the caller in, giving (module, method, cb) with method at index 1 — the alias tracker collapses both to BdApi.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: getModule 234 call sites across 137 plugins using BdApi.Webpack; 230 patcher calls, ~55 (dynamic) (genuinely runtime — mangled getMangled keys), the rest real methods led by render/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 — roots process, Buffer, __dirname, __filename, global, DiscordNative — keyed as root plus one segment (process.env); deeper is noise. Companion to requires for the polyfill-retirement story, hence the shared report card. Sanity magnitudes on the 2026-07 corpus: DiscordNative.clipboard in 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 bare ReactDOM.render, BdApi.ReactDOM.render, and library-held Internal.LibraryModules.ReactDOM.render (BDFDB) all count — they are all the real ReactDOM. Bare identifiers count only at call sites, which picks up const {createRoot} = ReactDOM; createRoot(el) (4 of 12 corpus createRoot files) without double-counting the binding site. createRoot is tracked as the healthy signal (ADOPTION), so isHazardMethod() 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 (a number, not a record). Plugins go through the AST branch (string literals + template quasis only); themes go through visitText on 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) and name__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 in Knew/EmbedMoreImages, as a dead classList.contains(…) branch sitting next to that same plugin's modern .nonVisualMediaItemContainer_f4758a selector — same f4758a prefix, 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/surCord 62,812, Theo/roundmoledV2 62,327, DevilBro/EmojiReplace 61,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 a visitText rule for both addon types (themes are CSS; plugins embed it). It blanks comments and quoted strings length-preservingly first (so /* --x: */ and content: "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} (a var(--name; nested var(--x, var(--y)) counts both). Four analyses in analyses/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.ts loads src/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 is overlap iff its name is in variables; a definition or consumption is outdated iff its name is in deprecated. The two sets are disjoint. This is the healthy counterpart to class-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).

    • variables is Discord's current names; deprecated is its former names (maintainer-confirmed). The classic semantic layer the ecosystem leans on — --background-primary, --text-normal, --header-primary, --interactive-normal — is absent from variables because Discord renamed/removed it (--text-normal--text-default), so overlap correctly measures only live reskins. Those former names populate deprecated (44 of them: the --background-*/--text-*/--header-*/--interactive-* semantic layer, the old --brand-experiment-* scale, the literally-named --deprecated-*, and --info-*-foreground), which drives css-var-outdated via isOutdatedVariable — a definition or consumption of a removed name, the CSS analog of a stale hardcoded class (it resolves to nothing now). The deprecated list was drafted from the classic semantic layer and filtered against the corpus (every entry is used by a theme and absent from variables), 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 the deprecated array — 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 of class-literals (they match the stable name prefix and ignore the hash). Themes via visitText (in REMOTE_CONTENT_RULES, so remote @import CSS counts per importing theme, standard shared-CSS caveat); plugins via the AST string branch (string literals + template quasis — embedded addStyle CSS and querySelector/matches args are the same signal; never raw JS text, per handoff-02). Matching runs on blanked text (the now-exported blankNonCode from cssvariables.ts, so CSS comments and content: 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 the grep -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 the resilientSelectorAddons KPI — the one "up is good" KPI, which is why deltaLine in report.ts takes 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 a visitText rule 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 from addonmanager.ts (~line 190), and the field list from types/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 @invite has no space, so substring(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 as valueless-field rather than invented as fields.
    • Only fields BD consumes are validated. Unknown fields are author/library conventions BD ignores (DevilBro's @var theme settings, @changelog, @colorwayVar) — validating them turned 8 conventions into fake "duplicate" defects. They still appear in the coverage table, marked non-standard. @invite is 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.com missing its colon, your-repo-here left in). Four of the 14 are square/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 (not no-meta-block) — matching BD, which loads it with every fallback ("Unknown Author"/???). Notably @updateUrl is on 90 addons (28%) despite BD core not parsing it at all — a de-facto convention owned by updater libraries.
  • Self-installing plugins (handoff-03, in the Security card): self-update (src/ast/rules/selfupdate.ts) finds writes of a .plugin.js path; the self-updating analysis ANDs that with a fetch of a .plugin.js URL, reusing the network-url/remote-url findings 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 @source link), and they are correctly excluded. Result: 48 = every DevilBro addon, each shipping the BDFDB downloader (fetch 0BDFDB.plugin.js → write into BdApi.Plugins.folder), which is a library bootstrap rather than literal self-update. Ground truth: 49 files pair writeFile with a .plugin.js mention; the 49th is Dastan/FavoriteMedia, correctly rejected (its .plugin.js is only @source meta; it writes mediaPath).

    • The rule matches on the method name (writeFile/writeFileSync), deliberately not pinning the receiver to fs: the corpus shape is require("fs").writeFile(…), whose callee is rooted in a call, so memberChain returns null. That alone is far too loose, which is why the path argument must independently point at a plugin file — via partialString (literals/templates/concat/consts) or a subtree scan for .plugin.js strings and BdApi.Plugins.folder chains, since path.join(…) is a CallExpression the evaluator cannot fold.
  • 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.json records BdApi's declared surface (18 namespaces, 147 members on BD v1.13.14), generated manually by scripts/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-apis and the shortlist are only as current as it is.
    • Key on exposed names, not classes or files. AddonAPI is one class exposed twice, as BdApi.Plugins and BdApi.Themes; a file-keyed reading made addonapi.ts look 100% unused when every member of it is used. The generator reads exposure from api/index.ts's static properties on class BdApi, resolving static Plugins = PluginAPIconst PluginAPI = new AddonAPI(...) → the class.
    • Three generator constraints, each load-bearing. (1) Follow extendsclass DOM extends BaseDOM declares only addStyle/removeStyle itself, so a base-blind walk would report onAdded/animate/createElement/parseHTML as never-declared and then as unused. (2) Read both halves of BD's static/getter pairsstatic Patcher: Patcher is what BdApi.Patcher gives and get Patcher(): BoundPatcher is what new BdApi("x").Patcher gives; the alias tracker collapses both onto one chain, so the manifest carries the union. (3) Only the classes index.ts actually exposescontextmenu.ts also contains MenuPatcher and several interfaces, whose activate/blur/handleRender/initialize are internals that a whole-file scan mistakes for public API.
    • @ignore (BD's own "not for addons" marker, on every constructor) excludes a member; @deprecated feeds the deprecated array, which is where bdapi.ts's DEPRECATED_CURRENT now comes from instead of a hardcoded copy. The old-old DEPRECATED list stays hardcoded on purpose — see the sanity-check note above.
    • Unused-ness is a corpus fact, not an addon fact, and the per-addon Analysis shape cannot express it (Record<member, 0> would aggregate into nonsense). It is computed in report.ts's assemble() from manifest + summary via unusedPaths(). Only phantom-apis is a real analysis. phantom-apis is classified in src/analyses/ast.ts rather than in a rule, because finalize has no access to other rules' findings — it reads the already alias-resolved bdapi-usage findings, the same reuse self-updating makes of the url rules.
    • 2026-07 corpus: 27 of 150 declared paths uncalled (led by Webpack at 6: getByRegex, getAllByRegex, getAllBySource, getAllByPrototypeKeys, getProxy, getMangledProxy), and BdApi.version used 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 in DevilBro/0BDFDB alone and all behind feature-detection guards (typeof BdApi.enableSetting == "function"; an is-array check for settings). 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. opaqueChains in report.ts computes 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") and Property (kind === "init" with a function value). Only mark the get-family deprecated (maintainer, 2026-07): observer/onSwitch/load are removal candidates, and printing them as deprecated publishes a deprecation BD never made. The headline is observer (6 plugins): core runs a document-wide MutationObserver and 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: unload is not tracked at all — it appears nowhere in pluginmanager.ts, so it was never a lifecycle member despite ~9 plugins defining it by symmetry with load; and load is 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.
    • observer is 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 a Property whose value is a FunctionExpression — so the kind === "init" check is what excludes it, and without it programmer2514/CollapsibleUI (its own private observer, runtime.observer.observe(...)) counts. The handoff's probe reported observer: 7 for exactly this reason; the true number is 6. Use the AST, never text: grep -l observer finds 28 files and 20 construct a MutationObserver.
  • Trend history (handoff-03): src/history.ts. writeSnapshot() runs in the pipeline between analyze() and generateReport(), writing history/<dataDate>.json keyed by the ISO-week Monday of .cache/meta.json lastUpdated (weekStart in cache.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-level history/ folder is committed (maintainer's call, 2026-07) and deliberately lives outside results/: snapshots are irreplaceable accumulated data (the store API has no time machine), unlike the regenerable artifacts in results/, and keeping it top-level avoids the fragile results/* + !results/history/ gitignore carve-out this originally used.

    • A snapshot stores summary plus a kpis block, because the interesting numbers are per-addon counts ("how many plugins use require") that summary.json cannot 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 deprecated table the handoff mentions does not exist (deprecated-apis is {}), 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 methodology version (METHODOLOGY in history.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 the class-literals-based fragileAddons. When previous.methodology !== METHODOLOGY, the report suppresses only the methodology-sensitive tile's delta and sparkline (METHODOLOGY_SENSITIVE, currently just fragileAddons) 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.
  • API bypass (handoff-06, done 2026-07-20): three rules for work that routes around BdApi and 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 globalBDFDB/BDFDB_GlobalBDFDB, and ZeresPluginLibrary/ZLibrary/PluginLibrary all → one canonical ZeresPluginLibrary (the same deprecated library, maintainer 2026-07). The signal is a global read, never a string mention: text grep -lF reports 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 both declared and shadowed (a library name can be a local const Library or the BDFDB closure param the DevilBro wrapper injects — that param is shadowed, so it drops, and window.BDFDB_Global carries the detection). global/self are peeled after guarding (stripGlobal only handles window/globalThis). Library self-files are excluded by basename (0BDFDB.plugin.js etc.) or they count as dependents of themselves. Findings carry {library, signal} where signal is guard (read in a !/if/?:/while test — the bootstrap check) vs read (active use); analyses library-deps (presence per library) and library-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 against grep -rlF BDFDB = 49: 47 real dependents + 0BDFDB.plugin.js (the library, excluded by name) + programmer2514/MessageScanAI (a bare // BDFDB compatibility comment, 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 callee document.createElement (after window/globalThis stripping), first arg folding via evalExpr, tag table a Map (source-derived keys, per the footgun above) — that classify differently: raw-style-element is 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 the createElement("script") row in the Security card's dynamic-code table. Rooting on document is load-bearing: it excludes React's BDFDB.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's n.default.document) are undercounted, per undercount > miscount. The BdApi.DOM.addStyle counterpart is already counted by bdapi-usage — the report pairs the two from there rather than double-counting. 2026-07: 3 plugins hand-roll <style> vs 73 using addStyle — 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-patching was 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 (what patcher-targets would miss), 1 patches DOM built-ins (Element.prototype/HTMLImageElement.prototype in Knew/UncompressedImages, self-restored), and 3 are bundled-library internals defining their own prototypes (gif.js EventEmitter/GIF3, lodash ListCache/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 a visitText rule, appliesTo: "both", so it needs no AST and runs for themes and unparseable plugins alike (visitText fires before the parse gate in analyzeAddon, so a parse-failed plugin still yields a size). It emits one finding with {bytes, lines, codeLines}. Deliberately not in REMOTE_CONTENT_RULES — it measures the store file on disk, so bytes/lines reconcile exactly against wc -c/wc -l (verified on 3 addons). Definitions: bytes = UTF-8 byte length via Buffer.byteLength(text, "utf8"), not text.length (UTF-16 code units diverge from wc -c on 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 what wc -l reports); 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). codeLines is labelled approximate and is a lie for minified plugins (a bundle is a handful of enormous lines: quantumsoul/LaTeX is 1.3 MB over 239 lines / 140 "code" lines), which is exactly why every line-based ratio is avoided there.

    • Analysis: sizeRecord<"bytes"|"lines"|"codeLines"|"remoteBytes", number>; aggregation sums each key, so summary.size.bytes is the total corpus disk size (16.5 MB on the 2026-07 corpus) and becomes the report's denominator. remoteBytes is added in the analysis, not the rule (the rule only sees the store file; the analysis has addon.remote_content) — it is a theme's concatenated remote @import CSS 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 as class-literals.
    • Ratios are computed in report.ts's assemble(), 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 with unused-apis; if the Results type 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 the KingGamingYT/ActivityFeed 628-token story), per-KB = tokens / (analyzedBytes/1000) where analyzedBytes = size.bytes + size.remoteBytes — the content the tokens were actually counted over, so import-only themes get an honest density (vozy 🎀/surCord 11.6/KB, not 62,812 ÷ its 3.3 KB wrapper) and genuinely-riddled small themes surface (Eight_P/T1 3,691 tokens but 24.3/KB). It is bytes-based, so it stays honest for minified plugins where line ratios are a lie.
    • KPI: corpusBytes in Kpis/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 future requires drop can now be told apart from the store simply losing plugins. Like unusedApis, 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 with corpusBytes stripped (deleted after).
  • 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 no CachedAddon.store fieldsrc/storemeta.ts is a dedicated loader (owns every judgement, like surface.ts/discordvars.ts): joins by <sanitized author dir>/<file_name> via authorDirName() in cache.ts (1:1 for all 323 files, zero collisions, verified), coerces likes at the boundary (the API sends it as a string while downloads is a number; the APIAddon typings were corrected to reality — dates are ISO strings, not Date), and degrades to an empty map, which makes every weighted value null → rendered "—", never a zero-download claim. The join happens at report/KPI time only (assemble() + deriveKpis()); the summing Results shape 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: observer 6 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 proxy downloads alone 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), and deprecatedSurfaceDownloads (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 the corpusBytes pattern (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. previous and the sparkline trail now filter on weekDate. CI never saw it (Monday downloads make raw date == week key); any local run whose cache was fetched off-Monday did.
  • Code-loading sinks + probe citations (handoff-08 A2/A3/A4/D, done 2026-07-29): the eval rule now also counts new Worker (kind Worker — NewExpression only, Worker(...) without new throws; shadow-guarded like eval/Function, and gif.js bundles' renamed Worker2/GifWorker never match the exact name). 2026-07 corpus: 3 uses / 3 plugins, exactly the grep -rlF 'new Worker' ground truth. Three prose blocks ride on the report rather than rules:

    • @updateUrl caption (meta card): the count is live from meta-fields (90 addons, 28% — the two case-variant strays updateurl/updateURL deliberately excluded), the host breakdown is a dated 2026-07 probe (mwittrien.github.io 54, raw.githubusercontent.com 28). BD never reads the field (maintainer-confirmed, 2026-07-21), so no update-hosts inventory exists — the caption frames it as demand evidence for a first-party updater, advisory for CSP. If BD ever adopts one, the meta rule already parses the values.
    • "Checked and absent" (security card): cites the clean probes — child_process, string-arg setTimeout/setInterval, document.write, getToken lookups, dynamic remote import(), all 0 corpus-wide; raw localStorage 1 plugin on a dead path. Text probes bound the corpus; any that graduate to a rule get built AST-first.
    • A4 (/api/vN keying) 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). Zero discord(app).com/api or relative "/api/" strings exist corpus-wide; the lone discord.com entry in network-urls is 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 touching src/. Every run does the full pipeline and deploys results/report.html to GitHub Pages (repo Settings → Pages → Source must be "GitHub Actions"). The .cache corpus is pinned per ISO week via actions/cache (weeks start Monday, same as the cron), so all runs within a week analyze identical data — matching local behavior. Only cron/dispatch runs commit history/: 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 (tsconfig includes src/ and scripts/; scripts/ holds manual tools that are typechecked and linted but never run by the pipeline). notes/handoff-*.md contains the planned-analysis backlog: handoff-00-orientation.md is 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 as obfuscation-signals, and obfuscated-plugins counts 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. The require analysis (requires key) exists to drive the require-polyfill removal: it counts which modules plugins request via the polyfilled require().

Conventions

  • Bun + TypeScript, ESM, strict tsconfig with type-aware eslint (@zerebos/eslint-config); 4-space indent, double quotes. Run bunx tsc --noEmit and bunx eslint src before considering work done; an occasional targeted // eslint-disable-next-line with judgement is acceptable.
  • Verify analysis changes empirically: run rules across .cache/addons and sanity-check counts against grep -F ground 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).