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 +21 -0
- package/README.md +356 -0
- package/action/report.mjs +222 -0
- package/action.yml +84 -0
- package/bin/webstore-lint.mjs +304 -0
- package/data/policy.json +652 -0
- package/package.json +48 -0
- package/src/audit.mjs +544 -0
- package/src/lint.mjs +125 -0
- package/src/privacy.mjs +218 -0
- package/src/rules.mjs +1549 -0
- package/src/scan.mjs +705 -0
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;
|