@extenshi/cli 0.23.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +1 -1
  2. package/dist/amo-source.d.ts +88 -17
  3. package/dist/amo-source.d.ts.map +1 -1
  4. package/dist/amo-source.js +442 -116
  5. package/dist/amo-source.js.map +1 -1
  6. package/dist/amowebstore-md.d.ts +26 -0
  7. package/dist/amowebstore-md.d.ts.map +1 -1
  8. package/dist/amowebstore-md.js +32 -3
  9. package/dist/amowebstore-md.js.map +1 -1
  10. package/dist/auto-update.d.ts +46 -3
  11. package/dist/auto-update.d.ts.map +1 -1
  12. package/dist/auto-update.js +94 -19
  13. package/dist/auto-update.js.map +1 -1
  14. package/dist/chromewebstore-md.d.ts +7 -0
  15. package/dist/chromewebstore-md.d.ts.map +1 -1
  16. package/dist/chromewebstore-md.js +20 -3
  17. package/dist/chromewebstore-md.js.map +1 -1
  18. package/dist/cli.js +73 -20
  19. package/dist/cli.js.map +1 -1
  20. package/dist/compliance-review.d.ts +5 -1
  21. package/dist/compliance-review.d.ts.map +1 -1
  22. package/dist/compliance-review.js +10 -5
  23. package/dist/compliance-review.js.map +1 -1
  24. package/dist/cws-policies.d.ts.map +1 -1
  25. package/dist/cws-policies.js +4 -0
  26. package/dist/cws-policies.js.map +1 -1
  27. package/dist/edgewebstore-md.d.ts +14 -0
  28. package/dist/edgewebstore-md.d.ts.map +1 -1
  29. package/dist/edgewebstore-md.js +20 -5
  30. package/dist/edgewebstore-md.js.map +1 -1
  31. package/dist/evidence-ingest.d.ts +67 -5
  32. package/dist/evidence-ingest.d.ts.map +1 -1
  33. package/dist/evidence-ingest.js +146 -21
  34. package/dist/evidence-ingest.js.map +1 -1
  35. package/dist/generate-listing.d.ts +18 -6
  36. package/dist/generate-listing.d.ts.map +1 -1
  37. package/dist/generate-listing.js +62 -19
  38. package/dist/generate-listing.js.map +1 -1
  39. package/dist/json-ld.d.ts +43 -5
  40. package/dist/json-ld.d.ts.map +1 -1
  41. package/dist/json-ld.js +53 -1
  42. package/dist/json-ld.js.map +1 -1
  43. package/dist/landing-sections.d.ts +21 -3
  44. package/dist/landing-sections.d.ts.map +1 -1
  45. package/dist/landing-sections.js +63 -17
  46. package/dist/landing-sections.js.map +1 -1
  47. package/dist/listing-checks.d.ts +15 -9
  48. package/dist/listing-checks.d.ts.map +1 -1
  49. package/dist/listing-checks.js +54 -46
  50. package/dist/listing-checks.js.map +1 -1
  51. package/dist/listing-data-flows.d.ts +140 -0
  52. package/dist/listing-data-flows.d.ts.map +1 -0
  53. package/dist/listing-data-flows.js +454 -0
  54. package/dist/listing-data-flows.js.map +1 -0
  55. package/dist/listing.d.ts +48 -0
  56. package/dist/listing.d.ts.map +1 -1
  57. package/dist/listing.js +201 -23
  58. package/dist/listing.js.map +1 -1
  59. package/dist/localize-parity.d.ts +26 -0
  60. package/dist/localize-parity.d.ts.map +1 -0
  61. package/dist/localize-parity.js +227 -0
  62. package/dist/localize-parity.js.map +1 -0
  63. package/dist/localize.d.ts +6 -0
  64. package/dist/localize.d.ts.map +1 -1
  65. package/dist/localize.js +4 -4
  66. package/dist/localize.js.map +1 -1
  67. package/dist/media.d.ts.map +1 -1
  68. package/dist/media.js +1 -2
  69. package/dist/media.js.map +1 -1
  70. package/dist/package.d.ts +13 -0
  71. package/dist/package.d.ts.map +1 -1
  72. package/dist/package.js +1 -1
  73. package/dist/package.js.map +1 -1
  74. package/dist/page.d.ts +16 -0
  75. package/dist/page.d.ts.map +1 -1
  76. package/dist/page.js +49 -4
  77. package/dist/page.js.map +1 -1
  78. package/dist/permission-justifications.d.ts +19 -1
  79. package/dist/permission-justifications.d.ts.map +1 -1
  80. package/dist/permission-justifications.js +129 -0
  81. package/dist/permission-justifications.js.map +1 -1
  82. package/dist/png.d.ts +40 -0
  83. package/dist/png.d.ts.map +1 -0
  84. package/dist/png.js +266 -0
  85. package/dist/png.js.map +1 -0
  86. package/dist/review-checks.d.ts +54 -13
  87. package/dist/review-checks.d.ts.map +1 -1
  88. package/dist/review-checks.js +367 -56
  89. package/dist/review-checks.js.map +1 -1
  90. package/dist/review-risk.d.ts +8 -3
  91. package/dist/review-risk.d.ts.map +1 -1
  92. package/dist/review-risk.js +14 -4
  93. package/dist/review-risk.js.map +1 -1
  94. package/dist/review-stores.d.ts +63 -0
  95. package/dist/review-stores.d.ts.map +1 -0
  96. package/dist/review-stores.js +92 -0
  97. package/dist/review-stores.js.map +1 -0
  98. package/dist/screenshots-scenes.d.ts +13 -0
  99. package/dist/screenshots-scenes.d.ts.map +1 -1
  100. package/dist/screenshots-scenes.js +20 -2
  101. package/dist/screenshots-scenes.js.map +1 -1
  102. package/dist/screenshots.d.ts +6 -4
  103. package/dist/screenshots.d.ts.map +1 -1
  104. package/dist/screenshots.js +31 -11
  105. package/dist/screenshots.js.map +1 -1
  106. package/dist/smoke.d.ts +54 -6
  107. package/dist/smoke.d.ts.map +1 -1
  108. package/dist/smoke.js +302 -26
  109. package/dist/smoke.js.map +1 -1
  110. package/dist/store-assets.d.ts +20 -3
  111. package/dist/store-assets.d.ts.map +1 -1
  112. package/dist/store-assets.js +17 -7
  113. package/dist/store-assets.js.map +1 -1
  114. package/package.json +1 -1
