webstore-lint 1.0.20

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Circadian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # webstore-lint
2
+
3
+ Find out why the Chrome Web Store rejected your extension, and check an unpacked
4
+ extension against the program policies before you submit or resubmit it.
5
+
6
+ ```bash
7
+ npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension
8
+ ```
9
+
10
+ Every finding cites the notification ID Google will actually send you (the
11
+ colour-metal codenames like `Blue Argon` and `Purple Potassium`), the verbatim
12
+ policy text, and Google's own stated fix. No dependencies. Nothing leaves your
13
+ machine.
14
+
15
+ ### Point it at the built extension, not at your project root
16
+
17
+ `./my-extension` means the directory holding the `manifest.json` you actually
18
+ ship: the unpacked build, or the contents of the zip you upload. **For most
19
+ projects that is not the repository root.** We checked 160 public extension
20
+ repositories and **90 of them had no loadable extension checked in at all**,
21
+ because the manifest is assembled by the build. Of those, 45 are a framework
22
+ build: 18 wxt, 14 Plasmo, 8 CRXJS, and the rest bare webpack or Vite.
23
+
24
+ So run your build first, then point at its output. Common ones:
25
+
26
+ ```bash
27
+ npm run build && npx github:Circadian-agent/chrome-extension-rejection-lint ./dist
28
+ ```
29
+
30
+ `dist/`, `build/`, `.output/chrome-mv3/` (wxt) and `build/chrome-mv3-prod/`
31
+ (Plasmo) are the usual names. If you keep several manifests, one per browser, use
32
+ the directory that contains the Chrome one.
33
+
34
+ **You should not have to work this out yourself, and mostly you do not.** If the
35
+ directory is a wxt, Plasmo, CRXJS, vite-plugin-web-extension or
36
+ webextension-toolbox project, the tool names your framework, the build command
37
+ and the exact output directory to point at instead. If you have already built,
38
+ it says so and gives you that path directly.
39
+
40
+ If you see the bare `no manifest.json in this directory` with no build advice,
41
+ that is the check working on a directory it does not recognise: there is no
42
+ extension in it, and no policy check it could run there would mean anything.
43
+
44
+ **Not on npm yet, which is why the command names GitHub.** The package is ready,
45
+ but npm requires a human registrant and this tool is maintained by an AI agent,
46
+ so that step waits on a person rather than on us working around the terms. `npx`
47
+ installs straight from this repo in the meantime and there is nothing to fetch:
48
+ the package has no dependencies. Once the name is published, `npx webstore-lint`
49
+ will be the shorter form for the same thing.
50
+
51
+ Prefer a clone, or working offline:
52
+
53
+ ```bash
54
+ git clone https://github.com/Circadian-agent/chrome-extension-rejection-lint
55
+ node webstore-lint/bin/webstore-lint.mjs ./my-extension
56
+ ```
57
+
58
+ ## In GitHub Actions
59
+
60
+ ```yaml
61
+ - uses: Circadian-agent/chrome-extension-rejection-lint@v1
62
+ with:
63
+ path: ./extension
64
+ ```
65
+
66
+ Findings land on the offending line of the pull request diff, and the verdict
67
+ goes on the checks tab, so a policy violation is visible where the review already
68
+ is rather than in a build log nobody opens.
69
+
70
+ | input | default | what it does |
71
+ |---|---|---|
72
+ | `path` | `.` | Directory holding `manifest.json`, or the unzipped package. |
73
+ | `fail-on` | `fail` | `fail` blocks only on policy violations. `warn` also blocks on findings that need a human judgement. `never` reports without blocking. |
74
+ | `privacy-policy` | none | Your listing's privacy policy URL. Setting it adds a check that the page answers, and it is the only input that makes a network request. |
75
+ | `annotations` | `true` | Set to `false` for the job summary only. |
76
+
77
+ Outputs `fail`, `warn` and `info` are the counts, for a later step. `unreadable`
78
+ is `true` when the extension could not be read at all.
79
+
80
+ **An extension that could not be read fails the step whatever `fail-on` says**,
81
+ and reports `unreadable: true`. That case is a typo in `path` far more often than
82
+ it is anything else, and a green check on a package nothing ever opened is the
83
+ one outcome worth refusing outright. It is reported separately from a clean run
84
+ because the two are opposite facts.
85
+
86
+ ## Why the codenames matter
87
+
88
+ Google publishes a complete list of extension rejection reasons on one page, and
89
+ calls them **notification IDs**. Rejection emails apparently call them
90
+ **violation reference IDs**. Everyone else calls them codenames. That three-way
91
+ terminology split is most of the reason they are so hard to look up when you are
92
+ staring at one.
93
+
94
+ There are 36 of them across 27 violation categories. This package carries all
95
+ 27, each verified against Google's own HTML rather than a summary of it.
96
+
97
+ ```bash
98
+ node bin/webstore-lint.mjs --policy # list every category and codename
99
+ ```
100
+
101
+ <!-- CODENAMES:START -->
102
+
103
+ All 36 codenames across 27 categories, generated from
104
+ [the open dataset](https://circadian-agent.com/data/chrome-rejection-ids.json)
105
+ (CC BY 4.0). Every quote behind these was fetched from developer.chrome.com and
106
+ re-checked against the raw HTML.
107
+
108
+ | Codename | Category | What it actually means |
109
+ |---|---|---|
110
+ | [Blue Argon](https://circadian-agent.com/research/chrome-rejections/blue-argon) | Additional requirements for Manifest V3 | Your Manifest V3 extension is loading or executing code that is not inside the package you submitted. |
111
+ | [Blue Copper](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
112
+ | [Blue Lithium](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
113
+ | [Blue Magnesium](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
114
+ | [Blue Nickel](https://circadian-agent.com/research/chrome-rejections/circumvents-the-overrides-api) | Circumvents the overrides API | You changed the New Tab Page or omnibox search by some route other than the official Overrides API. |
115
+ | [Blue Potassium](https://circadian-agent.com/research/chrome-rejections/circumvents-the-overrides-api) | Circumvents the overrides API | You changed the New Tab Page or omnibox search by some route other than the official Overrides API. |
116
+ | [Blue Titanium](https://circadian-agent.com/research/chrome-rejections/blue-titanium) | Enforcement circumvention | You tried to dodge a review or an enforcement action. This is the one that ends accounts. |
117
+ | [Blue Zinc](https://circadian-agent.com/research/chrome-rejections/prohibited-products) | Prohibited products | The extension gets round paywalls or logins, or enables downloading content the user has no right to. |
118
+ | [Grey Copper](https://circadian-agent.com/research/chrome-rejections/grey-copper) | Online gambling | The extension provides, facilitates, or directs users to real money gambling or prediction markets. |
119
+ | [Grey Lithium](https://circadian-agent.com/research/chrome-rejections/grey-lithium) | Pornographic content | The extension contains, serves, or exists mainly to enhance sexually explicit material. |
120
+ | [Grey Magnesium](https://circadian-agent.com/research/chrome-rejections/grey-magnesium) | Hate content | The extension carries or points to hate speech, or lacks moderation for user-generated content that does. |
121
+ | [Grey Nickel](https://circadian-agent.com/research/chrome-rejections/grey-nickel) | Not family safe | Your extension has adult-ish content but you never ticked the Mature box. |
122
+ | [Grey Potassium](https://circadian-agent.com/research/chrome-rejections/grey-potassium) | Violent content | The extension carries or points to gratuitously violent, threatening, harassing or bullying content. |
123
+ | [Grey Silicon](https://circadian-agent.com/research/chrome-rejections/grey-silicon) | Cryptocurrency mining | The extension mines cryptocurrency, or gives users the ability to. |
124
+ | [Grey Titanium](https://circadian-agent.com/research/chrome-rejections/grey-titanium) | Affiliate Ads | You inject affiliate links, codes or cookies without disclosing it and without the user doing something that would lead them to expect it. |
125
+ | [Grey Zinc](https://circadian-agent.com/research/chrome-rejections/grey-zinc) | Illegal activities | The extension engages in or promotes unlawful activity. |
126
+ | [Purple Copper](https://circadian-agent.com/research/chrome-rejections/purple-copper) | User data policy - secure transmission | You send user data over an insecure channel, or leak it in URLs and headers. |
127
+ | [Purple Lithium](https://circadian-agent.com/research/chrome-rejections/purple-lithium) | User data policy - disclosure policy | You collect user data but your privacy policy is missing, unreachable, in the wrong field, or does not actually describe data handling. |
128
+ | [Purple Magnesium](https://circadian-agent.com/research/chrome-rejections/purple-magnesium) | User data policy - other requirements | You collect browsing activity you do not need for a visible feature, or you expose sensitive data publicly. |
129
+ | [Purple Nickel](https://circadian-agent.com/research/chrome-rejections/purple-nickel) | User data policy - prominent disclosure | You collect user data without prominently telling the user first and getting their consent. |
130
+ | [Purple Potassium](https://circadian-agent.com/research/chrome-rejections/purple-potassium) | Excessive permissions | You asked for a permission you do not use, or a broader one than the job needs. |
131
+ | [Red Argon](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
132
+ | [Red Copper](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
133
+ | [Red Lithium](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
134
+ | [Red Magnesium](https://circadian-agent.com/research/chrome-rejections/single-purpose) | Single purpose | Your extension does two or more unrelated things and needs to be split into separate extensions. |
135
+ | [Red Nickel](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
136
+ | [Red Potassium](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
137
+ | [Red Silicon](https://circadian-agent.com/research/chrome-rejections/deceptive-behavior) | Deceptive behavior | What the extension does and what its listing says do not match, or it passes itself off as someone else's product. |
138
+ | [Red Titanium](https://circadian-agent.com/research/chrome-rejections/red-titanium) | Obfuscation | Your submitted code is obfuscated. Minification is fine, obfuscation is not. |
139
+ | [Red Zinc](https://circadian-agent.com/research/chrome-rejections/red-zinc) | Deceptive installation | How you got users to install the extension was misleading, regardless of what the extension itself does. |
140
+ | [Yellow Argon](https://circadian-agent.com/research/chrome-rejections/yellow-argon) | Keyword stuffing | Your description is padded with keywords, site lists or locations to game search ranking. |
141
+ | [Yellow Lithium](https://circadian-agent.com/research/chrome-rejections/yellow-lithium) | Redirection | The extension is a shortcut. All it does is open a website or another product. |
142
+ | [Yellow Magnesium](https://circadian-agent.com/research/chrome-rejections/yellow-magnesium) | Functionality not working | The reviewer could not get your extension to do what your listing says it does, or your package is broken. |
143
+ | [Yellow Nickel](https://circadian-agent.com/research/chrome-rejections/yellow-nickel) | Spam | Duplicate extensions, manipulated ratings or installs, notification abuse, or messages sent as the user. |
144
+ | [Yellow Potassium](https://circadian-agent.com/research/chrome-rejections/yellow-potassium) | Minimum Functionality | The extension is too thin to be worth listing, or it just links out to a service that does the actual work. |
145
+ | [Yellow Zinc](https://circadian-agent.com/research/chrome-rejections/yellow-zinc) | Missing or insufficient metadata | Your listing is missing an icon, title, screenshots or description, or what is there does not explain the extension. |
146
+
147
+ <!-- CODENAMES:END -->
148
+
149
+ ## The 1 August 2026 policy updates
150
+
151
+ Four policies changed. Enforcement begins on 1 August 2026, in Google's words:
152
+
153
+ > Enforcement for these updated policies will begin on August 1, 2026. Extensions
154
+ > found out of compliance after this date may face enforcement action from the
155
+ > Chrome Web Store.
156
+
157
+ The linter carries all four, and it distinguishes what is enforceable from what
158
+ was only announced:
159
+
160
+ | Change | In live policy text |
161
+ |---|---|
162
+ | Regulated Goods and Services (prediction markets) | yes |
163
+ | Limited Use Policy | yes |
164
+ | Disclosure Requirements Policy | yes |
165
+ | Malicious and Prohibited Products (AI guardrail circumvention) | **no** |
166
+
167
+ Three things worth knowing that the announcement does not tell you, all of which
168
+ the tool prints when relevant:
169
+
170
+ 1. **The disclosure rule got much wider.** It used to bite only when the data was
171
+ not closely related to your single purpose. The live policy now covers any
172
+ user data, disclosed before installation. Meanwhile Google's own
173
+ troubleshooting page for `Purple Nickel` still states the old, narrower rule
174
+ and still tells developers that undisclosed collection is fine when it matches
175
+ the single purpose. That guidance is contradicted by the policy it is meant to
176
+ explain.
177
+ 2. **The prediction markets ban has a carve-out the announcement omits entirely.**
178
+ Simulated markets with no real money winnings may be allowed, if you clearly
179
+ say no real money is involved.
180
+ 3. **The AI guardrail clause is not in any policy page.** It exists only in the
181
+ 1 July blog post. The words "guardrail" and "AI-powered" appear on no Chrome
182
+ Web Store policy page, and the live Malicious and Prohibited Products page
183
+ still carries a 2022 last-updated date. So the tool reports it as
184
+ informational, not as a failure. There is nothing enforceable to quote at you,
185
+ and equally nothing to rely on.
186
+
187
+ ## What it checks
188
+
189
+ Statically, from the package you are about to upload:
190
+
191
+ - Manifest V2 (no longer accepted), remote code, `eval`, `new Function`, remote
192
+ dynamic `import` (`Blue Argon`)
193
+ - Permissions declared but never used, and access to every site
194
+ (`Purple Potassium`)
195
+ - Missing description or icons, descriptions too short to state a purpose
196
+ (`Yellow Zinc`)
197
+ - Plain `http://` endpoints, excluding localhost (`Purple Copper`)
198
+ - Cryptocurrency mining (`Grey Silicon`)
199
+ - Obfuscation, by the `_0x` identifier signature. Minification is explicitly
200
+ allowed by Google and is not reported (`Red Titanium`)
201
+ - Keyword stuffing in the description (`Yellow Argon`)
202
+ - New Tab Page changes made outside the official override API (`Blue Nickel`)
203
+ - The four 1 August 2026 changes above
204
+
205
+ ### What the remote code check cannot see, and it is worth knowing before you rely on it
206
+
207
+ It finds a remote script URL that is **written down** in your package: in a
208
+ `<script src>` tag, assigned to an element's `src`, sat in a config key, or
209
+ handed to a dynamic `import()`. That covers the common case and it reads inside
210
+ bundled dependencies, which is where this usually comes from.
211
+
212
+ **It does not find a URL that your code assembles at runtime from parts**, and
213
+ some libraries now do exactly that. Current `mixpanel-browser` builds its
214
+ recorder URL by joining a base path to a content hashed filename, so neither
215
+ half is a remote script URL on its own and there is nothing to match.
216
+
217
+ This is a real limit rather than a bug we have not got to yet. We built the rule
218
+ that would catch it and measured it against 245 real Manifest V3 extensions: the
219
+ code shape it keys on is the ordinary way an extension loads one of its **own**
220
+ bundled files, so it fired on 44 places in 14 extensions, and the first 6 we
221
+ opened and read were all innocent. Whether a script is local or remote depends on a value that
222
+ usually reaches the loader through a variable, often from another function.
223
+ A static text scan cannot follow that, so we would rather miss it than tell you
224
+ your extension breaks a policy it does not.
225
+
226
+ **What to do instead:** check which entry point of an analytics or session
227
+ replay library you import, since that is normally where the choice is made, and
228
+ it is decidable by reading your own import line. Working through one such case
229
+ in public: https://github.com/mixpanel/mixpanel-js/issues/428#issuecomment-5145232728
230
+
231
+ **Where we can answer it, we do, and Flutter is the worked example.** A Flutter
232
+ web build assembles its renderer URL at runtime exactly as described above, so
233
+ none of the patterns in the list finds it, and developers are rejected for it:
234
+ see flutter/flutter#84288. But the thing that decides the verdict is in the
235
+ package rather than in the expression. `flutter build web
236
+ --no-web-resources-cdn` copies the renderer into a local `canvaskit/` folder and
237
+ records `useLocalCanvasKit` in `flutter_bootstrap.js`, so we check for those
238
+ instead of trying to read the URL. Note what that means: the
239
+ `www.gstatic.com/flutter-canvaskit` string is in `flutter.js` either way, so the
240
+ presence of the URL is **not** what we report. When a runtime URL has a
241
+ package level tell, that is the thing to key on.
242
+
243
+ ## The permission ledger
244
+
245
+ ```bash
246
+ node bin/webstore-lint.mjs ./my-extension --permissions
247
+ ```
248
+
249
+ The Chrome Web Store dashboard makes you write a justification for every
250
+ permission you request, and `Purple Potassium` is what comes back when those are
251
+ thin. A justification is only as good as the evidence under it, so this mode
252
+ answers the question the linter cannot: **what in your code actually requires
253
+ this permission, and would a narrower one have done?**
254
+
255
+ That second half is the comparative question Google's policy actually asks:
256
+
257
+ > If more than one permission could be used to implement a feature, you must
258
+ > request those with the least access to data or functionality.
259
+
260
+ So it prints, per permission, the exact call sites that require it, and where a
261
+ narrower option exists it names it with the evidence:
262
+
263
+ - `tabs` reaching only for the tab the user just acted on, where `activeTab`
264
+ grants that on a gesture with no install warning and no host permission
265
+ - `storage` declared while the code only ever calls `localStorage`, which needs
266
+ no permission at all
267
+ - `webRequest` where `declarativeNetRequest` covers it without the extension
268
+ seeing the traffic
269
+ - `<all_urls>` against the hosts your code actually names, which is usually a
270
+ short list and removes the "Read and change all your data on all websites"
271
+ warning
272
+
273
+ It also derives your Privacy practices answers from what the code touches rather
274
+ than from memory.
275
+
276
+ **Every suggestion carries the condition that would make it wrong.** These are
277
+ recommendations to delete a permission, so a false one breaks a working
278
+ extension. If you observe tabs the user has not touched, `activeTab` cannot do
279
+ it, and the tool says so instead of suggesting it. Three honesty rules hold
280
+ throughout: a permission with no pattern in the tool is reported `unknown` and
281
+ never `unused`; minified bundles defeat call-site evidence and are declared as
282
+ such rather than reported as a confident zero; and unread or skipped files are
283
+ named, because an absence is only as broad as where you looked.
284
+
285
+ ## Severities, and why warnings do not fail the build
286
+
287
+ - **fail** - the package contains something Google names as a trigger. Exit
288
+ code 1.
289
+ - **warn** - a condition that needs something this tool cannot see: your store
290
+ listing, your privacy policy page, your screenshots. Unresolvable locally by
291
+ construction, so it never fails the run. A CI gate that cannot be satisfied
292
+ locally gets switched off, and a switched-off linter is worth less than none.
293
+ - **info** - announced but not yet in any live policy page.
294
+
295
+ ## Checking your privacy policy URL
296
+
297
+ ```bash
298
+ npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension \
299
+ --privacy-policy https://example.com/privacy
300
+ ```
301
+
302
+ This is the only flag that touches the network, and only when you pass a URL.
303
+ Without it the tool opens no sockets at all.
304
+
305
+ It exists because of a real rejection. A developer was rejected under **Purple
306
+ Lithium** and the cause was mundane: the privacy policy URL in their listing
307
+ 404'd, because the GitHub repository serving it was private. It looks perfectly
308
+ fine while you are logged in. Google's own triggers for that category include
309
+ *"The privacy policy URL is not working"*, *"The privacy policy is not
310
+ accessible"* and *"The privacy policy URL is not leading to privacy policy"*.
311
+
312
+ What it reports:
313
+
314
+ - **fail** - the URL is not a URL, uses a scheme a browser cannot open, points at
315
+ a loopback or private address nobody outside your network can reach, or answers
316
+ anything other than 2xx.
317
+ - **warn** - the URL is plain http; or it answers 200 but the page does not read
318
+ like a policy; or it could not be reached from here at all. That last one is a
319
+ warning rather than a failure on purpose: your network failing is not evidence
320
+ about the address.
321
+ - **info** - it is reachable, and where it redirected to.
322
+
323
+ It checks *reachability*. It does not judge whether your policy is adequate,
324
+ which is the thing a reviewer actually reads it for.
325
+
326
+ ## What it cannot do
327
+
328
+ It reads your package. It cannot see your store listing, your screenshots or your
329
+ support site, and several policies are satisfied in exactly those places. With
330
+ `--privacy-policy` it can tell you whether your policy URL answers, and nothing
331
+ about whether the words on it are sufficient. It cannot install your extension or
332
+ check that it does what you say. It is not affiliated with, endorsed by or
333
+ connected to Google.
334
+
335
+ **A clean run is not a promise of approval.** It means the package does not
336
+ contain the static signals Google names. Reviewers are human and see more than a
337
+ file tree.
338
+
339
+ ## Provenance
340
+
341
+ Policy data is generated from a dataset gathered on 2026-07-29 by direct HTTPS
342
+ fetch of Google's pages, parsed from raw HTML, with 278 verbatim fields
343
+ re-checked programmatically against the raw bytes. Nothing in `data/policy.json`
344
+ is admitted unless it is marked `VERIFIED_GOOGLE`.
345
+
346
+ That method was not paranoia. The first pass over the troubleshooting page, done
347
+ through a summarising model, produced a fabricated policy quote and an invented
348
+ source structure. A second independent sweep invented a codename that does not
349
+ exist. Two summarisation passes each produced publishable-looking, wrong content,
350
+ which is why every quote here is re-derived from raw source.
351
+
352
+ ## Licence
353
+
354
+ MIT. Built by [Circadian](https://circadian-agent.com), an autonomous AI agent
355
+ business operating under human oversight. Corrections are welcome and wanted: if
356
+ a rule fires on compliant code, that is a bug worth more than a missed finding.
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ // The GitHub Action entry point: run the linter, then say what it found in the
3
+ // two places GitHub actually shows a developer.
4
+ //
5
+ // WHY THIS EXISTS AT ALL, since `npx webstore-lint .` in a `run:` step already
6
+ // works. That step puts its answer in the build log, which nobody opens unless
7
+ // something is already red. This one writes findings as ANNOTATIONS, so a
8
+ // remote-code finding appears on the offending line of popup.html in the pull
9
+ // request diff, and as a JOB SUMMARY, so the verdict is on the checks tab. The
10
+ // evidence the linter already collects carries a file and a line for exactly
11
+ // this, and until now nothing consumed it.
12
+ //
13
+ // WHY ALL THE LOGIC IS HERE AND THE YAML IS ONE LINE. Shell inside a composite
14
+ // action is the least testable surface in the whole package: it cannot be run by
15
+ // the suite, and it fails at the worst moment, on somebody else's runner. So
16
+ // action.yml does nothing but call this file, and this file is covered by
17
+ // test/action.test.mjs like everything else.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import { appendFileSync, readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+
26
+ // Resolved from the env run() is GIVEN, not from process.env at import time.
27
+ // Reading it at module scope meant the seam existed but did nothing: the tests
28
+ // that point this at a deliberately broken linter went on running the real one
29
+ // and passed on a clean fixture, so the check that a failed scan never reports
30
+ // green was itself never exercised.
31
+ const cliPath = (env) => env.WSL_CLI || join(HERE, "..", "bin", "webstore-lint.mjs");
32
+
33
+ const FAIL_ON = new Set(["fail", "warn", "never"]);
34
+
35
+ // A WORKFLOW COMMAND IS NOT PLAIN TEXT. Newlines end the command, so a multi-line
36
+ // detail would truncate the annotation and leave the rest of the sentence sitting
37
+ // in the build log as if it were output. Percent has to go first or it would
38
+ // re-encode the escapes written after it.
39
+ const encData = (s) =>
40
+ String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
41
+
42
+ // Property values escape two more characters, because the property list is
43
+ // comma-separated with colon-terminated keys. A finding titled "Declared but
44
+ // never used: bookmarks, downloads" would otherwise be read as extra properties.
45
+ const encProp = (s) => encData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
46
+
47
+ export function annotation(f, e) {
48
+ const level = f.severity === "fail" ? "error" : f.severity === "warn" ? "warning" : "notice";
49
+ const props = [`title=${encProp(`webstore-lint: ${f.title}`)}`];
50
+ // Not all evidence is a place in a file. The privacy policy check's evidence is
51
+ // a url and an HTTP status, and a manifest-level finding has no evidence at
52
+ // all. GitHub accepts an annotation with no file, and inventing one would point
53
+ // a developer at a line that has nothing to do with the finding.
54
+ if (e?.file) {
55
+ props.push(`file=${encProp(e.file)}`);
56
+ if (e.line) {
57
+ props.push(`line=${encProp(e.line)}`);
58
+ props.push(`col=1`);
59
+ }
60
+ }
61
+ const cite = f.citation?.notificationIds?.length
62
+ ? ` [${f.citation.notificationIds.join(", ")}]`
63
+ : "";
64
+ // Google's policy page first, because it is the authority; the explainer
65
+ // second, because it is what the codename actually means and how to fix it.
66
+ const body = [f.detail, f.citation?.policyUrl, f.citationUrl].filter(Boolean).join("\n");
67
+ return `::${level} ${props.join(",")}::${encData(`${f.title}${cite}\n${body}`)}`;
68
+ }
69
+
70
+ // The summary footer dates the policy data, so a reader can tell a stale build
71
+ // from a current one. It is cosmetic, and a build with an unreadable data file
72
+ // should still report its findings, so a failure here drops the footer rather
73
+ // than the report.
74
+ function readPolicyMeta() {
75
+ try {
76
+ const p = JSON.parse(readFileSync(join(HERE, "..", "data", "policy.json"), "utf8"));
77
+ return { datasetPulledAt: p.datasetPulledAt, enforcedFrom: p.enforcedFrom };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export function summary(result, policy) {
84
+ const { fail, warn, info } = result.counts;
85
+ const lines = [];
86
+ lines.push(`## webstore-lint`);
87
+ lines.push("");
88
+ if (result.manifestError) {
89
+ lines.push(`**The extension could not be read.** ${result.manifestError}`);
90
+ lines.push("");
91
+ lines.push(
92
+ "Nothing was checked, so this is not a clean result. Point the action at the " +
93
+ "directory holding manifest.json with the `path` input.",
94
+ );
95
+ return lines.join("\n");
96
+ }
97
+ lines.push(
98
+ `**${fail} failing, ${warn} needing your judgement, ${info} informational** in \`${result.root}\``,
99
+ );
100
+ lines.push("");
101
+ if (!result.findings.length) {
102
+ lines.push("No findings. This reads your package only. Your store listing, privacy policy page");
103
+ lines.push("and screenshots are where several policies are actually satisfied, and no local");
104
+ lines.push("tool can see them, so a clean run is not a promise of approval.");
105
+ return lines.join("\n");
106
+ }
107
+ const MARK = { fail: "FAIL", warn: "WARN", info: "INFO" };
108
+ lines.push("| | Finding | Where | Policy |");
109
+ lines.push("|---|---|---|---|");
110
+ for (const f of result.findings) {
111
+ const where = (f.evidence || [])
112
+ .slice(0, 3)
113
+ .map((e) => (e.file ? `\`${e.file}${e.line ? ":" + e.line : ""}\`` : ""))
114
+ .filter(Boolean)
115
+ .join(" ");
116
+ const more = (f.evidence || []).length > 3 ? ` +${f.evidence.length - 3}` : "";
117
+ // The codename links to Google, who decide; "explained" links to our page
118
+ // for that exact codename. Both, never one instead of the other - replacing
119
+ // the authority with ourselves would be the wrong trade in a compliance tool.
120
+ const cite = f.citation?.notificationIds?.length
121
+ ? `[${f.citation.notificationIds.join(", ")}](${f.citation.policyUrl})` +
122
+ (f.citationUrl ? ` - [explained](${f.citationUrl})` : "")
123
+ : "";
124
+ // A pipe inside a cell would start a new column and shear the table.
125
+ const cell = (s) => String(s).replace(/\|/g, "\\|").replace(/\n/g, " ");
126
+ lines.push(`| ${MARK[f.severity]} | ${cell(f.title)} | ${where}${more} | ${cite} |`);
127
+ }
128
+ lines.push("");
129
+ if (policy?.enforcedFrom) {
130
+ lines.push(`Policy data pulled ${policy.datasetPulledAt}, ${policy.enforcedFrom} updates included.`);
131
+ }
132
+ lines.push("");
133
+ lines.push("Run it yourself: `npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension`");
134
+ return lines.join("\n");
135
+ }
136
+
137
+ // EXIT CODE. `fail-on` governs FINDINGS, and an unreadable extension is not a
138
+ // finding - it is the tool not having done its job. So it fails whatever
139
+ // `fail-on` says. The alternative is that somebody types the path wrong, sets
140
+ // `fail-on: never` for advisory annotations, and gets a green check on an
141
+ // extension that was never opened. This package's own rule, from bin/: a tool
142
+ // that says "clean" about a question it never answered is worse than one that
143
+ // refuses.
144
+ export function exitCodeFor(result, failOn) {
145
+ if (result.manifestError) return 1;
146
+ if (failOn === "never") return 0;
147
+ if (failOn === "warn") return result.counts.fail + result.counts.warn ? 1 : 0;
148
+ return result.counts.fail ? 1 : 0;
149
+ }
150
+
151
+ export function run({ env = process.env, out = console.log, err = console.error } = {}) {
152
+ const failOn = (env.WSL_FAIL_ON || "fail").trim();
153
+ // Same reasoning as the CLI's unknown-flag check, applied at a new call site:
154
+ // a value we do not recognise must not quietly become the default. "failure",
155
+ // "true" and "all" are all things a person would plausibly type here, and each
156
+ // of them silently meaning "fail" is a build that does not block when its
157
+ // author believes it does.
158
+ if (!FAIL_ON.has(failOn)) {
159
+ err(`fail-on: ${JSON.stringify(failOn)} is not one of ${[...FAIL_ON].join(", ")}.`);
160
+ err(`Nothing was checked. Fix the value in your workflow file.`);
161
+ return 2;
162
+ }
163
+ const annotate = (env.WSL_ANNOTATIONS || "true").trim() !== "false";
164
+
165
+ const args = [cliPath(env), env.WSL_PATH || ".", "--json"];
166
+ const url = (env.WSL_PRIVACY_POLICY || "").trim();
167
+ if (url) args.push("--privacy-policy", url);
168
+
169
+ const r = spawnSync(process.execPath, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
170
+
171
+ // THE LOAD-BEARING CHECK. "No findings" and "the linter never produced a
172
+ // report" print the same empty list if you are careless, and the second one
173
+ // must never come out green. So the report is only believed when it parses and
174
+ // carries the counts object the linter always writes; anything else is the
175
+ // scan having failed, and it is reported as that rather than as a clean run.
176
+ let result = null;
177
+ try {
178
+ const parsed = JSON.parse(r.stdout);
179
+ if (parsed && typeof parsed === "object" && parsed.counts && typeof parsed.counts.fail === "number") {
180
+ result = parsed;
181
+ }
182
+ } catch {
183
+ /* handled below */
184
+ }
185
+ if (!result) {
186
+ err("webstore-lint did not produce a report, so nothing was checked.");
187
+ if (r.error) err(String(r.error.message || r.error));
188
+ if (r.stderr) err(r.stderr.trim());
189
+ if (r.stdout) err(`unparsed output: ${r.stdout.slice(0, 2000)}`);
190
+ err(`exit code was ${r.status}`);
191
+ return 1;
192
+ }
193
+
194
+ if (annotate) {
195
+ for (const f of result.findings) {
196
+ const ev = (f.evidence || []).length ? f.evidence.slice(0, 10) : [null];
197
+ for (const e of ev) out(annotation(f, e));
198
+ }
199
+ }
200
+
201
+ const md = summary(result, readPolicyMeta());
202
+ if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, md + "\n");
203
+ else out(md);
204
+
205
+ if (env.GITHUB_OUTPUT) {
206
+ const { fail, warn, info } = result.counts;
207
+ appendFileSync(
208
+ env.GITHUB_OUTPUT,
209
+ `fail=${fail}\nwarn=${warn}\ninfo=${info}\nunreadable=${result.manifestError ? "true" : "false"}\n`,
210
+ );
211
+ }
212
+
213
+ const code = exitCodeFor(result, failOn);
214
+ if (code === 1 && result.manifestError) {
215
+ err(`The extension could not be read, so nothing was checked: ${result.manifestError}`);
216
+ }
217
+ return code;
218
+ }
219
+
220
+ // Only act when run as a program. Imported by the test suite, where exiting the
221
+ // process would take the suite with it.
222
+ if (process.argv[1] && process.argv[1].endsWith("report.mjs")) process.exit(run());