webstore-lint 1.0.33 → 1.0.35

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/README.md CHANGED
@@ -68,14 +68,35 @@ Pass one extension directory per command. If you maintain several extensions,
68
68
  run the command separately for each one. Multiple directories, including those
69
69
  expanded from a shell wildcard, are rejected before any checks run.
70
70
 
71
+ Symbolic links inside the selected directory are listed as unread and produce a
72
+ scan coverage warning. The scanner does not follow linked files or directories.
73
+ Run it on a copy of the built extension containing regular files to check that
74
+ code. A linked `manifest.json` makes the package unreadable. The Action fails on
75
+ that case at every threshold; other skipped links trigger `fail-on: warn`.
76
+
77
+ The tool also warns when `content_scripts` names an unsupported JavaScript or
78
+ stylesheet file type. Chrome skips the entire entry, including its other files.
79
+ Use built `.js` or `.mjs` scripts and `.css` stylesheets, then reload the unpacked
80
+ extension to verify it runs. `.user.js` is supported, and Chrome accepts `.scss`
81
+ for compatibility without compiling Sass. This loading warning has no policy
82
+ codename and triggers the Action's `fail-on: warn` threshold. See
83
+ [Chromium's file-type validation](https://chromium.googlesource.com/chromium/src/+/main/extensions/common/utils/content_script_utils.cc).
84
+
71
85
  ## In GitHub Actions
72
86
 
73
87
  ```yaml
74
- - uses: Circadian-agent/chrome-extension-rejection-lint@v1
88
+ - uses: Circadian-agent/chrome-extension-rejection-lint@v1.0.33
75
89
  with:
76
90
  path: ./extension
77
91
  ```
78
92
 
93
+ This example pins [v1.0.33](https://github.com/Circadian-agent/chrome-extension-rejection-lint/releases/tag/v1.0.33),
94
+ which includes the privacy-page timeout fix and refreshed Google policy citations.
95
+ The floating `v1` tag still points to v1.0.30 as of 10 October 2026. If your workflow
96
+ uses `@v1`, change it to `@v1.0.33` to include these fixes. Check the
97
+ [release notes](https://github.com/Circadian-agent/chrome-extension-rejection-lint/releases)
98
+ before updating the pin in future.
99
+
79
100
  Findings land on the offending line of the pull request diff, and the verdict
80
101
  goes on the checks tab, so a policy violation is visible where the review already
81
102
  is rather than in a build log nobody opens.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webstore-lint",
3
- "version": "1.0.33",
3
+ "version": "1.0.35",
4
4
  "description": "Check an unpacked Chrome extension against the Chrome Web Store program policies before you submit it. Every finding cites Google's own notification ID and verbatim policy text. Covers the 1 August 2026 policy updates.",
5
5
  "keywords": [
6
6
  "chrome-extension",
@@ -38,7 +38,7 @@
38
38
  "node": ">=18"
39
39
  },
40
40
  "scripts": {
41
- "test": "node test/lint.test.mjs && node test/action.test.mjs && node --test test/permissions-output.test.mjs test/cli-modes.test.mjs test/privacy-timeout.test.mjs"
41
+ "test": "node test/lint.test.mjs && node test/action.test.mjs && node --test test/permissions-output.test.mjs test/cli-modes.test.mjs test/privacy-timeout.test.mjs test/symlinks.test.mjs test/content-script-types.test.mjs"
42
42
  },
43
43
  "repository": {
44
44
  "type": "git",
package/src/rules.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  // is worth less than none. When in doubt a rule warns and says what a human
19
19
  // must check.
20
20
 
21
- import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround, htmlCommentRanges, insideHtmlComment, offsetOf } from "./scan.mjs";
21
+ import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround, htmlCommentRanges, insideHtmlComment, offsetOf, skippedPath } from "./scan.mjs";
22
22
  import {
23
23
  MANIFEST_EVIDENCE, PERMISSION_API, NO_NAMESPACE_PERMISSIONS,
24
24
  namespaceUsed, looksMinified, looksMinifiedLarge, bareImports,
@@ -128,6 +128,30 @@ function bareOf(files) {
128
128
  // reads text files, so an icon that is present would be invisible to the check
129
129
  // and would report as missing on every package in the world.
130
130
  const norm = (p) => String(p).split(/[\\/]/).join("/").replace(/^\.\//, "").replace(/[?#].*$/, "");
131
+
132
+ // Chromium validates content-script types from the filename, before reading
133
+ // the resource. A bad type causes the whole content_scripts entry to be skipped.
134
+ // SCSS is explicitly allowed for compatibility; this does not compile Sass.
135
+ // Verified against extensions/common/utils/content_script_utils.cc and
136
+ // net/base/mime_util.cc in Chromium, plus browser probes on 10 October 2026.
137
+ const supportedContentFile = (path, type) =>
138
+ (type === "js" ? /\.(?:js|mjs)$/i : /\.(?:css|scss)$/i).test(path);
139
+
140
+ function invalidContentFiles(manifest) {
141
+ const out = [];
142
+ const scripts = Array.isArray(manifest?.content_scripts) ? manifest.content_scripts : [];
143
+ for (const [index, script] of scripts.entries()) {
144
+ for (const type of ["js", "css"]) {
145
+ for (const path of Array.isArray(script?.[type]) ? script[type] : []) {
146
+ if (typeof path === "string" && !supportedContentFile(path, type)) {
147
+ out.push({ path, key: `content_scripts[${index}].${type}` });
148
+ }
149
+ }
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
131
155
  function declaredFiles(manifest) {
132
156
  const m = manifest || {};
133
157
  const out = [];
@@ -136,8 +160,10 @@ function declaredFiles(manifest) {
136
160
  add(m.background?.page, "background.page");
137
161
  for (const s of m.background?.scripts || []) add(s, "background.scripts");
138
162
  for (const cs of m.content_scripts || []) {
139
- for (const j of cs.js || []) add(j, "content_scripts[].js");
140
- for (const c of cs.css || []) add(c, "content_scripts[].css");
163
+ // Unsupported types get their own loading warning. They may exist even
164
+ // when the scanner does not read that suffix, so do not call them missing.
165
+ for (const j of cs.js || []) if (supportedContentFile(j, "js")) add(j, "content_scripts[].js");
166
+ for (const c of cs.css || []) if (supportedContentFile(c, "css")) add(c, "content_scripts[].css");
141
167
  }
142
168
  add(m.action?.default_popup, "action.default_popup");
143
169
  add(m.browser_action?.default_popup, "browser_action.default_popup");
@@ -157,8 +183,8 @@ function declaredFiles(manifest) {
157
183
  const missingCache = new WeakMap();
158
184
  function missingDeclaredOf(manifest, files, skipped = []) {
159
185
  if (missingCache.has(files)) return missingCache.get(files);
160
- const present = new Set([...files.map((f) => norm(f.path)), ...skipped.map((s) => norm(s.path))]);
161
- const v = declaredFiles(manifest).filter((d) => !present.has(d.path));
186
+ const present = new Set(files.map((f) => norm(f.path)));
187
+ const v = declaredFiles(manifest).filter((d) => !present.has(d.path) && !skippedPath(d.path, skipped));
162
188
  missingCache.set(files, v);
163
189
  return v;
164
190
  }
@@ -167,7 +193,40 @@ function missingDeclaredOf(manifest, files, skipped = []) {
167
193
 
168
194
  export const RULES = [
169
195
 
170
- // FIRST, BECAUSE IT CHANGES HOW YOU READ EVERY OTHER FINDING. If this fires,
196
+ {
197
+ id: "content-script-file-types",
198
+ run({ manifest }) {
199
+ const invalid = invalidContentFiles(manifest);
200
+ if (!invalid.length) return [];
201
+ return [finding({
202
+ severity: "warn",
203
+ title: `${invalid.length} content-script resource(s) have an unsupported file type`,
204
+ detail: "Chrome skips an entire content_scripts entry when any of its js or css filenames has an unsupported file type. "
205
+ + "Point js at built .js or .mjs files, and css at .css files, then reload the unpacked extension to verify it runs. "
206
+ + "The .user.js suffix is supported. Chrome also accepts .scss for compatibility, but does not compile Sass. "
207
+ + "This is a loading check, not a Chrome Web Store policy violation or a reason to delete permissions.",
208
+ evidence: invalid.map(d => ({ file: "manifest.json", line: 1, text: `${d.key}: ${d.path}` })),
209
+ })];
210
+ },
211
+ },
212
+
213
+ {
214
+ id: "symbolic-links",
215
+ run({ skipped = [] }) {
216
+ const links = skipped.filter(s => s.kind === "symlink");
217
+ if (!links.length) return [];
218
+ return [finding({
219
+ severity: "warn",
220
+ title: `${links.length} symbolic link(s) were not read; scan coverage is incomplete`,
221
+ detail: "Linked files and directories are not followed. Their targets may be outside this package or form a cycle. "
222
+ + "Scan a copy of the built extension containing regular files, then run the checks again. "
223
+ + "This warning describes a limit of this scan, not a Chrome Web Store policy violation.",
224
+ evidence: links.map(s => ({ file: s.path, text: s.why })),
225
+ })];
226
+ },
227
+ },
228
+
229
+ // This changes how you read every policy finding. If this fires,
171
230
  // the directory is not the thing Chrome would review, and every rule that
172
231
  // reasons from the ABSENCE of something is unreliable against it.
173
232
  //
@@ -1094,7 +1153,7 @@ export const RULES = [
1094
1153
  // someone to go looking by hand for a site the tool already found.
1095
1154
  const streamed = new Set(oversized.filter(isCode).map((o) => o.path));
1096
1155
  const unreadCode = skipped.filter((s) =>
1097
- /\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path));
1156
+ s.kind !== "symlink" && /\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path));
1098
1157
  if (unreadCode.length) {
1099
1158
  out.push(finding({
1100
1159
  severity: "warn",
@@ -1194,7 +1253,7 @@ export const RULES = [
1194
1253
  // read.
1195
1254
  const streamed = new Set(largeCode.map((o) => o.path));
1196
1255
  const unread = skipped.filter((s) =>
1197
- /\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path));
1256
+ s.kind === "symlink" || (/\.(js|mjs|cjs|ts|jsx|tsx|html?)$/i.test(s.path || "") && !streamed.has(s.path)));
1198
1257
  // AND THE ONES WE DID READ STILL COUNT TOWARD (2), which is the half that
1199
1258
  // matters. Having read the bytes of a bundle is not having seen the name:
1200
1259
  // over the 82 cached release packages, dropping the unread caveat without
@@ -1211,8 +1270,11 @@ export const RULES = [
1211
1270
  // names dist/background.js and we were linting a folder of seven JSON
1212
1271
  // files - "never used" there is a claim about nothing at all.
1213
1272
  const gone = missingDeclaredOf(manifest, files, skipped);
1214
- if (unread.length || minified.length || bare.length || gone.length) {
1215
- const why = gone.length
1273
+ const invalidTypes = invalidContentFiles(manifest);
1274
+ if (unread.length || minified.length || bare.length || gone.length || invalidTypes.length) {
1275
+ const why = invalidTypes.length
1276
+ ? `a content_scripts entry has an unsupported file type (${invalidTypes[0].path}); fix that loading error before deciding whether these permissions are unused`
1277
+ : gone.length
1216
1278
  ? `manifest.json names ${gone.length} file(s) that are not in this directory (${gone[0].key}: ${gone[0].path}), so the code that uses these permissions is not here to be read`
1217
1279
  : unread.length
1218
1280
  ? `${unread.length} code file(s) in this package were not read (${unread[0].path}: ${unread[0].why}), so this scan did not see all of your code`
package/src/scan.mjs CHANGED
@@ -100,9 +100,19 @@ export function buildHint(root, exists = existsSync) {
100
100
  const MAX_BYTES = 2 * 1024 * 1024;
101
101
  const TEXT_EXT = new Set([
102
102
  ".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx", ".json", ".html", ".htm",
103
- ".css", ".txt", ".md", ".webmanifest",
103
+ ".css", ".scss", ".txt", ".md", ".webmanifest",
104
104
  ]);
105
105
 
106
+ // A link may name a file or a whole directory. Its descendants are unknown,
107
+ // not proven absent. Do not resolve the target just to make that distinction.
108
+ export function skippedPath(path, skipped) {
109
+ const normalized = path.split(/[\\/]/).join("/");
110
+ return skipped.some(s => {
111
+ const entry = s.path.split(/[\\/]/).join("/");
112
+ return normalized === entry || (s.kind === "symlink" && normalized.startsWith(entry + "/"));
113
+ });
114
+ }
115
+
106
116
  export function scan(root) {
107
117
  const files = [];
108
118
  const skipped = [];
@@ -123,6 +133,10 @@ export function scan(root) {
123
133
  for (const e of entries) {
124
134
  const full = join(dir, e.name);
125
135
  const rel = relative(root, full);
136
+ if (e.isSymbolicLink()) {
137
+ skipped.push({ path: rel, kind: "symlink", why: "symbolic link; target was not read" });
138
+ continue;
139
+ }
126
140
  if (e.isDirectory()) {
127
141
  if (SKIP_DIRS.has(e.name)) { skipped.push({ path: rel, why: "not part of a submitted package" }); continue; }
128
142
  walk(full);
@@ -153,7 +167,9 @@ export function scan(root) {
153
167
  let manifest = null;
154
168
  let manifestError = null;
155
169
  let manifestHint = null;
156
- if (!existsSync(manifestPath)) {
170
+ if (skipped.some(s => s.path === "manifest.json" && s.kind === "symlink")) {
171
+ manifestError = "manifest.json is a symbolic link; its target was not read. Scan a package containing regular files.";
172
+ } else if (!existsSync(manifestPath)) {
157
173
  // NAME THE ACTUAL PROBLEM. "no manifest.json in this directory" was printed
158
174
  // for a path that is not a directory and for a path that does not exist at
159
175
  // all, which sends someone to look for a file in a folder that was never
@@ -233,7 +249,7 @@ export function resolveI18n(manifest, files, skipped = []) {
233
249
  // A locale file the scanner refused to read (over the size limit, unreadable)
234
250
  // is NOT evidence that a message is missing. Staying quiet there is the same
235
251
  // fail-safe direction as everything else in this file.
236
- const wasSkipped = wanted ? skipped.some((s) => s.path.split(/[\\/]/).join("/") === wanted) : false;
252
+ const wasSkipped = wanted ? skippedPath(wanted, skipped) : false;
237
253
 
238
254
  let messages = null;
239
255
  if (file) {