{"id":51175293,"url":"https://github.com/thunderbird/webext-linter","last_synced_at":"2026-06-27T03:05:14.892Z","repository":{"id":365524451,"uuid":"1271216923","full_name":"thunderbird/webext-linter","owner":"thunderbird","description":"Verifies WebExtensions against Thunderbird's annotated API schemas and ATN review policies.","archived":false,"fork":false,"pushed_at":"2026-06-25T08:23:22.000Z","size":3213,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-06-25T10:13:06.107Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://thunderbird.github.io/webext-linter/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mpl-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/thunderbird.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-16T12:54:20.000Z","updated_at":"2026-06-25T08:23:26.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/thunderbird/webext-linter","commit_stats":null,"previous_names":["thunderbird/webext-linter"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/thunderbird/webext-linter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fwebext-linter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fwebext-linter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fwebext-linter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fwebext-linter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/thunderbird","download_url":"https://codeload.github.com/thunderbird/webext-linter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/thunderbird%2Fwebext-linter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34839927,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-27T03:05:14.213Z","updated_at":"2026-06-27T03:05:14.883Z","avatar_url":"https://github.com/thunderbird.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# webext-linter\n\nVerifies Thunderbird WebExtensions against the [annotated WebExtension API\nschemas](https://github.com/thunderbird/webext-annotated-schemas) and ATN review\npolicies. The tool will report API, manifest, permission, and bundled-code\nissues. It does not modify the reviewed sources.\n\nThe schema verification works like\n[`addons-linter`](https://github.com/mozilla/addons-linter) - parsing the\nJavaScript and matching `browser.*` / `messenger.*` / `chrome.*` calls against\nthe API surface - but uses Thunderbird's annotated schema files.\n\nBeyond the deterministic checks, a few review judgments that resist static\nanalysis can optionally be delegated to an LLM (Claude or ChatGPT) when an API\nkey is supplied. See the LLM checks under Review checks below.\n\n\n## Usage\n\n```sh\nnpm install\n```\n\nRun via `npm` (the `--` separator stops npm from eating the parameters):\n\n```sh\nnpm run help\nnpm run verify -- \u003cxpi|folder\u003e [options]\n```\n\nRun the `node` script directly:\n\n```sh\nnode verify.js \u003cxpi|folder\u003e [options]\n```\n\nThe schema review **always** reads `manifest_version` and uses the matching\nschema (MV2 → `\u003cchannel\u003e-mv2`, MV3 → `\u003cchannel\u003e-mv3`). An add-on that omits\n`manifest_version` (or has a missing/invalid manifest) is treated as MV2.\n\n| Option | Description |\n| --- | --- |\n| `--schema-cache \u003cdir\u003e` | Where downloaded schema zips are cached (default `.schema-cache`). |\n| `--schema-channel \u003cname\u003e` | Schema channel (default `release`). One of `release`, `beta`, `esr`. |\n| `--schema-force-refresh` | Re-download the schema even if a cached copy exists. |\n| `--schema-zip \u003cpath\u003e` | Use a local schema zip (or directory) instead of downloading. |\n| `--checks-only \u003cids\u003e` | Only run these checks (comma-separated). See the check list below. |\n| `--checks-skip \u003cids\u003e` | Skip these checks (comma-separated). See the check list below. |\n| `--report-format \u003ctext\\|json\u003e` | Report output format (default `text`). |\n| `--report-out \u003cfile\u003e` | Write the report to a file in addition to stdout. |\n| `--llm-enabled` | Enable the LLM checks. The key is read from the `LLM_API_KEY` environment variable (see [LLM configuration](#llm-configuration)). |\n| `--llm-list-models` | List the models your token can use, then exit. |\n| `--llm-review` | Shorthand for `--llm-enabled --full-summary` - run the AI add-on review in one flag. |\n| `--allow-experiments` | Accept add-ons that use Experiment APIs, instead of rejecting them as unsupported. Off by default. |\n| `--eslint` | Run the ESLint `code-sanity` check on authored JS. Off by default. |\n| `--diff-to \u003cxpi\\|folder\u003e` | Previously published version, to diff against. |\n| `--diff-summary` | Add an AI assisted **\"Summary of changes\"** section: how the add-on changed since the `--diff-to` baseline. Needs `--diff-to` and `--llm-enabled`. |\n| `--full-summary` | Add an AI **\"Summary of add-on\"** section after the report - what the add-on does, with security/privacy notes and a permission review (which declared permissions appear unused) - from its (almost) full current source (vendored and unused files excluded). The same pass also **re-checks the unsure items** other checks escalated, judging them with full-add-on context, so confident cases resolve instead of landing in manual review (see [LLM checks](#llm-checks)). Advisory, not a finding. Needs `--llm-enabled`. |\n| `--verbose` | Verbose logging. |\n\n**Exit codes:** `0` no errors · `1` one or more error-severity findings · `2`\ntool failure.\n\n### LLM configuration\n\nThe LLM checks are configured from the environment, and enabled via the `--llm-enabled` flag:\n\n| Variable | Description |\n| --- | --- |\n| `LLM_API_TYPE` | Provider: `claude` (default), `chatgpt`, or `ollama` (local). |\n| `LLM_API_KEY` | The provider API key. Required for `claude`/`chatgpt`, not used by `ollama`. |\n| `LLM_API_MODEL` | Model for the LLM checks (default: the provider's default). |\n| `LLM_API_URL` | Override the provider's API base URL (e.g. a proxy, or a remote Ollama host). |\n\n```sh\n# Claude (the default provider)\nexport LLM_API_KEY=sk-ant-...\nnode verify.js \u003cxpi|folder\u003e --llm-enabled\n\n# ChatGPT\nexport LLM_API_TYPE=chatgpt\nexport LLM_API_KEY=sk-...\nnode verify.js \u003cxpi|folder\u003e --llm-enabled\n\n# local Ollama - no API key\nexport LLM_API_TYPE=ollama\nnode verify.js \u003cxpi|folder\u003e --llm-enabled\n```\n\nEach provider has a default model (`claude-sonnet-4-6` for `claude`, `gpt-4.1`\nfor `chatgpt`, `llama3.1` for `ollama`). Override it by setting `LLM_API_MODEL`,\nor list the available models with `--llm-list-models`.\n\n**Local model (Ollama).** With [Ollama](https://ollama.com) running, the checks\ntalk to its OpenAI-compatible endpoint at `http://localhost:11434/v1` — no API\nkey. Pull a tool-capable model first (the structured checks require tool calling),\ne.g. `ollama pull llama3.1`. When the LLM is enabled, a Setup-step pre-flight\nshows the chosen type and model and **fails hard** if Ollama is unreachable or the\nmodel is not pulled. Point `LLM_API_URL` at a remote host to use a non-local Ollama.\n\n\n## Review checks\n\nA review has three kinds of check, all declared in\n[assets/registry.yaml](assets/registry.yaml):\n\n- **Deterministic** - decided entirely in code, no LLM, offline apart from the\n  one-time vendor source fetch. A few are gated by review mode\n  (`diff: true`/`false`).\n- **LLM** - a deterministic pre-flight always runs offline, only the ambiguous\n  residue is delegated to an LLM (when an API key is supplied) or routed to\n  manual review.\n- **Manual** - checks the tool can't make itself, surfaced as a todo list.\n\n### Deterministic checks\n\nEach `deterministic-checks` entry links to a module in\n[src/checks/rules/](src/checks/rules/) and supplies the severity for its\nfindings. A deterministic check either decides each case as a finding or\nescalates it straight to manual review (e.g. `vendor-unverified`,\n`native-messaging`). The LLM checks escalate only their ambiguous residue.\n\n| Check | What it flags |\n| --- | --- |\n| `api-coverage` | Dynamic/aliased API access static analysis can't resolve (info). Files that fail to parse are `unparsable-file`. |\n| `async-onmessage` | An async listener passed to `runtime.onMessage.addListener()`. |\n| `background-module` | A background script (`background.scripts`/`service_worker`) that uses static ES module syntax (`import`/`export`) while the manifest's background is not declared `\"type\": \"module\"` - it won't load as a module (error). Background pages and content scripts are out of scope. |\n| `bundled-files` | Referenced files that aren't packaged: `content_scripts`/`background`/popup/options manifest entries, and packaged-file paths passed to file-loading API calls (script registration, `setIcon`, `executeScript`/`insertCSS`, `getURL`, ...) - the same schema-derived loader set that fuels the reference graph, not a hardcoded list. |\n| `cleartext-transmission` | Data transmitted to a remote host over an unencrypted scheme (`http://`/`ws://`/`ftp://`) by an overt API (`fetch`, XHR, WebSocket, `sendBeacon`) - any cleartext send, regardless of payload (error). Covert disguised channels are the `disguised-*` checks. |\n| `code-sanity` | Opt-in (only runs with `--eslint`). ESLint-based code errors: `no-redeclare`, `no-shadow`, dupe/unreachable/self-* rules, empty blocks (`no-empty`, e.g. an error-swallowing empty `catch`) (info). Style/fixable rules (e.g. `prefer-const`) are excluded - the tool is read-only, so \"rewrite this\" is not a review concern. No `no-undef` (WebExtension scripts share a global scope). |\n| `csp-unsafe-eval` | A `content_security_policy` that allows `'unsafe-eval'` - permits dynamic code execution (error). |\n| `csp-unsafe-inline` | A `content_security_policy` that allows `'unsafe-inline'` - permits dynamic code execution via inline scripts (error). |\n| `debugger-statement` | Unconditional `debugger` statements. |\n| `default-locale-missing` | A packaged `_locales/` directory but no `default_locale` manifest key - Thunderbird refuses to load the add-on (error). |\n| `default-locale-unused` | A `default_locale` manifest key but no packaged `_locales/` directory - Thunderbird refuses to load the add-on (error). |\n| `deprecated-api` | Deprecated APIs (member or namespace level), and APIs whose `version_added` is newer than the target Thunderbird. |\n| `disguised-navigation` | Data smuggled out through a page navigation (`location.assign`/`replace`) built with appended runtime data (error, regardless of consent). |\n| `disguised-resource` | Data smuggled out through a resource-load URL (image/iframe/media `src`, `setAttribute`) built with appended runtime data (error, regardless of consent). |\n| `disguised-stylesheet` | Data smuggled out through a stylesheet or CSS `url()` built with appended runtime data (error, regardless of consent). |\n| `disguised-window` | Data smuggled out through a `window.open()` to a remote URL built with appended runtime data (error, regardless of consent). |\n| `eval-call` | An `eval()` call in authored JS outside the WebExtension tree (Experiment/privileged code) - dynamic code execution (error). WebExtension code is exempt: it cannot run eval without a permissive CSP, which `csp-unsafe-eval` flags. |\n| `experiment-manual-review` | Every reviewed Experiment (declares `experiment_apis`) - routed to manual review with a reminder that Experiments have full access to Thunderbird's internals and need a careful human code review. Fires for pristine, modified, and `--allow-experiments` submissions; silent for non-Experiments and outright-rejected ones. |\n| `experiment-missing-strict-max-version` | An accepted Experiment (`--allow-experiments`) that sets no `strict_max_version` (error). Silent when experiments are disallowed, since `experiment-not-allowed` already rejects it. |\n| `experiment-modified` | A bundled Experiment that is a recognised published Thunderbird API draft but a modified or outdated copy (error) - the submission stays on the normal review path but is rejected until the unmodified latest upstream copy is bundled. |\n| `experiment-not-allowed` | An Experiment (declares `experiment_apis`) when experiments are not enabled via `--allow-experiments` (error, on by default). |\n| `experiment-overrides-api` | An Experiment whose declared API path overrides or grafts onto a built-in Thunderbird API instead of adding a new namespace (error). |\n| `function-constructor` | A `new Function(...)` (the Function constructor) in authored JS outside the WebExtension tree (Experiment/privileged code) - dynamic code execution (error). WebExtension code is exempt (CSP-gated, see `csp-unsafe-eval`). |\n| `manifest-invalid-json` | manifest.json is present but is not valid JSON (error). |\n| `manifest-missing` | No manifest.json at the add-on root (error). |\n| `manifest-missing-key` | A required top-level manifest key (`manifest_version`/`name`/`version`) is absent (error). |\n| `manifest-unknown-permission` | A declared permission value that is neither a known permission, a data-collection permission, nor a match pattern (error). |\n| `manifest-version-mismatch` | `manifest_version` disagrees with the schema set being reviewed (error). |\n| `minimize-host-permissions` | Broad (`\u003call_urls\u003e` / `*` host) permissions requested as required (info). |\n| `missing-library` | A JS file (not in the VENDOR file) that looks like a bundled third-party library - by a `/*! … */` banner, UMD wrapper, `.min.js` name, or known library filename (warning). Heuristic, no hash DB: it can't say which library or verify the version. |\n| `missing-manifest-key` | A called API needs a manifest key (e.g. `action`) that is not declared (error). The manifest-key counterpart of `missing-permission`. |\n| `missing-permission` | A permission used by a called API but not declared (error). An API needing a manifest key is `missing-manifest-key`. |\n| `missing-vendor-file` | A VENDOR entry (file + source URL) naming a file not present in the submission (warning). |\n| `mistyped-manifest-value` | A known manifest key whose value has the wrong type, validated with ajv against a JSON Schema derived from the annotated schema (warning). Thunderbird misreads such values. |\n| `native-messaging` | The `nativeMessaging` permission (in `permissions` or `optional_permissions`), which lets the add-on exchange messages with a native application outside Thunderbird - routed to manual review to confirm disclosure (No Surprises). |\n| `non-experiment-strict-max-version` | A non-Experiment that pins `strict_max_version` (warning - it only blocks installs on newer Thunderbird). |\n| `obfuscated-code` | A JS file (not a recognized library) shipped minified or obfuscated - by minified line geometry, `_0x…` obfuscator identifiers, or `eval`/`Function`-of-decoded-string packers (error). High precision, partial recall - some obfuscators evade it. |\n| `privacy-policy` | Data transmitted to a hardcoded remote host by an overt API - routed to manual review to confirm the listing carries a privacy policy disclosing the collection (the policy text is not part of the package). Complements `data-exfiltration` (which judges consent). |\n| `strict-max-version-bump-only` | Diff check (needs `--diff-to`): fires (info) when a submission changes only the `version` and the gecko `strict_max_version` vs. the prior version - the developer could raise the max on ATN instead of resubmitting. Runs only with `--diff-to`. |\n| `string-timer` | A code string passed to `setTimeout`/`setInterval` (it is eval'd) in authored JS outside the WebExtension tree (Experiment/privileged code) - dynamic code execution (error). WebExtension code is exempt (CSP-gated, see `csp-unsafe-eval`). |\n| `sync-xhr` | Synchronous `XMLHttpRequest` (`open(..., false)`). |\n| `trademark-violation` | Add-on name (resolved from `_locales` for a `__MSG__` name) using a Mozilla trademark - `Firefox`/`Mozilla`/`MZLA` anywhere, or `Thunderbird` other than as a trailing \"for Thunderbird\" (error, case-insensitive). The icon is a separate manual check. |\n| `unknown-api` | Unknown namespaces, unknown members (incl. methods on property types like `storage.local.x`), and APIs marked `unsupported`. |\n| `unparsable-file` | A JS file that failed to parse, so its API checks were skipped (info). |\n| `unpinned-dependency` | A `package.json` dependency declared as a version range with no lock file, so it can't be pinned to one release and verified (error). |\n| `unpinned-vendor-source` | A VENDOR-declared file whose (trusted-host) source is not pinned to an immutable version/tag/commit, so its bytes can't be verified (error). |\n| `unrecognized-manifest-key` | A top-level manifest key the schema does not define - Thunderbird ignores it (warning). |\n| `unsafe-html` | Any write to `innerHTML`/`outerHTML`/`srcdoc`/`insertAdjacentHTML`; only `Element.setHTML()` is sanctioned (an empty/null clear is exempt) (warning). |\n| `vendor-modified` | A declared third-party file whose bytes don't match its pinned source (EOL-tolerant compare) - it appears modified from upstream (error). |\n| `vendor-unparseable` | A VENDOR file is present but no block pairs a library file with a source URL that points to a file, so nothing can be verified (error). |\n| `vendor-unverified` | Declarations that can't be settled automatically - an untrusted-host source, a library not confirmed widely used, or an unfetchable source - routed to manual review. |\n\n### LLM checks\n\nEach LLM check **always runs its deterministic pre-flight**, regardless if LLM support is enabled or not.\nCases the pre-flight can settle become findings directly, and only the\ngenuinely-ambiguous residue is escalated, per case. When LLM support is not enabled, unsure findings are added to the manual review queue. When LLM support *is* enabled\n(`--llm-enabled` with an `LLM_API_KEY`), each escalated case is sent to the model\nwith the check's rubric and that case's evidence (e.g. the offending file's\nsource). The model returns a three-way verdict - **fail** / **pass** /\n**unsure** - so a confident result is final. Any **unsure** finding is routed to manual review.\n\n`--full-summary` adds a second pass over those unsure items. The whole-add-on\nsummary re-judges each one with full-add-on context (richer than the per-case\nevidence of the first pass), so many resolve to a confident **pass**/**fail**\ninstead of staying on the manual-review list. Without `--full-summary`, each\nunsure case simply goes to manual review as above.\n\n| Check id (`check:`) | Pre-flight (always) + what the LLM judges |\n| --- | --- |\n| `remote-eval` | Pre-flight: the statically-undecidable `fetch()-\u003eeval` pattern (scanned only outside the WebExtension tree, like the other dynamic-execution checks - WebExtension code is CSP-gated) → the LLM judges (given the offending file) whether the executed code is fetched remotely. The definite dynamic-execution cases are the deterministic `eval-call`/`function-constructor`/`string-timer`/`csp-unsafe-eval`/`csp-unsafe-inline` checks. |\n| `remote-script` | Pre-flight: remote `\u003cscript\u003e`/`\u003clink\u003e`/`@import`/`url()`/media/imports/`importScripts`/runtime injection/WASM, and a CSP permitting a remote script source → a finding. Statically-undecidable cases (non-literal URLs, inline `data:`/`blob:` script sources) → the LLM judges whether the source is remote. |\n| `data-exfiltration` | Pre-flight: a normal transmission (`fetch`/XHR/WebSocket/EventSource/`sendBeacon`) to a remote/dynamic host → the LLM judges, given the file and the options page, whether user data is sent without an explicit opt-in. Covert channels are the separate `disguised-*` errors. |\n| `missing-english-localization` | Pre-flight: A `_locales` English directory (`en`, `en-US`, …) present → pass. A `_locales` directory but no English → a finding. No `_locales` directory → the LLM judges whether user-facing strings are hardcoded in a non-English language. |\n| `minimize-web-accessible-resources` | Pre-flight: over-broad exposure (a resource pattern like `*`, or MV3 `matches` of `\u003call_urls\u003e`/`*://*/*`) and concrete resources no content script/page loads → a finding. An ambiguous exposed resource (dynamic loaders, or name mentioned) → the LLM judges whether it is needlessly exposed. |\n| `unused-files` | Pre-flight: hidden/junk by name, and files reachable from no manifest entry point (a reference graph over imports/`getURL`/HTML/CSS plus schema-derived file-loading APIs) - a clearly-unreferenced file is a finding. An ambiguous file (string-mentioned, or the add-on uses dynamic loaders) → the LLM judges whether it is unused. License/README/VENDOR/`_locales` are exempt. |\n\n\n### Manual checks\n\nSome review steps can't be automated - they need hands-on testing or a human's\njudgment over content the tool can't see (the store listing, screenshots, the\nicon). These live under `manual-checks` in the yaml and are surfaced in the\nreport's **Standard manual review** to-do list.\n\n| Check id (`check:`) | What the reviewer verifies |\n| --- | --- |\n| `check-submission-spam` | The listing and add-on for spam or inappropriate, misleading, or low-effort content. |\n| `test-add-on` | Functionality in a test profile, fail if credentials or other info are needed to continue. |\n| `no-surprises-policy` | The code diff for behavior not documented on the ATN listing that could surprise the user. |\n| `missing-payment-disclosure` | Whether the add-on requires payment but the \"needs payment\" flag is not set on ATN. |\n| `suitability-for-listing` | Whether the add-on targets a limited or non-public audience (better self-hosted than listed). |\n| `acceptable-use-policy` | The name, summary, description, and screenshots against Mozilla's Acceptable Use Policy. |\n| `icon-trademark-imitation` | The icon for imitation of the Thunderbird or Mozilla logo (an image the automated checks can't inspect). |\n| `missing-atn-description` | The ATN listing page has usage instructions, entry points, and screenshots. |\n| `missing-english-atn-localization` | The ATN listing page also has an English version. |\n| `forked-add-on` | New-submission prompt (`diff: false`, skipped when reviewing against a `--diff-to` baseline): a forked add-on is clearly distinguished from the original and offers a significant difference in functionality and/or code. |\n\n\n## Examples\n\n```sh\n# Review a submitted xpi against the matching schema (read-only - report only)\nnode verify.js ./submission.xpi\n\n# Review an unpacked source folder\nnode verify.js ./my-addon\n\n# esr channel, machine-readable, offline schema\nnode verify.js ./submission.xpi --schema-channel esr --report-format json --schema-zip ./schemas.zip\n\n# Review with the LLM checks enabled, plus an AI summary of the add-on\nexport LLM_API_KEY=sk-…\nnode verify.js ./submission.xpi --llm-enabled --full-summary\n\n# Same, but use ChatGPT instead of the default (Claude)\nexport LLM_API_KEY=sk-… LLM_API_TYPE=chatgpt\nnode verify.js ./submission.xpi --llm-enabled\n\n# List the models your token can use, then exit (needs a token)\nexport LLM_API_KEY=sk-…\nnode verify.js --llm-list-models\n```\n\n## Contributing\n\nRequires Node `\u003e=20` and `npm install` once. Before sending a change, run:\n\n```sh\nnpm run lint          # ESLint over src/ and the root entry files\nnpm run format:check  # Prettier (run `npx prettier --write \u003cglob\u003e` to fix)\nnpm test              # add-on golden snapshots + the unit suite\n```\n\nConventions:\n\n- **Prettier-formatted and ESLint-clean** - double quotes, semicolons,\n  `printWidth` 80.\n- **The registry owns every model-facing string** - check rubrics, the LLM\n  system intro, and prompts live in\n  [`assets/registry.yaml`](assets/registry.yaml), never in `src/`.\n- **Each source file opens with a header comment** stating what belongs in it,\n  keep it accurate when you edit.\n- **Golden tests are byte-exact** - regenerate intended report changes with\n  `UPDATE_GOLDEN=1 npm test` and review the diff.\n\n(See [tests/README.md](tests/README.md) for the test suite.)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthunderbird%2Fwebext-linter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthunderbird%2Fwebext-linter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthunderbird%2Fwebext-linter/lists"}