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