@@ -14,6 +14,12 @@
14
14
  * Chrome for Developers documentation so the user can verify the rule at the
15
15
  * source. (We do not cite third-party books in product output.)
16
16
  *
17
+ * The suite predicts ONE store's review per run (`store`, default Chrome Web
18
+ * Store). Findings that describe a store's review process — the in-depth review
19
+ * of broad host access, re-review on listing edits, the Privacy practices form —
20
+ * exist only for the store whose documentation describes them; see
21
+ * review-stores.ts for the wording that differs.
22
+ *
17
23
  * This module is PURE (no I/O) so the rules are unit-testable against fixtures.
18
24
  * Archive extraction lives in review-archive.ts; the network manifest fetch in
19
25
  * review-catalog.ts; rendering in review-render.ts. The store-listing rules
@@ -21,7 +27,9 @@
21
27
  * caller gets one findings list regardless of which surface produced it.
22
28
  */
23
29
  import { policyForCheck } from './cws-policies.js';
30
+ import { readLocaleMessages } from './listing.js';
24
31
  import { runListingChecks } from './listing-checks.js';
32
+ import { storeRules } from './review-stores.js';
25
33
  // ── ch.10 permission → Chrome warning dialog ─────────────────────────────────
26
34
  //
27
35
  // The auto-disable-on-update behaviour (check 3) hinges on ONE thing: does
@@ -68,6 +76,64 @@ export const PERMISSION_WARNINGS = {
68
76
  webAuthenticationProxy: 'Read and change all your data on all websites',
69
77
  webNavigation: 'Read your browsing history',
70
78
  };
79
+ // ── Firefox: permission → update prompt ──────────────────────────────────────
80
+ //
81
+ // Firefox does not disable anything when an update adds a permission. Extension
82
+ // Workshop, "Request the right permissions"
83
+ // (https://extensionworkshop.com/documentation/develop/request-the-right-permissions/):
84
+ // the user is "prompted to approve the permissions before the updated version is
85
+ // installed", and if they cancel, "the previous version remains installed".
86
+ //
87
+ // Host permissions of a Manifest V3 extension are the exception. MDN,
88
+ // manifest.json/host_permissions
89
+ // (https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions):
90
+ // since Firefox 127 they are in the install prompt, but new ones requested by an
91
+ // update "are not shown to the user" (bug 1893232, still NEW on 2026-10-11).
92
+ // The bug's comment 2 (Mozilla, 2025-05-29) says what happens instead: "the
93
+ // extension is updated without adding the requested host permission", while an
94
+ // MV2 extension is not updated until the user approves.
95
+ //
96
+ // Which API permissions prompt is Firefox's own list, not Chrome's:
97
+ // PERMISSIONS_WITH_MESSAGE in toolkit/components/extensions/ExtensionPermissionMessages.sys.mjs,
98
+ // with the messages from toolkit/locales/en-US/toolkit/global/extensionPermissions.ftl
99
+ // (mozilla-firefox/firefox, main, 2026-10-11). Neither has identity or
100
+ // unlimitedStorage. Left out: userScripts and trialML, which Firefox's
101
+ // manifest schema declares OptionalOnlyPermission (schemas/user_scripts.json,
102
+ // schemas/trial_ml.json) — in `permissions` they are dropped with a warning,
103
+ // so an update never prompts for them. Every key here can be moved to
104
+ // optional_permissions (MDN, manifest.json/optional_permissions). Keep in sync
105
+ // with shared-types/permission-warnings.ts (`firefoxWarningByPermission`) —
106
+ // pinned by permission-warnings-sync.test.ts.
107
+ //
108
+ // `devtools` is also implied: Extension.sys.mjs getRequiredPermissions() adds
109
+ // it whenever the manifest has a devtools_page and does not list devtools in
110
+ // optional_permissions — see firefoxRequiredApiPermissions().
111
+ export const FIREFOX_PERMISSION_MESSAGES = {
112
+ bookmarks: 'Read and modify bookmarks',
113
+ browserSettings: 'Read and modify browser settings',
114
+ browsingData: 'Clear recent browsing history, cookies, and related data',
115
+ clipboardRead: 'Get data from the clipboard',
116
+ clipboardWrite: 'Input data to the clipboard',
117
+ declarativeNetRequest: 'Block content on any page',
118
+ declarativeNetRequestFeedback: 'Read your browsing history',
119
+ devtools: 'Extend developer tools to access your data in open tabs',
120
+ downloads: 'Download files and read and modify the browser’s download history',
121
+ 'downloads.open': 'Open files downloaded to your computer',
122
+ find: 'Read the text of all open tabs',
123
+ geolocation: 'Access your location',
124
+ history: 'Access browsing history',
125
+ management: 'Monitor extension usage and manage themes',
126
+ nativeMessaging: 'Exchange messages with programs other than Firefox',
127
+ notifications: 'Display notifications to you',
128
+ pkcs11: 'Provide cryptographic authentication services',
129
+ privacy: 'Read and modify privacy settings',
130
+ proxy: 'Control browser proxy settings',
131
+ sessions: 'Access recently closed tabs',
132
+ tabs: 'Access browser tabs',
133
+ tabHide: 'Hide and show browser tabs',
134
+ topSites: 'Access browsing history',
135
+ webNavigation: 'Access browser activity during navigation',
136
+ };
71
137
  // Official Chrome documentation backing each check (shown as the finding source).
