webstore-lint 1.0.26 → 1.0.28

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.
@@ -131,7 +131,10 @@ if (flags.has("--permissions")) {
131
131
  const { audit: a, root, manifestError } = auditPermissions(target);
132
132
  if (manifestError) { console.error(manifestError); process.exit(1); }
133
133
 
134
- if (flags.has("--json")) { console.log(JSON.stringify(a, null, 2)); process.exit(0); }
134
+ if (flags.has("--json")) {
135
+ await new Promise((resolve) => process.stdout.write(JSON.stringify(a, null, 2) + "\n", resolve));
136
+ process.exit(0);
137
+ }
135
138
 
136
139
  const cite = POLICY.categories["excessive-permissions"];
137
140
  console.log(`\nwebstore-lint permission ledger ${root}`);
@@ -173,6 +176,9 @@ if (flags.has("--permissions")) {
173
176
  for (const m of a.confidence.minified) console.log(` minified, so call sites are unreliable: ${m.file} (longest line ${m.longestLine} chars)`);
174
177
  for (const s of a.confidence.skipped) console.log(` not read: ${s.path} (${s.why})`);
175
178
  console.log("");
179
+ // Drain the text report before exiting too. A slow pipe can otherwise lose
180
+ // the minified/skipped warnings needed to interpret the permission evidence.
181
+ await new Promise((resolve) => process.stdout.write("", resolve));
176
182
  process.exit(0);
177
183
  }
178
184
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webstore-lint",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
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"
41
+ "test": "node test/lint.test.mjs && node test/action.test.mjs && node --test test/permissions-output.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 } from "./scan.mjs";
21
+ import { grep, grepAcross, grepLarge, largeWindows, isCode, isMarkup, codeView, excerptAround, htmlCommentRanges, insideHtmlComment, offsetOf } from "./scan.mjs";
22
22
  import {
23
23
  MANIFEST_EVIDENCE, PERMISSION_API, NO_NAMESPACE_PERMISSIONS,
24
24
  namespaceUsed, looksMinified, looksMinifiedLarge, bareImports,
@@ -441,7 +441,45 @@ export const RULES = [
441
441
  evidence: website,
442
442
  }));
443
443
  }
