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/action.yml ADDED
@@ -0,0 +1,84 @@
1
+ # A GitHub Action wrapper around the same linter the CLI runs.
2
+ #
3
+ # Composite rather than Docker on purpose: the package has no dependencies and
4
+ # needs no build, so a container would add a pull and a minute of cold start to a
5
+ # check that finishes in well under a second. Every runner already has node.
6
+ #
7
+ # There is deliberately no shell logic below beyond calling one file. Shell in a
8
+ # composite action cannot be exercised by the test suite, and it breaks on
9
+ # somebody else's runner where nobody can see it. action/report.mjs holds the
10
+ # behaviour and test/action.test.mjs covers it.
11
+ name: "webstore-lint"
12
+ # GITHUB MARKETPLACE CAPS THIS AT 125 CHARACTERS and refuses the whole publish
13
+ # until it fits (2026-07-31: the owner tried to tick the Marketplace box and got
14
+ # "Description must be less than 125 characters" - the only thing the form
15
+ # objected to). The old wording was 237. test/action.test.mjs measures the real
16
+ # file so the limit is enforced here rather than remembered.
17
+ #
18
+ # It is also SEARCH COPY, not just metadata: the Marketplace listing is indexed
19
+ # by GitHub search, so this reuses what s065 measured on repository search - a
20
+ # developer in trouble types "rejection" and "rejected", never "program
21
+ # policies". That is why the short version leads with the rejection and not with
22
+ # the linting.
23
+ description: "Catch Chrome Web Store rejections before you submit, with the exact policy quoted. Covers the 1 August 2026 rules."
24
+ author: "Circadian-agent"
25
+
26
+ branding:
27
+ icon: "shield"
28
+ color: "yellow"
29
+
30
+ inputs:
31
+ path:
32
+ description: "Directory holding manifest.json, or the unzipped package."
33
+ required: false
34
+ default: "."
35
+ fail-on:
36
+ description: >-
37
+ Which findings fail the step. "fail" (default) blocks only on policy
38
+ violations, matching the CLI's exit code. "warn" also blocks on findings
39
+ that need a human judgement. "never" reports without blocking. An
40
+ unreadable extension fails whatever this is set to, because nothing was
41
+ checked and a green check would be a lie.
42
+ required: false
43
+ default: "fail"
44
+ privacy-policy:
45
+ description: >-
46
+ Optional. Your store listing's privacy policy URL. Passing it adds a check
47
+ that the page actually answers. This is the only input that makes a network
48
+ request, and only when it is set.
49
+ required: false
50
+ default: ""
51
+ annotations:
52
+ description: >-
53
+ Write findings as inline annotations on the pull request diff. Set to
54
+ "false" for the job summary only.
55
+ required: false
56
+ default: "true"
57
+
58
+ outputs:
59
+ fail:
60
+ description: "Number of failing findings."
61
+ value: ${{ steps.lint.outputs.fail }}
62
+ warn:
63
+ description: "Number of findings needing a human judgement."
64
+ value: ${{ steps.lint.outputs.warn }}
65
+ info:
66
+ description: "Number of informational findings."
67
+ value: ${{ steps.lint.outputs.info }}
68
+ unreadable:
69
+ description: >-
70
+ "true" when the extension could not be read, so no policy check ran. This
71
+ is distinct from a clean result and the two must not be confused.
72
+ value: ${{ steps.lint.outputs.unreadable }}
73
+
74
+ runs:
75
+ using: "composite"
76
+ steps:
77
+ - id: lint
78
+ shell: bash
79
+ run: node "$GITHUB_ACTION_PATH/action/report.mjs"
80
+ env:
81
+ WSL_PATH: ${{ inputs.path }}
82
+ WSL_FAIL_ON: ${{ inputs.fail-on }}
83
+ WSL_PRIVACY_POLICY: ${{ inputs.privacy-policy }}
84
+ WSL_ANNOTATIONS: ${{ inputs.annotations }}
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ // webstore-lint - check an unpacked Chrome extension against the Chrome Web
3
+ // Store program policies before you submit it.
4
+ //
5
+ // npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension
6
+ // npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension --json
7
+ // npx github:Circadian-agent/chrome-extension-rejection-lint ./my-extension --quiet
8
+ //
9
+ // THE COMMAND NAMES GitHub BECAUSE THE npm NAME IS NOT PUBLISHED. npm requires a
10
+ // human registrant. Do not print the bare `npx webstore-lint` anywhere a reader
11
+ // sees it until that lands: it 404s, and this help text is read by people who
12
+ // have already got the tool working, so a wrong line here teaches them a command
13
+ // that fails the next time they reach for it.
14
+ //
15
+ // EXIT CODES: 0 clean or warnings only, 1 at least one failure, 2 bad usage.
16
+ // Warnings do not fail the run on purpose. Half of them are conditions this
17
+ // tool cannot resolve without seeing your store listing, and a CI gate that
18
+ // cannot be satisfied locally gets switched off.
19
+
20
+ import { lint, auditPermissions, POLICY, pageUrlFor } from "../src/lint.mjs";
21
+ import { checkPolicyUrl } from "../src/privacy.mjs";
22
+
23
+ const args = process.argv.slice(2);
24
+ const flags = new Set(args.filter((a) => a.startsWith("--")));
25
+
26
+ // Flags that TAKE A VALUE. The value must be removed before the directory is
27
+ // picked out of what is left, or `--privacy-policy https://x ./ext` lints the URL
28
+ // as a directory and reports that manifest.json is missing - a confusing failure
29
+ // that blames the wrong argument.
30
+ const VALUED = new Set(["--privacy-policy"]);
31
+ const KNOWN = new Set(["--help", "--json", "--quiet", "--policy", "--permissions", ...VALUED]);
32
+
33
+ // A FLAG WE DO NOT RECOGNISE IS AN ERROR, NOT SILENCE. Unknown flags used to be
34
+ // dropped on the floor, so `--permission` - a one-letter slip away from the
35
+ // flagship `--permissions` ledger - ran the ordinary lint instead and exited 0.
36
+ // The user reads "0 failing" and believes they were told something about the
37
+ // report they asked for. This is scan.mjs's own rule about silent skips applied
38
+ // to the argument list: a tool that says "clean" about a question it never
39
+ // answered is worse than one that refuses.
40
+ const unknown = [...flags].filter((f) => !KNOWN.has(f));
41
+ if (unknown.length) {
42
+ console.error(`unknown flag${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`);
43
+ console.error(`known flags: ${[...KNOWN].sort().join(", ")}`);
44
+ process.exit(2);
45
+ }
46
+
47
+ const valueOf = (flag) => {
48
+ const i = args.indexOf(flag);
49
+ if (i < 0) return null;
50
+ const v = args[i + 1];
51
+ return v && !v.startsWith("--") ? v : null;
52
+ };
53
+
54
+ // Same reasoning, one level down: `--privacy-policy` with no url behind it USED
55
+ // TO RUN THE LINT AND EXIT 0, having quietly skipped the only network check in
56
+ // the tool. The user explicitly asked for their policy page to be fetched and
57
+ // was told nothing had gone wrong.
58
+ for (const f of VALUED) {
59
+ if (flags.has(f) && !valueOf(f)) {
60
+ console.error(`${f} needs a url after it, and the check was NOT run.`);
61
+ console.error(` webstore-lint ./my-extension ${f} https://example.com/privacy`);
62
+ process.exit(2);
63
+ }
64
+ }
65
+ const consumed = new Set();
66
+ for (const f of VALUED) {
67
+ const i = args.indexOf(f);
68
+ if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) consumed.add(i + 1);
69
+ }
70
+ const target = args.find((a, i) => !a.startsWith("--") && !consumed.has(i));
71
+ const policyUrl = valueOf("--privacy-policy");
72
+
73
+ if (flags.has("--help") || (!target && !flags.has("--policy"))) {
74
+ console.log(`webstore-lint - Chrome Web Store policy check, before you submit
75
+
76
+ webstore-lint <extension-directory> [--json] [--quiet]
77
+ webstore-lint <extension-directory> --permissions
78
+ the permission ledger: what in your code
79
+ requires each permission, and where a
80
+ narrower one would have done
81
+ webstore-lint <extension-directory> --privacy-policy <url>
82
+ additionally check that your listing's
83
+ privacy policy URL actually answers. This
84
+ is the ONLY flag that touches the network,
85
+ and only when you pass a url
86
+ webstore-lint --policy list the policy data this build carries
87
+
88
+ Not on npm yet, so the one-liner is:
89
+ npx github:Circadian-agent/chrome-extension-rejection-lint <extension-directory>
90
+
91
+ Policy data pulled ${POLICY.datasetPulledAt}. Enforcement of the ${POLICY.enforcedFrom} updates:
92
+ ${POLICY.enforcementQuote}
93
+
94
+ This tool reads your package. Without --privacy-policy it makes no network
95
+ request at all, and it cannot see your store listing or your screenshots. It is
96
+ not affiliated with Google. A clean run is not a promise of approval.`);
97
+ process.exit(flags.has("--help") ? 0 : 2);
98
+ }
99
+
100
+ if (flags.has("--policy")) {
101
+ console.log(`${Object.keys(POLICY.categories).length} verified rejection categories, from ${POLICY.generatedFrom}\n`);
102
+ for (const [id, c] of Object.entries(POLICY.categories)) {
103
+ console.log(` ${c.notificationIds.join(", ").padEnd(38)} ${c.title}`);
104
+ }
105
+ console.log(`\n${POLICY.changes2026.length} changes effective ${POLICY.enforcedFrom}:`);
106
+ for (const c of POLICY.changes2026) {
107
+ console.log(` ${c.inLivePolicyText ? "in live policy text" : "ANNOUNCED ONLY, not in any policy page"} ${c.name}`);
108
+ }
109
+ process.exit(0);
110
+ }
111
+
112
+ const wrapEarly = (s, indent = " ", width = 92) => {
113
+ const words = String(s).split(/\s+/);
114
+ const lines = [];
115
+ let cur = indent;
116
+ for (const w of words) {
117
+ if (cur.trim() && cur.length + w.length + 1 > width) { lines.push(cur); cur = indent; }
118
+ cur += (cur.trim() ? " " : "") + w;
119
+ }
120
+ if (cur.trim()) lines.push(cur);
121
+ return lines.join("\n");
122
+ };
123
+
124
+ // --permissions: the ledger. This is the evidence a Chrome Web Store permission
125
+ // justification has to rest on, and the comparative question ("could a narrower
126
+ // permission have done this") that Purple Potassium is actually decided on.
127
+ if (flags.has("--permissions")) {
128
+ const { audit: a, root, manifestError } = auditPermissions(target);
129
+ if (manifestError) { console.error(manifestError); process.exit(1); }
130
+
131
+ if (flags.has("--json")) { console.log(JSON.stringify(a, null, 2)); process.exit(0); }
132
+
133
+ const cite = POLICY.categories["excessive-permissions"];
134
+ console.log(`\nwebstore-lint permission ledger ${root}`);
135
+ console.log(`manifest v${a.manifestVersion}, ${a.confidence.filesRead} JavaScript file(s) read as source\n`);
136
+
137
+ if (!a.ledger.length) console.log(" The manifest declares no named permissions.\n");
138
+
139
+ for (const l of a.ledger) {
140
+ const tag = l.status === "used" ? "used " : l.status === "unused" ? "UNUSED " : "unknown";
141
+ console.log(` ${tag} ${l.permission}${l.siteCount ? ` (${l.siteCount} call site${l.siteCount === 1 ? "" : "s"})` : ""}${l.where === "optional_permissions" ? " [optional]" : ""}`);
142
+ if (l.disclosure) console.log(` discloses: ${l.disclosure}`);
143
+ if (l.note) console.log(wrapEarly(l.note, " "));
144
+ for (const s of l.sites.slice(0, 6)) console.log(` ${s.file}:${s.line} ${s.text}`);
145
+ if (l.siteCount > 6) console.log(` ... and ${l.siteCount - 6} more`);
146
+ console.log("");
147
+ }
148
+
149
+ if (a.narrowings.length) {
150
+ console.log(`\nCould a narrower permission have done the job? Google's words:`);
151
+ console.log(wrapEarly(`"${cite.policyQuote}"`, " "));
152
+ console.log(` ${cite.policyUrl}\n`);
153
+ for (const n of a.narrowings) {
154
+ console.log(` ${n.from} -> ${n.to}`);
155
+ console.log(wrapEarly(n.why, " "));
156
+ for (const e of n.evidence.slice(0, 6)) console.log(` ${e.file}${e.line ? ":" + e.line : ""} ${e.text}`);
157
+ if (n.evidence.length > 6) console.log(` ... and ${n.evidence.length - 6} more`);
158
+ console.log(wrapEarly(`This suggestion is WRONG if ${n.wrongIf}`, " "));
159
+ console.log("");
160
+ }
161
+ }
162
+
163
+ if (a.disclosures.length) {
164
+ console.log(`\nPrivacy practices tab, from what the code actually touches:`);
165
+ for (const d of a.disclosures) console.log(` ${d.permission.padEnd(16)} ${d.category}`);
166
+ console.log("");
167
+ }
168
+
169
+ console.log(wrapEarly(a.confidence.caveat, " "));
170
+ for (const m of a.confidence.minified) console.log(` minified, so call sites are unreliable: ${m.file} (longest line ${m.longestLine} chars)`);
171
+ for (const s of a.confidence.skipped) console.log(` not read: ${s.path} (${s.why})`);
172
+ console.log("");
173
+ process.exit(0);
174
+ }
175
+
176
+ const result = lint(target);
177
+
178
+ // The one network check, merged into the same findings list so it renders and
179
+ // exits through the same path as everything else. Only reached when a url was
180
+ // given, and skipped entirely when the manifest could not be read - there is no
181
+ // point telling somebody their policy url is fine when we could not find their
182
+ // extension.
183
+ if (policyUrl && !result.manifestError) {
184
+ const extra = await checkPolicyUrl(policyUrl);
185
+ for (const f of extra) {
186
+ // These findings are built here rather than in lint(), so they need the same
187
+ // two derived fields attached by the same rule. A finding that reaches the
188
+ // renderer without citationUrl silently loses its explainer link, and the
189
+ // only reader who would notice is the one whose privacy policy failed.
190
+ const citation = f.category ? POLICY.categories[f.category] || null : null;
191
+ result.findings.push({
192
+ ...f,
193
+ citation,
194
+ citationUrl: pageUrlFor(citation),
195
+ change: null,
196
+ });
197
+ result.counts[f.severity]++;
198
+ }
199
+ const order = { fail: 0, warn: 1, info: 2 };
200
+ result.findings.sort((a, b) => order[a.severity] - order[b.severity]);
201
+ } else if (policyUrl && result.manifestError) {
202
+ console.error(
203
+ "--privacy-policy was skipped: the extension directory could not be read, so " +
204
+ "the finding below is the one that matters first.",
205
+ );
206
+ }
207
+
208
+ // PROCESS.EXIT() TRUNCATES A PIPE, AND THIS COST US THE WHOLE CI STORY.
209
+ // `process.stdout` is asynchronous when fd 1 is a pipe and synchronous when it
210
+ // is a TTY, so `console.log(big); process.exit()` prints in full interactively
211
+ // and is cut to one 64 KB pipe buffer everywhere else. Measured on automa's
212
+ // shipped release package: 1,502,987 bytes to a file, 65,536 through a pipe -
213
+ // the same command, differing only in what fd 1 is, with 96% of the report
214
+ // discarded and the exit code still 1.
215
+ //
216
+ // Anything that consumes --json reads a pipe by definition. That includes our
217
+ // own GitHub Action, which spawnSyncs this file: it refuses to report a run it
218
+ // cannot parse, so it failed loudly rather than passing a broken extension -
219
+ // but it failed on every package big enough to matter, which is every real one.
220
+ // Interactive use is exactly the case that cannot show this.
221
+ //
222
+ // So: hand the data over, WAIT for the callback that says it reached the OS,
223
+ // and only then exit. `process.exitCode` alone is not enough here because the
224
+ // human branch below must not also run.
225
+ if (flags.has("--json")) {
226
+ await new Promise((resolve) => {
227
+ process.stdout.write(
228
+ JSON.stringify(result, (k, v) => (k === "files" || k === "lines" ? undefined : v), 2) + "\n",
229
+ resolve,
230
+ );
231
+ });
232
+ process.exit(result.counts.fail ? 1 : 0);
233
+ }
234
+
235
+ const MARK = { fail: "FAIL", warn: "WARN", info: "INFO" };
236
+ const wrap = (s, indent = " ", width = 92) => {
237
+ const words = String(s).split(/\s+/);
238
+ const lines = [];
239
+ let cur = indent;
240
+ for (const w of words) {
241
+ if (cur.trim() && cur.length + w.length + 1 > width) { lines.push(cur); cur = indent; }
242
+ cur += (cur.trim() ? " " : "") + w;
243
+ }
244
+ if (cur.trim()) lines.push(cur);
245
+ return lines.join("\n");
246
+ };
247
+
248
+ console.log(`\nwebstore-lint ${result.root}`);
249
+ console.log(`policy data pulled ${POLICY.datasetPulledAt}, ${POLICY.enforcedFrom} updates included\n`);
250
+
251
+ if (!result.findings.length) {
252
+ console.log("No findings.\n");
253
+ } else {
254
+ for (const f of result.findings) {
255
+ console.log(`${MARK[f.severity]} ${f.title}`);
256
+ console.log(` rule ${f.rule}${f.citation ? ` | ${f.citation.notificationIds.join(", ")} | ${f.citation.title}` : ""}`);
257
+ // ONE LINE, ALWAYS PRINTED, INCLUDING UNDER --quiet. This output is the
258
+ // artifact that travels: a developer pastes it into an issue, a forum
259
+ // thread or a question, and until now nothing in it said what produced it
260
+ // or where to read more. The codename is the exact phrase a rejected
261
+ // developer searches for, and this is the page answering it.
262
+ if (f.citationUrl) console.log(` explained: ${f.citationUrl}`);
263
+ if (f.detail) console.log(wrap(f.detail, " "));
264
+ for (const e of (f.evidence || []).slice(0, 8)) {
265
+ // Not all evidence is a line in a file. The privacy policy check's evidence
266
+ // is a url and an HTTP status, and printing "url:undefined" for it reads
267
+ // like a bug in the tool.
268
+ console.log(` ${e.file}${e.line ? ":" + e.line : ""} ${e.text || e.match || ""}`);
269
+ }
270
+ if ((f.evidence || []).length > 8) console.log(` ... and ${f.evidence.length - 8} more`);
271
+ if (!flags.has("--quiet") && f.citation) {
272
+ console.log(`\n What Google's policy says, verbatim:`);
273
+ console.log(wrap(`"${f.citation.policyQuote}"`, " "));
274
+ console.log(` ${f.citation.policyUrl}`);
275
+ if (f.citation.fixes?.length) {
276
+ console.log(` Google's stated fix:`);
277
+ for (const fix of f.citation.fixes) console.log(wrap(`- ${fix}`, " "));
278
+ }
279
+ }
280
+ if (!flags.has("--quiet") && f.change) {
281
+ console.log(`\n Changed ${POLICY.enforcedFrom}. ${f.change.inLivePolicyText ? "Live policy text now reads:" : "NOT IN ANY LIVE POLICY PAGE. The announcement reads:"}`);
282
+ console.log(wrap(`"${f.change.inLivePolicyText ? f.change.livePolicyQuote : f.change.blogQuote}"`, " "));
283
+ console.log(wrap(`Watch out: ${f.change.discrepancy}`, " "));
284
+ if (f.change.carveOut) console.log(wrap(`Carve-out the announcement omitted: ${f.change.carveOut}`, " "));
285
+ }
286
+ console.log("");
287
+ }
288
+ }
289
+
290
+ for (const s of result.skipped) console.log(` not read: ${s.path} (${s.why})`);
291
+ if (result.skipped.length) console.log("");
292
+
293
+ const { fail, warn, info } = result.counts;
294
+ console.log(`${fail} failing, ${warn} needing your judgement, ${info} informational`);
295
+ console.log(
296
+ "This reads your package only. Your store listing, privacy policy page and screenshots are " +
297
+ "where several of these are actually satisfied, and no local tool can see them.",
298
+ );
299
+ console.log("webstore-lint is free and open source: https://github.com/Circadian-agent/chrome-extension-rejection-lint\n");
300
+ // Same trap, same reason: the human report is also large on a real extension
301
+ // and it is also piped (`webstore-lint . | less`, or into a file in CI). Setting
302
+ // the code instead of calling exit() lets Node drain stdout and then leave with
303
+ // it. Nothing runs after this line, so there is nothing to guard against.
304
+ process.exitCode = fail ? 1 : 0;