72
138
  const DOC_REMOTE_CODE = 'https://developer.chrome.com/docs/webstore/program-policies/code-readability';
73
139
  const DOC_REVIEW = 'https://developer.chrome.com/docs/webstore/review-process';
@@ -75,6 +141,9 @@ const DOC_PERMISSION_WARNINGS = 'https://developer.chrome.com/docs/extensions/de
75
141
  const DOC_CSP = 'https://developer.chrome.com/docs/extensions/reference/manifest/content-security-policy';
76
142
  const DOC_SERVICE_WORKER = 'https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle';
77
143
  const DOC_PERMISSIONS = 'https://developer.chrome.com/docs/extensions/develop/concepts/declare-permissions';
144
+ const DOC_EDGE_UPDATE = 'https://learn.microsoft.com/en-us/microsoft-edge/extensions/update/update-extension';
145
+ const DOC_FIREFOX_PERMISSIONS = 'https://extensionworkshop.com/documentation/develop/request-the-right-permissions/';
146
+ const DOC_FIREFOX_HOST_PERMISSIONS = 'https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/host_permissions';
78
147
  // ── Host-permission helpers ──────────────────────────────────────────────────
79
148
  /**
80
149
  * The four spellings of "every host" from ch.10 "Global Host Permission" — all
@@ -140,20 +209,58 @@ export function htmlEntrypoints(m) {
140
209
  return [...new Set(out)];
141
210
  }
142
211
  // ── Check 1: slow-review-queue predictor (ch.10) ─────────────────────────────
143
- export function checkSlowReviewQueue(m) {
144
- const global = requiredHostPermissions(m).filter(isGlobalHost);
145
- if (global.length === 0)
212
+ /**
213
+ * Content scripts that inject into every site, as `content_scripts[i].matches: <pattern>`.
214
+ *
215
+ * Chrome grants a content script's match patterns the same host access as
216
+ * `host_permissions` and shows the same warning for them, so an extension that
217
+ * reaches every site only through `content_scripts` has broad host access all
218
+ * the same — and is the common shape for page-styling extensions.
219
+ */
220
+ function globalContentScriptMatches(m) {
221
+ const out = [];
222
+ for (const [i, cs] of (m.content_scripts ?? []).entries()) {
223
+ for (const pattern of asStringArray(cs?.matches).filter(isGlobalHost)) {
224
+ out.push(`content_scripts[${i}].matches: ${pattern}`);
225
+ }
226
+ }
227
+ return out;
228
+ }
229
+ /**
230
+ * Chrome Web Store only. Its review-process page says reviews "may take longer
231
+ * for extensions that request broad host permissions". Edge certifies every
232
+ * submission the same way (up to 7 business days) and AMO publishes after
233
+ * automated checks; neither documents a longer path for broad host access, so
234
+ * this finding would be invented there.
235
+ */
236
+ export function checkSlowReviewQueue(m, store = 'CHROME') {
237
+ if (store !== 'CHROME')
146
238
  return [];
239
+ const global = [...new Set(requiredHostPermissions(m).filter(isGlobalHost))];
240
+ const viaContentScripts = globalContentScriptMatches(m);
241
+ if (global.length === 0 && viaContentScripts.length === 0)
242
+ return [];
243
+ const onlyContentScripts = global.length === 0;
147
244
  return [
148
245
  {
149
246
  verdict: 'SLOW',
150
247
  check: 'global-host-permissions',
151
- title: 'Global host permissions put the extension in the slow manual review queue',
248
+ title: onlyContentScripts
249
+ ? 'Content scripts on every site put the extension in the slow manual review queue'
250
+ : 'Global host permissions put the extension in the slow manual review queue',
152
251
  detail: 'Requesting access to every host (any of <all_urls>, *://*/*, http://*/*, https://*/*) ' +
153
- 'forces a manual review, which typically raises review time from under 24 hours to several days.',
154
- evidence: [...new Set(global)].join(', '),
155
- recommendation: 'Narrow to the specific hosts you actually call (e.g. https://api.example.com/*), or drop host ' +
156
- 'permissions entirely in favour of activeTab — see the activeTab recommendation below.',
252
+ 'forces a manual review, which typically raises review time from under 24 hours to several days.' +
253
+ (viaContentScripts.length > 0
254
+ ? ' Content scripts count: their match patterns grant the same access as host permissions, and ' +
255
+ 'Chrome shows the same "Read and change all your data on all websites" warning for them.'
256
+ : ''),
257
+ evidence: [...global, ...viaContentScripts].join(', '),
258
+ recommendation: onlyContentScripts
259
+ ? 'Narrow content_scripts.matches to the sites the extension is for, or inject on demand with ' +
260
+ 'activeTab + scripting.executeScript() after a click. If it genuinely works on every page, keep ' +
261
+ 'it and plan for the longer review.'
262
+ : 'Narrow to the specific hosts you actually call (e.g. https://api.example.com/*), or drop host ' +
263
+ 'permissions entirely in favour of activeTab — see the activeTab recommendation below.',
157
264
  citation: `Chrome Web Store: review process — ${DOC_REVIEW}`,
158
265
  },
159
266
  ];
@@ -162,8 +269,29 @@ export function checkSlowReviewQueue(m) {
162
269
  /**
163
270
  * Static advisory — there is nothing in the package to inspect; it's a
164
271
  * publishing-process fact developers routinely get burned by. Emitted once.
272
+ *
273
+ * Per store, because the process differs: Edge restarts certification for a
274
+ * metadata-only update too, and AMO documents nothing about listing-only edits
275
+ * (so nothing is said there).
165
276
  */
166
- export function checkListingChangeReReview() {
277
+ export function checkListingChangeReReview(store = 'CHROME') {
278
+ if (store === 'FIREFOX')
279
+ return [];
280
+ if (store === 'EDGE') {
281
+ return [
282
+ {
283
+ verdict: 'SLOW',
284
+ check: 'listing-change-rereview',
285
+ title: 'Editing the Edge Add-ons listing sends the extension through certification again',
286
+ detail: 'In Partner Center an update to the store listing alone (description, screenshots, search terms) ' +
287
+ 'starts the same certification as a new package, which can take up to 7 business days — and ' +
288
+ 'editing a submission that is already in review stops that review and starts a new one.',
289
+ recommendation: 'Batch listing edits with a code release, and finish editing before you click Publish rather than ' +
290
+ 'while the submission is in review.',
291
+ citation: `Microsoft Edge Add-ons: update an extension — ${DOC_EDGE_UPDATE}`,
292
+ },
293
+ ];
294
+ }
167
295
  return [
168
296
  {
169
297
  verdict: 'SLOW',
@@ -177,63 +305,144 @@ export function checkListingChangeReReview() {
177
305
  },
178
306
  ];
179
307
  }
180
- // ── Check 3: auto-disable-on-update guard (ch.10 / ch.13) — manifest diff ─────
308
+ /**
309
+ * Chromium (Chrome, Edge): a new required permission that warns disables the
310
+ * extension until the user accepts it. Microsoft publishes no page of its own on
311
+ * this, so Edge findings cite Chrome's and name Microsoft Edge in the text.
312
+ */
313
+ function chromiumNewPermission(perm, warning, store) {
314
+ const edge = store === 'EDGE';
315
+ return {
316
+ title: `Adding required "${perm}" silently disables the extension${edge ? ' in Microsoft Edge' : ''} for existing users`,
317
+ detail: `"${perm}" shows the warning "${warning}". When an update adds a required permission that warns, ` +
318
+ `${edge ? 'Microsoft Edge' : 'the browser'} silently disables the extension for everyone who already has it ` +
319
+ 'installed and shows only a small toolbar icon — most users never re-enable, so you lose them.',
320
+ recommendation: `Move "${perm}" into optional_permissions and request it at runtime with chrome.permissions.request(), ` +
321
+ 'so existing users keep the extension enabled.',
322
+ citation: chromiumUpdateCitation(edge),
323
+ };
324
+ }
325
+ function chromiumUpdateCitation(edge) {
326
+ return edge
327
+ ? `Chrome for Developers (Microsoft Edge runs the same Chromium behaviour): permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`
328
+ : `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`;
329
+ }
330
+ function chromiumNewHost(host, store) {
331
+ const edge = store === 'EDGE';
332
+ const global = isGlobalHost(host);
333
+ return {
334
+ title: `Adding host access "${host}" silently disables the extension${edge ? ' in Microsoft Edge' : ''} for existing users`,
335
+ detail: `Host permissions show a "Read and change ${global ? 'all your data on all websites' : `your data on ${host}`}" ` +
336
+ `warning. Adding one in an update silently disables the extension${edge ? ' in Microsoft Edge' : ''} for ` +
337
+ 'the installed base until each user re-accepts — only a small toolbar icon signals it.',
338
+ recommendation: 'Move it to optional_host_permissions and request it on a user gesture, or use activeTab if you only ' +
339
+ 'need the active tab.',
340
+ citation: chromiumUpdateCitation(edge),
341
+ };
342
+ }
343
+ /** Firefox: the update waits for the user's approval; nothing is disabled. */
344
+ function firefoxNewPermission(perm, message) {
345
+ return {
346
+ title: `Adding required "${perm}" holds the update for existing Firefox users until they approve it`,
347
+ detail: `Firefox shows "${perm}" as "${message}". When an update adds a permission with a message, Firefox asks ` +
348
+ 'each existing user to approve it before it installs the update. Until they do — and for good, if they ' +
349
+ 'decline — they stay on your previous version.',
350
+ recommendation: `Move "${perm}" into optional_permissions and request it with browser.permissions.request() from a user ` +
351
+ 'action (a toolbar button or menu item click), so the update installs without a prompt.',
352
+ citation: `Firefox Extension Workshop: Request the right permissions — ${DOC_FIREFOX_PERMISSIONS}`,
353
+ };
354
+ }
355
+ /**
356
+ * Firefox host access. MV2 hosts sit in `permissions` and hold the update like
357
+ * any other permission; MV3 `host_permissions` added by an update are neither
358
+ * shown nor granted, so the update installs and the new access is just missing.
359
+ */
360
+ function firefoxNewHost(host, mv3) {
361
+ const target = isGlobalHost(host) ? 'every website' : host;
362
+ if (!mv3) {
363
+ return {
364
+ title: `Adding host access "${host}" holds the update for existing Firefox users until they approve it`,
365
+ detail: `Firefox asks each existing user to approve access to ${target} before it installs the update. ` +
366
+ 'Until they do — and for good, if they decline — they stay on your previous version.',
367
+ recommendation: 'Move it to optional_permissions (where Manifest V2 lists optional hosts) and request it with ' +
368
+ 'browser.permissions.request() from a user action, or use activeTab if you only need the active tab.',
369
+ citation: `Firefox Extension Workshop: Request the right permissions — ${DOC_FIREFOX_PERMISSIONS}`,
370
+ };
371
+ }
372
+ return {
373
+ title: `Adding host access "${host}" leaves existing Firefox users without it after the update`,
374
+ detail: 'Firefox installs the update but neither shows nor grants host permissions an update adds to a Manifest V3 ' +
375
+ `extension (Firefox bug 1893232). Whatever needs access to ${target} stops working for existing users ` +
376
+ 'until each of them grants it in the Firefox Add-ons Manager; only new installs are asked for it.',
377
+ recommendation: 'Move it to optional_host_permissions, check it with browser.permissions.contains(), and request it with ' +
378
+ 'browser.permissions.request() from a user action so existing users are asked — or use activeTab if you ' +
379
+ 'only need the active tab.',
380
+ citation: `MDN: host_permissions — host permissions added by an update are not shown to the user — ${DOC_FIREFOX_HOST_PERMISSIONS}`,
381
+ };
382
+ }
383
+ /** Firefox's required API permissions: the declared ones plus `devtools`, implied by a devtools_page. */
384
+ function firefoxRequiredApiPermissions(m) {
385
+ const out = requiredApiPermissions(m);
386
+ if (m.devtools_page &&
387
+ !out.includes('devtools') &&
388
+ !asStringArray(m.optional_permissions).includes('devtools')) {
389
+ out.push('devtools');
390
+ }
391
+ return out;
392
+ }
181
393
  /**
182
394
  * Diff the local manifest against the last-published one. A newly-added
183
- * *required* permission that shows a warning silently disables the extension for
184
- * the whole installed base on update (only a small toolbar icon appears) →
185
- * silent attrition. Newly-added permissions of any kind also re-trip the slow
186
- * review queue (ch.10).
395
+ * *required* permission that prompts costs the installed base on update →
396
+ * attrition. How depends on the browser: Chromium (Chrome, Edge) silently
397
+ * disables the extension until the user re-accepts (only a small toolbar icon
398
+ * appears); Firefox keeps users on the previous version until they approve, and
399
+ * installs an MV3 update without the host access it adds (see
400
+ * FIREFOX_PERMISSION_MESSAGES). On the Chrome Web Store, newly-added permissions
401
+ * of any kind also re-trip the slow review queue (ch.10).
187
402
  *
188
403
  * `prior` is the manifest fetched from the catalog's last snapshot; pass null
189
404
  * when it could not be fetched (the caller surfaces why and this returns []).
190
405
  */
191
- export function checkAutoDisableOnUpdate(local, prior) {
406
+ export function checkAutoDisableOnUpdate(local, prior, store = 'CHROME') {
192
407
  if (!prior)
193
408
  return [];
194
409
  const findings = [];
195
- const priorApi = new Set(requiredApiPermissions(prior));
410
+ const firefox = store === 'FIREFOX';
411
+ const apiPermissions = firefox ? firefoxRequiredApiPermissions : requiredApiPermissions;
412
+ const priorApi = new Set(apiPermissions(prior));
196
413
  const priorHosts = new Set(requiredHostPermissions(prior));
197
- const newApi = requiredApiPermissions(local).filter((p) => !priorApi.has(p));
414
+ const newApi = apiPermissions(local).filter((p) => !priorApi.has(p));
198
415
  const newHosts = requiredHostPermissions(local).filter((h) => !priorHosts.has(h));
199
- // Newly-added required API permissions that show a warning → auto-disable.
416
+ const declaredApi = new Set(requiredApiPermissions(local));
417
+ // Newly-added required API permissions that prompt → attrition. Each browser
418
+ // has its own list of which ones prompt.
200
419
  for (const perm of newApi) {
201
- const warning = PERMISSION_WARNINGS[perm];
420
+ const warning = firefox ? FIREFOX_PERMISSION_MESSAGES[perm] : PERMISSION_WARNINGS[perm];
202
421
  if (!warning)
203
422
  continue; // silently-granted permission — safe to add in an update
204
423
  findings.push({
205
424
  verdict: 'ATTRITION',
206
425
  check: 'auto-disable-new-permission',
207
- title: `Adding required "${perm}" silently disables the extension for existing users`,
208
- detail: `"${perm}" shows the warning "${warning}". When an update adds a required permission that warns, ` +
209
- 'the browser silently disables the extension for everyone who already has it installed and shows only ' +
210
- 'a small toolbar icon — most users never re-enable, so you lose them.',
211
- evidence: `new required permission "${perm}" (absent from the last published manifest)`,
212
- recommendation: `Move "${perm}" into optional_permissions and request it at runtime with chrome.permissions.request(), ` +
213
- 'so existing users keep the extension enabled.',
214
- citation: `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`,
426
+ ...(firefox ? firefoxNewPermission(perm, warning) : chromiumNewPermission(perm, warning, store)),
427
+ evidence: declaredApi.has(perm)
428
+ ? `new required permission "${perm}" (absent from the last published manifest)`
429
+ : `new devtools_page — Firefox grants "${perm}" with it (absent from the last published manifest)`,
215
430
  });
216
431
  }
217
432
  // Newly-added host permissions always warn ("Read and change your data on …")
218
- // → same auto-disable behaviour.
433
+ // → same attrition, except that Firefox grants none of them to an MV3 update.
219
434
  for (const host of newHosts) {
220
- const global = isGlobalHost(host);
221
435
  findings.push({
222
436
  verdict: 'ATTRITION',
223
437
  check: 'auto-disable-new-host',
224
- title: `Adding host access "${host}" silently disables the extension for existing users`,
225
- detail: `Host permissions show a "Read and change ${global ? 'all your data on all websites' : `your data on ${host}`}" ` +
226
- 'warning. Adding one in an update silently disables the extension for the installed base until each user ' +
227
- 're-accepts — only a small toolbar icon signals it.',
438
+ ...(firefox ? firefoxNewHost(host, local.manifest_version === 3) : chromiumNewHost(host, store)),
228
439
  evidence: `new host permission "${host}" (absent from the last published manifest)`,
229
- recommendation: 'Move it to optional_host_permissions and request it on a user gesture, or use activeTab if you only ' +
230
- 'need the active tab.',
231
- citation: `Chrome: permission warnings re-prompt users on update — ${DOC_PERMISSION_WARNINGS}`,
232
440
  });
233
441
  }
234
442
  // Any new permission also re-trips the slow review queue (ch.10), independent
235
- // of whether it warns. One consolidated note.
236
- if (newApi.length + newHosts.length > 0) {
443
+ // of whether it warns. One consolidated note. A Chrome Web Store process
444
+ // fact: Edge certifies every update the same way, AMO documents no such queue.
445
+ if (store === 'CHROME' && newApi.length + newHosts.length > 0) {
237
446
  findings.push({
238
447
  verdict: 'SLOW',
239
448
  check: 'update-adds-permissions',
@@ -533,13 +742,16 @@ export function checkServiceWorker(m, files) {
533
742
  // ── Check 5: activeTab advisor (ch.10) ───────────────────────────────────────
534
743
  /** True when a content script auto-injects into all pages (then activeTab can't replace its host perms). */
535
744
  function hasBroadContentScript(m) {
536
- for (const cs of m.content_scripts ?? []) {
537
- if (asStringArray(cs.matches).some(isGlobalHost))
538
- return true;
539
- }
540
- return false;
745
+ return globalContentScriptMatches(m).length > 0;
541
746
  }
542
- export function checkActiveTabAdvisor(m) {
747
+ /**
748
+ * Chrome Web Store only: the advice holds everywhere, but the verdict it is
749
+ * filed under — skipping the slow review queue — is a Chrome Web Store process
750
+ * fact (see `checkSlowReviewQueue`).
751
+ */
752
+ export function checkActiveTabAdvisor(m, store = 'CHROME') {
753
+ if (store !== 'CHROME')
754
+ return [];
543
755
  const global = requiredHostPermissions(m).filter(isGlobalHost);
544
756
  if (global.length === 0)
545
757
  return [];
@@ -785,7 +997,10 @@ const PERMISSION_DISCLOSURES = [
785
997
  * Optional permissions count: the declaration covers what the extension can
786
998
  * request, and an optional permission ships in the package and is grantable.
787
999
  */
788
- export function checkPrivacyDisclosure(manifest) {
1000
+ export function checkPrivacyDisclosure(manifest, store = 'CHROME') {
1001
+ // The questions it predicts are the Chrome Web Store dashboard's form.
1002
+ if (store !== 'CHROME')
1003
+ return [];
789
1004
  const held = new Set();
790
1005
  for (const key of ['permissions', 'optional_permissions']) {
791
1006
  const raw = manifest[key];
@@ -855,6 +1070,92 @@ export function privacyPracticesForManifest(manifest) {
855
1070
  }
856
1071
  return out;
857
1072
  }
1073
+ // ── Check: manifest field lengths, per locale ────────────────────────────────
1074
+ //
1075
+ // The store shows `name`, `short_name` and `description` as each locale
1076
+ // resolves them, and documents a maximum for each — so the limit binds every
1077
+ // `_locales/<lang>/messages.json`, not only the default one. Translations run
1078
+ // longer than English (German, Russian), which is exactly where a field that
1079
+ // fits in `en` stops fitting.
1080
+ //
1081
+ // Advisory (SLOW) like the listing rules: the stores document the numbers but
1082
+ // not what they do with a longer value, and a false REJECTED fails a release.
1083
+ const LENGTH_FIELDS = ['name', 'short_name', 'description'];
1084
+ const MSG_KEY_RE = /^__MSG_([A-Za-z0-9_@]+)__$/;
1085
+ /**
1086
+ * The text a store shows for one message: `$name$` placeholders replaced by
1087
+ * their `content`, `$$` by `$`. Message names are case-insensitive in the
1088
+ * WebExtensions i18n format, both the key and the placeholder name.
1089
+ */
1090
+ export function resolveMessage(messages, key) {
1091
+ const found = Object.entries(messages).find(([k]) => k.toLowerCase() === key.toLowerCase())?.[1];
1092
+ if (!found || typeof found !== 'object')
1093
+ return undefined;
1094
+ const { message, placeholders } = found;
1095
+ if (typeof message !== 'string' || !message.trim())
1096
+ return undefined;
1097
+ const named = placeholders && typeof placeholders === 'object' ? placeholders : {};
1098
+ return message.replace(/\$\$|\$([A-Za-z0-9_@]+)\$/g, (token, name) => {
1099
+ if (token === '$$')
1100
+ return '$';
1101
+ const entry = Object.entries(named).find(([k]) => k.toLowerCase() === name?.toLowerCase())?.[1];
1102
+ const content = entry && typeof entry === 'object' ? entry.content : undefined;
1103
+ return typeof content === 'string' ? content : token;
1104
+ });
1105
+ }
1106
+ export function checkManifestFieldLengths(manifest, files, store = 'CHROME') {
1107
+ const rules = storeRules(store);
1108
+ const messagesByLocale = readLocaleMessages(files);
1109
+ const out = [];
1110
+ for (const field of LENGTH_FIELDS) {
1111
+ const limit = rules.fieldLimits[field];
1112
+ const raw = manifest[field];
1113
+ if (!limit || typeof raw !== 'string' || !raw.trim())
1114
+ continue;
1115
+ const key = MSG_KEY_RE.exec(raw.trim())?.[1];
1116
+ const values = [];
1117
+ if (!key)
1118
+ values.push({ text: raw });
1119
+ // Code-point order, as Python's sorted() in the replay mirror: the per-check
1120
+ // cap keeps the first ten, so both sides must agree which ten.
1121
+ else
1122
+ for (const [locale, messages] of [...messagesByLocale].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
1123
+ const text = resolveMessage(messages, key);
1124
+ if (text)
1125
+ values.push({ locale, text });
1126
+ }
1127
+ for (const { locale, text } of values) {
1128
+ // Code points, as the store counters show them — `.length` would count
1129
+ // an emoji or a CJK extension character twice.
1130
+ const length = [...text].length;
1131
+ if (length <= limit.max)
1132
+ continue;
1133
+ const place = locale ? `_locales/${locale}/messages.json` : 'manifest.json';
1134
+ const recommended = field === 'short_name';
1135
+ out.push({
1136
+ verdict: 'SLOW',
1137
+ check: 'manifest-field-length',
1138
+ title: `${field} is ${length} characters${locale ? ` in [${locale}]` : ''} — ${rules.label} ${recommended ? 'recommends at most' : 'allows'} ${limit.max}`,
1139
+ detail: recommended
1140
+ ? `${rules.label} recommends at most ${limit.max} characters for short_name and truncates a longer one ` +
1141
+ 'where space is limited. The limit applies to every locale as that locale resolves it.'
1142
+ : `${rules.label} allows at most ${limit.max} characters in the manifest ${field}` +
1143
+ (store === 'FIREFOX' && field === 'description'
1144
+ ? ', which AMO shows as the add-on summary'
1145
+ : '') +
1146
+ ', and the limit applies to every locale as that locale resolves it. What happens to a longer ' +
1147
+ 'value is not documented — it may be refused at upload or cut off where it is shown — so shorten ' +
1148
+ 'it before submitting.',
1149
+ evidence: `[${locale ?? 'manifest'}] ${field}: ${length} characters — "${[...text].slice(0, 60).join('')}${length > 60 ? '…' : ''}"`,
1150
+ recommendation: key
1151
+ ? `Shorten "${key}" in ${place} to ${limit.max} characters or fewer.`
1152
+ : `Shorten ${field} in manifest.json to ${limit.max} characters or fewer.`,
1153
+ citation: `${rules.label}: ${field} length limit — ${limit.source}`,
1154
+ });
1155
+ }
1156
+ }
1157
+ return out;
1158
+ }
858
1159
  /**
859
1160
  * Drop exact duplicates (same check + same evidence — a single bundled pattern
860
1161
  * copied across N entry chunks would otherwise become N "REJECTED" rows) and cap
@@ -893,18 +1194,20 @@ function dedupeAndCap(findings) {
893
1194
  return out;
894
1195
  }
895
1196
  export function runChecks(input) {
1197
+ const store = input.store ?? 'CHROME';
896
1198
  return withPolicies(dedupeAndCap([
897
1199
  ...checkRemoteCode(input.files),
898
1200
  ...checkHtmlCsp(input.manifest, input.files),
899
1201
  ...checkServiceWorker(input.manifest, input.files),
900
- ...checkAutoDisableOnUpdate(input.manifest, input.priorManifest),
901
- ...checkSlowReviewQueue(input.manifest),
902
- ...checkActiveTabAdvisor(input.manifest),
1202
+ ...checkAutoDisableOnUpdate(input.manifest, input.priorManifest, store),
1203
+ ...checkSlowReviewQueue(input.manifest, store),
1204
+ ...checkActiveTabAdvisor(input.manifest, store),
903
1205
  ...checkUnusedPermissions(input.manifest, input.files),
904
- ...checkPrivacyDisclosure(input.manifest),
905
- ...runListingChecks(input.listing ?? null),
906
- ...checkListingChangeReReview(),
907
- ]));
1206
+ ...checkPrivacyDisclosure(input.manifest, store),
1207
+ ...checkManifestFieldLengths(input.manifest, input.files, store),
1208
+ ...runListingChecks(input.listing ?? null, storeRules(store)),
1209
+ ...checkListingChangeReReview(store),
1210
+ ]), store);
908
1211
  }
909
1212
  /**
910
1213
  * The store-listing rules alone — deduped, capped and policy-stamped exactly as
@@ -928,8 +1231,8 @@ export function runChecks(input) {
928
1231
  * ever reuses an id across the two sets breaks that, and would make `scan` and
929
1232
  * `review-risk` disagree on counts.
930
1233
  */
931
- export function runListingOnlyChecks(listing) {
932
- return withPolicies(dedupeAndCap(runListingChecks(listing)));
1234
+ export function runListingOnlyChecks(listing, store = 'CHROME') {
1235
+ return withPolicies(dedupeAndCap(runListingChecks(listing, storeRules(store))), store);
933
1236
  }
934
1237
  /**
935
1238
  * Stamp each finding with the store policy its check is registered against.
@@ -939,8 +1242,13 @@ export function runListingOnlyChecks(listing) {
939
1242
  * pass because it only sees the registry. An unregistered check id is left
940
1243
  * without a policy rather than guessed at — the test catches it, and until then
941
1244
  * the report says nothing instead of saying something wrong.
1245
+ *
1246
+ * The registry is the Chrome Web Store's, so findings for another store carry
1247
+ * no `policy`: their `citation` names that store's own rule.
942
1248
  */
943
- function withPolicies(findings) {
1249
+ function withPolicies(findings, store = 'CHROME') {
1250
+ if (store !== 'CHROME')
1251
+ return findings;
944
1252
  return findings.map((f) => {
945
1253
  const policy = policyForCheck(f.check);
946
1254
  return policy === undefined ? f : { ...f, policy };
@@ -951,8 +1259,11 @@ const VERDICT_ORDER = { REJECTED: 0, ATTRITION: 1, SLOW: 2 };
951
1259
  * Ruleset version stamped on a persisted `review_risk` prediction.
952
1260
  * Bump when a check is added, removed, or its verdict changes — otherwise
953
1261
  * iteration 4 averages incompatible rules into one precision number.
1262
+ *
1263
+ * '2' (2026-10-10): `global-host-permissions` also reads content_scripts
1264
+ * matches, and `manifest-field-length` is new.
954
1265
  */
955
- export const REVIEW_RISK_RULES_VERSION = '1';
1266
+ export const REVIEW_RISK_RULES_VERSION = '2';
956
1267
  export function summarize(findings) {
957
1268
  const sorted = [...findings].sort((a, b) => VERDICT_ORDER[a.verdict] - VERDICT_ORDER[b.verdict]);
958
1269
  const counts = { REJECTED: 0, ATTRITION: 0, SLOW: 0 };