444
- split(remoteScript, (shipped, tests) => shipped.length
444
+ // COMMENTED-OUT TAGS ARE THEIR OWN ANSWER, NOT A FAILURE AND NOT SILENCE.
445
+ // A <script src> inside <!-- --> does not load and cannot be rejected for,
446
+ // so failing on it is the wrong-fail case that gets a linter muted. We did
447
+ // exactly that to google/archat, whose options/options.html carries a
448
+ // commented-out Google Analytics block - the single most visible repo in
449
+ // our corpus, reported at the highest severity the tool has.
450
+ //
451
+ // IT GETS ITS OWN LABEL RATHER THAN JOINING THE WEBSITE OR TEST WARNS
452
+ // (T-0620). Those two sentences say "this file probably does not ship";
453
+ // this one says "this file ships and the tag is one edit from live". Three
454
+ // different facts, three different words. Overloading one of the existing
455
+ // ones would tell a developer to check their build output for a file that
456
+ // is already in it.
457
+ //
458
+ // Measured before it was written: 14 of 703 remote <script src> sites
459
+ // across 6,351 HTML files in the corpus cache are inside a comment, in six
460
+ // distinct repos.
461
+ const commentCache = new Map();
462
+ const inComment = (h) => {
463
+ const f = files.find((x) => x.path === h.file);
464
+ if (!f) return false; // cannot tell -> resolve toward FAIL, as elsewhere in this rule
465
+ if (!commentCache.has(f.path)) commentCache.set(f.path, htmlCommentRanges(f.text));
466
+ return insideHtmlComment(commentCache.get(f.path), offsetOf(f, h.line, h.col));
467
+ };
468
+ const commentedOut = remoteScript.filter(inComment);
469
+ const remoteScriptLive = remoteScript.filter((h) => !inComment(h));
470
+ if (commentedOut.length) {
471
+ out.push(finding({
472
+ severity: "warn",
473
+ title: `A <script> tag loading remote code is commented out (${commentedOut.length} site(s))`,
474
+ detail:
475
+ "Every site below sits inside an HTML comment, so it does not load and Google cannot reject you for it "
476
+ + "as it stands. It is reported because it ships in the package and is one edit from live: if you "
477
+ + "uncomment it before submitting, it becomes the first trigger Google names for this category. Delete "
478
+ + "it, or vendor the file into the package before you re-enable it.",
479
+ evidence: commentedOut,
480
+ }));
481
+ }
482
+ split(remoteScriptLive, (shipped, tests) => shipped.length
445
483
  ? finding({
446
484
  severity: "fail",
447
485
  title: "A <script> tag loads code from outside the extension package",
package/src/scan.mjs CHANGED
@@ -396,6 +396,52 @@ export function grepAcross(files, re, filter = () => true) {
396
396
  export const isCode = (f) => [".js", ".mjs", ".cjs", ".ts", ".jsx", ".tsx"].includes(f.ext);
397
397
  export const isMarkup = (f) => [".html", ".htm"].includes(f.ext);
398
398
 
399
+ // Where a grepAcross hit sits in the file, from the line and column it reports.
400
+ //
401
+ // grepAcross deliberately returns line+col rather than the raw offset, because
402
+ // that is what evidence carries and an extra field on a hit ends up in the
403
+ // published report. A caller that needs the offset back can ask for it here
404
+ // instead of every caller re-deriving it slightly differently.
405
+ export function offsetOf(file, line, col) {
406
+ let at = 0;
407
+ for (let i = 0; i < line - 1; i++) at += (file.lines[i]?.length ?? 0) + 1; // +1 for the newline
408
+ return at + col;
409
+ }
410
+
411
+ // The byte ranges of every HTML comment in a document.
412
+ //
413
+ // WHY A LINTER NEEDS THIS AT ALL. A commented-out <script src="https://..."> is
414
+ // not remote code. It does not load, it does not run, and Google cannot reject
415
+ // an extension for it. Reporting one at FAIL - the highest severity this tool
416
+ // has - is the wrong-fail case that gets a linter muted, and it happened on the
417
+ // most visible repo in our own corpus: google/archat carries a commented-out
418
+ // Google Analytics block in options/options.html and we called it a policy
419
+ // violation. Measured across 6,351 HTML files in the 1,079-repo clone cache,
420
+ // 14 of 703 remote <script src> sites (2.0%) are inside a comment.
421
+ //
422
+ // AN UNTERMINATED `<!--` IS TREATED AS NOT-A-COMMENT, which is deliberate and is
423
+ // the one place this disagrees with a browser (an unclosed comment really does
424
+ // swallow the rest of the document). The rule that uses this resolves every
425
+ // ambiguity toward FAIL, the same convention the off-package test in rules.mjs
426
+ // already follows, because a missed real violation is recoverable and a
427
+ // confident wrong one is not believed twice. An unterminated comment is also
428
+ // vanishingly rare next to the case this exists for.
429
+ export function htmlCommentRanges(text) {
430
+ const ranges = [];
431
+ let i = 0;
432
+ for (;;) {
433
+ const open = text.indexOf("<!--", i);
434
+ if (open === -1) break;
435
+ const close = text.indexOf("-->", open + 4);
436
+ if (close === -1) break; // unterminated: see above, NOT a comment
437
+ ranges.push([open, close + 3]);
438
+ i = close + 3;
439
+ }
440
+ return ranges;
441
+ }
442
+
443
+ export const insideHtmlComment = (ranges, at) => ranges.some(([s, e]) => at >= s && at < e);
444
+
399
445
  // Blank out comments, keeping every other byte where it was.
400
446
  //
401
447
  // WHY THIS LIVES HERE rather than in the one rule that first needed it: a