tabsmith-lint 0.1.0

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 rsub122
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,90 @@
1
+ # tabsmith-lint
2
+
3
+ A pre-submission compliance linter for browser extensions. v0.1 analyzes an unpacked
4
+ Manifest V3 Chrome extension directory and reports likely Chrome Web Store rejection
5
+ risks **before** you submit — unused/excessive permissions, missing permissions, MV3
6
+ violations, and broken file references — with a clear verdict and actionable fixes.
7
+
8
+ **Catch the "Purple Potassium" permission rejection before you submit.** The most
9
+ common Chrome Web Store rejection is excessive/unused permissions — declaring `tabs`,
10
+ `bookmarks`, `cookies`, or `<all_urls>` your code never actually uses. tabsmith-lint
11
+ statically checks your manifest against your code and maps findings to the violations
12
+ Chrome reviewers flag:
13
+
14
+ | Violation ID | What it means | tabsmith rules |
15
+ |---|---|---|
16
+ | Purple Potassium | Excessive / unused permissions | PERM001, PERM003, PERM004, PERM005 |
17
+ | Blue Argon | Remotely hosted code / string execution | MV3001, MV3002 |
18
+ | Yellow Magnesium | Functionality / broken packaging | FUNC001, FUNC002 |
19
+
20
+ ```bash
21
+ npx tabsmith-lint ./my-extension
22
+ npx tabsmith-lint ./my-extension --format json
23
+ npx tabsmith-lint ./my-extension --min-severity reject
24
+ ```
25
+
26
+ Exit codes: `0` pass · `1` needs fixes · `2` high rejection risk · `3` tool error.
27
+
28
+ > **Status:** the package is publish-ready (verified via `npm pack` + a real install)
29
+ > but **not yet on npm**, so `npx tabsmith-lint` resolves only after the first
30
+ > `npm publish`. Until then, run it from a clone: `npm install && npm run build &&
31
+ > node dist/cli.js ./my-extension`.
32
+
33
+ ## Develop
34
+
35
+ ```bash
36
+ npm install
37
+ npm test # vitest: unit + e2e (one fixture per rule)
38
+ npm run build # tsc → dist/
39
+ node dist/cli.js ./my-extension
40
+ ```
41
+
42
+ ## Validate against real extensions
43
+
44
+ Beyond the unit/e2e fixtures, the linter is validated on **real** extensions —
45
+ both Google's clean MV3 samples and real published, **built/minified** OSS
46
+ extensions (uBlock Origin, Dark Reader, Stylus, ...). All fetched on demand — see
47
+ `corpus/`:
48
+
49
+ ```bash
50
+ npm run build # required first — the scripts use dist/
51
+ npm run validate # robustness: ~100 clean extensions, gate on 0 crashes
52
+ npm run score # accuracy: PERM001 false-positive rate vs ground truth
53
+ npm run validate:releases # robustness on real minified extensions
54
+ npm run score:releases # accuracy on the messy corpus
55
+ npm run demo # print reports for recognizable real extensions
56
+ ```
57
+
58
+ `npm run score` gates promoting PERM001 to `reject`: under 5% false positives.
59
+ Across **28 hand-labeled extensions (22 samples + 6 minified releases): 0% false
60
+ positives, 0 false negatives, all verdicts matched** — independently audited.
61
+ Building this corpus caught and fixed **six** real false positives (plus a
62
+ prototype-safety crash) the synthetic fixtures missed — see `corpus/README.md`. CI
63
+ runs the suite and all validation gates on every push (`.github/workflows/ci.yml`).
64
+
65
+ ## PERM001 accuracy gate
66
+
67
+ PERM001 (declared-but-unused permission) is the flagship rule, and its
68
+ false-positive rate is make-or-break — a wrong "remove this permission" suggestion
69
+ destroys trust. In v0.1 it ships at **`fix` severity, never `reject`** (the single
70
+ constant `PERM001_SEVERITY` in `src/rules/permissions.ts`). Promoting it to `reject`
71
+ is gated on validating under 5% false positives against a hand-labeled corpus of
72
+ 20–30 real extensions, where the label is "permission actually used by
73
+ implementation" — not "extension passed review". That corpus is deferred; until it
74
+ exists, leave the severity at `fix`.
75
+
76
+ ## Status
77
+
78
+ v0.1. Chrome-only, directory input, 10 rules (PERM001-005, MV3001-003, FUNC001-002).
79
+
80
+ Firefox/Edge, ZIP/CRX input, SARIF, and a GitHub Action are on the roadmap.
81
+
82
+ Two rules are intentionally conservative in v0.1 (trust is the product): **PERM001**
83
+ (unused permission) and **MV3002** (string execution: `eval`/`new Function`) both ship
84
+ at `fix` severity, not `reject` — a static linter can't always tell a real problem from
85
+ a sandboxed or unreachable one, so they surface for you to verify rather than condemn.
86
+ Each is gated behind a single severity constant for later promotion.
87
+
88
+ ## License
89
+
90
+ MIT — see [LICENSE](LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { analyze } from "./engine/analyze.js";
6
+ import { ManifestError } from "./engine/manifest.js";
7
+ import { verdictExitCode } from "./engine/verdict.js";
8
+ import { renderHuman } from "./reporters/human.js";
9
+ import { renderJson } from "./reporters/json.js";
10
+ const USAGE = "Usage: tabsmith-lint <extension-dir> [--format human|json] [--browser chrome] [--min-severity reject|fix|info]";
11
+ function version() {
12
+ try {
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ return JSON.parse(readFileSync(join(here, "../package.json"), "utf8")).version ?? "0.0.0";
15
+ }
16
+ catch {
17
+ return "0.0.0";
18
+ }
19
+ }
20
+ function helpText() {
21
+ return `tabsmith-lint v${version()} - pre-submission compliance linter for Manifest V3 Chrome extensions
22
+
23
+ Point it at an unpacked extension directory and it flags likely Chrome Web Store
24
+ rejection risks before you submit:
25
+ - unused / excessive permissions (the "Purple Potassium" rejection)
26
+ - Manifest V3 violations: remotely hosted code, string execution, Manifest V2
27
+ - broken packaging: missing or wrong-case file references
28
+
29
+ ${USAGE}
30
+
31
+ Options:
32
+ --format human|json output format (default: human)
33
+ --browser chrome target browser (only chrome in v0.1)
34
+ --min-severity reject|fix|info minimum severity to report (default: info)
35
+ -v, --version print version
36
+ -h, --help show this help
37
+
38
+ Exit codes: 0 pass | 1 needs fixes | 2 high rejection risk | 3 tool error
39
+ Docs: https://github.com/rsub122/tabsmith-lint`;
40
+ }
41
+ export function run(argv) {
42
+ let dir;
43
+ let format = "human";
44
+ let browser = "chrome";
45
+ let minSeverity = "info";
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const arg = argv[i];
48
+ if (arg === "--format")
49
+ format = argv[++i];
50
+ else if (arg === "--browser")
51
+ browser = argv[++i];
52
+ else if (arg === "--min-severity")
53
+ minSeverity = argv[++i];
54
+ else if (arg === "-h" || arg === "--help")
55
+ return { exitCode: 0, stdout: helpText() };
56
+ else if (arg === "-v" || arg === "--version")
57
+ return { exitCode: 0, stdout: version() };
58
+ else if (arg.startsWith("--"))
59
+ return { exitCode: 3, stderr: `Unknown option: ${arg}\n${USAGE}` };
60
+ else if (dir === undefined)
61
+ dir = arg;
62
+ else
63
+ return { exitCode: 3, stderr: `Unexpected argument: ${arg}\n${USAGE}` };
64
+ }
65
+ if (!dir)
66
+ return { exitCode: 3, stderr: `No extension directory provided.\n${USAGE}` };
67
+ if (browser !== "chrome")
68
+ return { exitCode: 3, stderr: `Only --browser chrome is supported in v0.1 (got "${browser}").` };
69
+ if (!["human", "json"].includes(format))
70
+ return { exitCode: 3, stderr: `Invalid --format "${format}" (expected human|json).` };
71
+ if (!["reject", "fix", "info"].includes(minSeverity))
72
+ return { exitCode: 3, stderr: `Invalid --min-severity "${minSeverity}" (expected reject|fix|info).` };
73
+ let report;
74
+ try {
75
+ report = analyze(dir, { version: version(), minSeverity });
76
+ }
77
+ catch (e) {
78
+ const msg = e instanceof ManifestError ? e.message : e.message;
79
+ return { exitCode: 3, stderr: `error: ${msg}` };
80
+ }
81
+ const stdout = format === "json" ? renderJson(report) : renderHuman(report, minSeverity);
82
+ return { exitCode: verdictExitCode(report.browsers.chrome.verdict), stdout };
83
+ }
84
+ // Run as a CLI only when invoked directly (not when imported by tests). npm/npx
85
+ // install the bin as a SYMLINK (node_modules/.bin/tabsmith-lint -> dist/cli.js),
86
+ // so process.argv[1] is the symlink path while import.meta.url is the real file —
87
+ // compare realpaths, or the published binary silently does nothing.
88
+ let invokedDirectly = false;
89
+ try {
90
+ invokedDirectly = !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
91
+ }
92
+ catch {
93
+ invokedDirectly = false;
94
+ }
95
+ if (invokedDirectly) {
96
+ const result = run(process.argv.slice(2));
97
+ if (result.stdout)
98
+ console.log(result.stdout);
99
+ if (result.stderr)
100
+ console.error(result.stderr);
101
+ process.exit(result.exitCode);
102
+ }
@@ -0,0 +1,54 @@
1
+ // High-frequency Chrome permission → API namespace map (PRD 6.6). Not exhaustive
2
+ // by design; unknown permissions are simply not mapped (no false unused finding).
3
+ // Null-prototype throughout: namespace/permission keys come from parsed code, so
4
+ // a lookup like map["constructor"] must not resolve to an inherited Object member.
5
+ export const chromePermissionMap = Object.assign(Object.create(null), {
6
+ storage: { namespace: "storage" },
7
+ scripting: { namespace: "scripting" },
8
+ cookies: { namespace: "cookies", requiresHostPermission: true },
9
+ bookmarks: { namespace: "bookmarks" },
10
+ alarms: { namespace: "alarms" },
11
+ notifications: { namespace: "notifications" },
12
+ contextMenus: { namespace: "contextMenus" },
13
+ webRequest: { namespace: "webRequest", requiresHostPermission: true },
14
+ declarativeNetRequest: { namespace: "declarativeNetRequest" },
15
+ downloads: { namespace: "downloads" },
16
+ history: { namespace: "history" },
17
+ identity: { namespace: "identity" },
18
+ management: { namespace: "management" },
19
+ tabs: { special: "sensitiveTabProperties" },
20
+ activeTab: { special: "temporaryHostAccess" },
21
+ });
22
+ /** namespace → permission required to use it (PERM002). Only namespaces whose
23
+ * use genuinely requires a permission. `tabs` is intentionally excluded: the
24
+ * tabs namespace methods don't require the `tabs` permission (PRD §5 PERM002). */
25
+ export const namespaceToPermission = (() => {
26
+ const out = Object.create(null);
27
+ for (const [perm, mapping] of Object.entries(chromePermissionMap)) {
28
+ if (mapping.namespace)
29
+ out[mapping.namespace] = perm;
30
+ }
31
+ return out;
32
+ })();
33
+ /** Some namespaces are granted by more than one permission string. Declaring
34
+ * ANY of these satisfies PERM002 for that namespace. (Real extensions commonly
35
+ * use declarativeNetRequestWithHostAccess instead of the base permission.) */
36
+ export const namespacePermissionAliases = Object.assign(Object.create(null), {
37
+ declarativeNetRequest: [
38
+ "declarativeNetRequest",
39
+ "declarativeNetRequestWithHostAccess",
40
+ "declarativeNetRequestFeedback",
41
+ ],
42
+ });
43
+ /** All permission strings that satisfy a namespace's PERM002 requirement. */
44
+ export function permissionsForNamespace(namespace) {
45
+ if (namespacePermissionAliases[namespace])
46
+ return namespacePermissionAliases[namespace];
47
+ const p = namespaceToPermission[namespace];
48
+ return p ? [p] : [];
49
+ }
50
+ /** Broad host match patterns that trigger PERM003/004/005. */
51
+ export const BROAD_HOST_PATTERNS = ["<all_urls>", "*://*/*", "http://*/*", "https://*/*"];
52
+ export function isBroadHost(pattern) {
53
+ return BROAD_HOST_PATTERNS.includes(pattern);
54
+ }
@@ -0,0 +1,87 @@
1
+ import { loadManifest } from "./manifest.js";
2
+ import { discoverFiles, isScript, isHtml } from "./file-discovery.js";
3
+ import { collectManifestRefs } from "./manifest-refs.js";
4
+ import { parseScript } from "../parse/parse-script.js";
5
+ import { extractFromScripts } from "../parse/extract-api-calls.js";
6
+ import { runRules } from "../rules/index.js";
7
+ import { computeVerdict, filterByMinSeverity } from "./verdict.js";
8
+ /** Pure-ish engine entry point: directory in → LintReport out. Only manifest
9
+ * loading and file discovery touch the filesystem. Throws ManifestError (→ CLI
10
+ * exit 3) when the manifest is missing or invalid. */
11
+ export function analyze(rootDir, options) {
12
+ const { manifest, raw } = loadManifest(rootDir);
13
+ const manifestRefs = collectManifestRefs(manifest);
14
+ const files = discoverFiles(rootDir, manifestRefs);
15
+ const htmlFiles = files.filter((f) => isHtml(f.relPath));
16
+ const scriptFiles = files.filter((f) => isScript(f.relPath)).map(parseScript);
17
+ const extraction = extractFromScripts(scriptFiles);
18
+ const hostAccessSignals = [
19
+ ...extraction.hostAccessSignals,
20
+ ...deriveHostSignals(extraction.apiCalls),
21
+ ...(manifest.content_scripts?.length ? [{ kind: "contentScript", file: "manifest.json" }] : []),
22
+ ];
23
+ const model = {
24
+ rootDir,
25
+ manifest,
26
+ manifestRaw: raw,
27
+ files,
28
+ scriptFiles,
29
+ htmlFiles,
30
+ apiCalls: extraction.apiCalls,
31
+ sensitiveTabReads: extraction.sensitiveTabReads,
32
+ dynamicApiAccess: extraction.dynamicApiAccess,
33
+ hostAccessSignals,
34
+ manifestRefs: manifestRefs.all,
35
+ };
36
+ const ruleFindings = runRules(model);
37
+ const parseFindings = scriptFiles
38
+ .filter((s) => s.parseError)
39
+ .map((s) => ({
40
+ ruleId: "PARSE",
41
+ severity: "info",
42
+ confidence: "low",
43
+ title: "File could not be parsed",
44
+ message: `${s.relPath} failed to parse; its API usage was not analyzed. Findings for code in this file may be incomplete.`,
45
+ file: s.relPath,
46
+ }));
47
+ const allFindings = [...ruleFindings, ...parseFindings];
48
+ // --min-severity is a threshold, not just a display filter: it gates both the
49
+ // reported findings AND the verdict/exit code. This mirrors eslint --quiet
50
+ // (suppressing warnings makes a warning-only run exit 0). So `--min-severity
51
+ // reject` on a fix-only extension yields `pass`/exit 0 by design — the caller
52
+ // opted out of caring about fix-level findings.
53
+ const findings = filterByMinSeverity(allFindings, options.minSeverity ?? "info");
54
+ const verdict = computeVerdict(findings);
55
+ const scriptsParsed = scriptFiles.filter((s) => !s.parseError).length;
56
+ return {
57
+ tool: "tabsmith-lint",
58
+ version: options.version,
59
+ inputPath: rootDir,
60
+ manifestVersion: manifest.manifest_version,
61
+ browsers: {
62
+ chrome: {
63
+ verdict,
64
+ findings,
65
+ stats: {
66
+ filesScanned: files.length,
67
+ scriptsParsed,
68
+ parseErrors: scriptFiles.length - scriptsParsed,
69
+ },
70
+ },
71
+ },
72
+ };
73
+ }
74
+ function deriveHostSignals(apiCalls) {
75
+ const out = [];
76
+ for (const c of apiCalls) {
77
+ if (c.namespace === "cookies")
78
+ out.push({ kind: "cookies", file: c.file, line: c.line });
79
+ else if (c.namespace === "webRequest")
80
+ out.push({ kind: "webRequest", file: c.file, line: c.line });
81
+ else if (c.namespace === "declarativeNetRequest")
82
+ out.push({ kind: "declarativeNetRequest", file: c.file, line: c.line });
83
+ else if (c.namespace === "scripting" && c.path.includes("executeScript"))
84
+ out.push({ kind: "scripting.executeScript", file: c.file, line: c.line });
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1,122 @@
1
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join, relative, posix, dirname, resolve, sep } from "node:path";
3
+ import { stripBom } from "./source-lines.js";
4
+ const SCRIPT_EXT = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"];
5
+ const HTML_EXT = [".html", ".htm"];
6
+ const SCANNABLE = new Set([...SCRIPT_EXT, ...HTML_EXT]);
7
+ const EXCLUDE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".cache", "coverage"]);
8
+ function ext(path) {
9
+ const i = path.lastIndexOf(".");
10
+ return i < 0 ? "" : path.slice(i).toLowerCase();
11
+ }
12
+ export function isScript(relPath) {
13
+ return SCRIPT_EXT.includes(ext(relPath));
14
+ }
15
+ export function isHtml(relPath) {
16
+ return HTML_EXT.includes(ext(relPath));
17
+ }
18
+ function toRel(rootDir, abs) {
19
+ return relative(rootDir, abs).split(/[\\/]/).join(posix.sep);
20
+ }
21
+ /** A leading "/" in a manifest reference is root-relative to the EXTENSION, not
22
+ * the filesystem (e.g. "/images/icon.png" means <ext-root>/images/icon.png).
23
+ * Normalize to a path relative to rootDir before any filesystem use. */
24
+ export function refToRelPath(p) {
25
+ return p.replace(/^[\\/]+/, "");
26
+ }
27
+ /** True when a manifest reference resolves to a location inside `rootDir`.
28
+ * Blocks "../secret.js" from escaping the package; allows root-relative "/x". */
29
+ export function isWithinRoot(rootDir, relPath) {
30
+ const root = resolve(rootDir);
31
+ const abs = resolve(rootDir, refToRelPath(relPath));
32
+ return abs === root || abs.startsWith(root + sep);
33
+ }
34
+ function readVirtual(rootDir, relPath, discoveredBy) {
35
+ const rel = refToRelPath(relPath);
36
+ if (!isWithinRoot(rootDir, rel))
37
+ return null; // never read/parse files outside the package
38
+ const absPath = join(rootDir, rel);
39
+ if (!existsSync(absPath) || !statSync(absPath).isFile())
40
+ return null;
41
+ return { relPath: rel, absPath, content: stripBom(readFileSync(absPath, "utf8")), discoveredBy };
42
+ }
43
+ const SCRIPT_SRC_RE = /<script[^>]*\bsrc\s*=\s*["']([^"']+)["']/gi;
44
+ function localScriptSrcs(htmlContent, htmlRel) {
45
+ const out = [];
46
+ let m;
47
+ while ((m = SCRIPT_SRC_RE.exec(htmlContent)) !== null) {
48
+ const src = m[1];
49
+ if (/^(https?:)?\/\//i.test(src) || src.startsWith("data:"))
50
+ continue; // remote/inline-data handled by MV3001
51
+ const resolved = posix.normalize(posix.join(dirname(htmlRel) || ".", src)).replace(/^\.\//, "");
52
+ out.push(resolved);
53
+ }
54
+ return out;
55
+ }
56
+ /**
57
+ * Two-pass discovery: manifest-directed entry points first (tagged "manifest"),
58
+ * then a package-wide fallback scan (tagged "fallback"). Manifest tags win on
59
+ * dedupe. node_modules/.git/build caches and source maps are excluded.
60
+ *
61
+ * Takes pre-collected ManifestRefs so the caller can reuse them (e.g. for
62
+ * model.manifestRefs) without collecting twice.
63
+ */
64
+ export function discoverFiles(rootDir, refs) {
65
+ const byRel = new Map();
66
+ const add = (vf) => {
67
+ if (!vf)
68
+ return;
69
+ const existing = byRel.get(vf.relPath);
70
+ if (!existing || (existing.discoveredBy === "fallback" && vf.discoveredBy === "manifest")) {
71
+ byRel.set(vf.relPath, vf);
72
+ }
73
+ };
74
+ // --- Pass 1: manifest-directed ---
75
+ for (const s of refs.scripts)
76
+ add(readVirtual(rootDir, s, "manifest"));
77
+ for (const p of refs.pages) {
78
+ const page = readVirtual(rootDir, p, "manifest");
79
+ add(page);
80
+ if (page && isHtml(p)) {
81
+ for (const src of localScriptSrcs(page.content, p))
82
+ add(readVirtual(rootDir, src, "manifest"));
83
+ }
84
+ }
85
+ // --- Pass 2: fallback scan ---
86
+ walk(rootDir, rootDir, (abs) => {
87
+ const rel = toRel(rootDir, abs);
88
+ if (rel === "manifest.json")
89
+ return;
90
+ if (rel.endsWith(".map"))
91
+ return;
92
+ if (!SCANNABLE.has(ext(rel)))
93
+ return;
94
+ add(readVirtual(rootDir, rel, "fallback"));
95
+ if (isHtml(rel)) {
96
+ const page = byRel.get(rel);
97
+ for (const src of localScriptSrcs(page.content, rel))
98
+ add(readVirtual(rootDir, src, "fallback"));
99
+ }
100
+ });
101
+ return [...byRel.values()];
102
+ }
103
+ function walk(rootDir, dir, onFile) {
104
+ let entries;
105
+ try {
106
+ entries = readdirSync(dir, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ return;
110
+ }
111
+ for (const e of entries) {
112
+ const abs = join(dir, e.name);
113
+ if (e.isDirectory()) {
114
+ if (EXCLUDE_DIRS.has(e.name))
115
+ continue;
116
+ walk(rootDir, abs, onFile);
117
+ }
118
+ else if (e.isFile()) {
119
+ onFile(abs);
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,95 @@
1
+ function pushStr(out, source, v) {
2
+ if (typeof v === "string" && v.length > 0)
3
+ out.push({ ref: v, source });
4
+ }
5
+ function pushList(out, source, v) {
6
+ if (Array.isArray(v))
7
+ for (const item of v)
8
+ pushStr(out, source, item);
9
+ }
10
+ /** Collect file references from the manifest. Glob entries (containing '*') are
11
+ * dropped from `all` since they can't be existence-checked. */
12
+ export function collectManifestRefs(manifest) {
13
+ const all = [];
14
+ const pages = [];
15
+ const scripts = [];
16
+ const m = manifest;
17
+ // background
18
+ pushStr(all, "background.service_worker", m.background?.service_worker);
19
+ if (m.background?.service_worker)
20
+ scripts.push(m.background.service_worker);
21
+ pushList(all, "background.scripts", m.background?.scripts);
22
+ if (Array.isArray(m.background?.scripts))
23
+ scripts.push(...m.background.scripts);
24
+ // content scripts
25
+ if (Array.isArray(m.content_scripts)) {
26
+ for (const cs of m.content_scripts) {
27
+ pushList(all, "content_scripts.js", cs?.js);
28
+ pushList(all, "content_scripts.css", cs?.css);
29
+ if (Array.isArray(cs?.js))
30
+ scripts.push(...cs.js);
31
+ }
32
+ }
33
+ // action popup + icons
34
+ pushStr(all, "action.default_popup", m.action?.default_popup ?? m.browser_action?.default_popup);
35
+ const popup = m.action?.default_popup ?? m.browser_action?.default_popup;
36
+ if (typeof popup === "string")
37
+ pages.push(popup);
38
+ collectIcons(all, "action.default_icon", m.action?.default_icon);
39
+ collectIcons(all, "icons", m.icons);
40
+ // pages
41
+ for (const [key, val] of [
42
+ ["options_page", m.options_page],
43
+ ["options_ui.page", m.options_ui?.page],
44
+ ["devtools_page", m.devtools_page],
45
+ ["side_panel.default_path", m.side_panel?.default_path],
46
+ ]) {
47
+ if (typeof val === "string") {
48
+ pushStr(all, key, val);
49
+ if (val.endsWith(".html"))
50
+ pages.push(val);
51
+ }
52
+ }
53
+ // chrome_url_overrides
54
+ if (m.chrome_url_overrides && typeof m.chrome_url_overrides === "object") {
55
+ for (const [k, v] of Object.entries(m.chrome_url_overrides)) {
56
+ pushStr(all, `chrome_url_overrides.${k}`, v);
57
+ if (typeof v === "string" && v.endsWith(".html"))
58
+ pages.push(v);
59
+ }
60
+ }
61
+ // web accessible resources (skip globs)
62
+ if (Array.isArray(m.web_accessible_resources)) {
63
+ for (const war of m.web_accessible_resources) {
64
+ if (Array.isArray(war?.resources)) {
65
+ for (const r of war.resources) {
66
+ if (typeof r === "string" && !r.includes("*"))
67
+ pushStr(all, "web_accessible_resources", r);
68
+ }
69
+ }
70
+ else if (typeof war === "string" && !war.includes("*")) {
71
+ // MV2-style flat array
72
+ pushStr(all, "web_accessible_resources", war);
73
+ }
74
+ }
75
+ }
76
+ // declarative net request rulesets
77
+ const rulesets = m.declarative_net_request?.rule_resources;
78
+ if (Array.isArray(rulesets)) {
79
+ for (const rs of rulesets)
80
+ pushStr(all, "declarative_net_request.path", rs?.path);
81
+ }
82
+ return { all, pages: dedupe(pages), scripts: dedupe(scripts) };
83
+ }
84
+ function collectIcons(out, source, icons) {
85
+ if (typeof icons === "string") {
86
+ pushStr(out, source, icons);
87
+ }
88
+ else if (icons && typeof icons === "object") {
89
+ for (const v of Object.values(icons))
90
+ pushStr(out, source, v);
91
+ }
92
+ }
93
+ function dedupe(xs) {
94
+ return [...new Set(xs)];
95
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync, existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { stripBom } from "./source-lines.js";
4
+ export class ManifestError extends Error {
5
+ }
6
+ /**
7
+ * Load and minimally validate manifest.json from an extension directory.
8
+ * Throws ManifestError (→ CLI exit 3) on a missing dir, missing/unreadable
9
+ * manifest, malformed JSON, or absent manifest_version. We deliberately do NOT
10
+ * deep-validate the schema here — rules handle semantics.
11
+ */
12
+ export function loadManifest(rootDir) {
13
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
14
+ throw new ManifestError(`Not a directory: ${rootDir}`);
15
+ }
16
+ const manifestPath = join(rootDir, "manifest.json");
17
+ if (!existsSync(manifestPath)) {
18
+ throw new ManifestError(`No manifest.json found in ${rootDir}`);
19
+ }
20
+ let raw;
21
+ try {
22
+ raw = stripBom(readFileSync(manifestPath, "utf8"));
23
+ }
24
+ catch (e) {
25
+ throw new ManifestError(`Could not read manifest.json: ${e.message}`);
26
+ }
27
+ let manifest;
28
+ try {
29
+ manifest = JSON.parse(raw);
30
+ }
31
+ catch (e) {
32
+ throw new ManifestError(`manifest.json is not valid JSON: ${e.message}`);
33
+ }
34
+ if (typeof manifest !== "object" || manifest === null) {
35
+ throw new ManifestError("manifest.json must be a JSON object");
36
+ }
37
+ if (typeof manifest.manifest_version !== "number") {
38
+ throw new ManifestError("manifest.json is missing a numeric manifest_version");
39
+ }
40
+ return { manifest, raw };
41
+ }
@@ -0,0 +1,2 @@
1
+ // Core types for the tabsmith-lint engine. PRD 6.2 (ExtensionModel/ApiCall) + 4.2 (report schema).
2
+ export {};
@@ -0,0 +1,22 @@
1
+ // ponytail: naive first-match line lookup. Manifest findings get an approximate
2
+ // line by searching the raw JSON text for a needle. Good enough for a hint;
3
+ // upgrade to a real JSON AST (jsonc-parser) only if precise locations are needed.
4
+ export function findLine(source, needle) {
5
+ if (!needle)
6
+ return undefined;
7
+ const lines = source.split(/\r?\n/);
8
+ for (let i = 0; i < lines.length; i++) {
9
+ if (lines[i].includes(needle))
10
+ return i + 1;
11
+ }
12
+ return undefined;
13
+ }
14
+ /** 1-based line number for a character offset into `source`. */
15
+ export function findLineByIndex(source, index) {
16
+ return source.slice(0, index).split("\n").length;
17
+ }
18
+ /** Strip a leading UTF-8 BOM (U+FEFF). Windows/PowerShell-authored files often
19
+ * carry one, which would otherwise break JSON.parse and source scanning. */
20
+ export function stripBom(s) {
21
+ return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
22
+ }
@@ -0,0 +1,25 @@
1
+ /** Canonical severity ranking — the single source of truth for ordering and
2
+ * filtering. Higher = more severe. */
3
+ export const SEVERITY_RANK = { reject: 2, fix: 1, info: 0 };
4
+ /** Most-severe-first order, for grouped display and stable sorting. */
5
+ export const SEVERITY_ORDER = ["reject", "fix", "info"];
6
+ export function filterByMinSeverity(findings, min) {
7
+ return findings.filter((f) => SEVERITY_RANK[f.severity] >= SEVERITY_RANK[min]);
8
+ }
9
+ export function computeVerdict(findings) {
10
+ if (findings.some((f) => f.severity === "reject"))
11
+ return "high_rejection_risk";
12
+ if (findings.some((f) => f.severity === "fix"))
13
+ return "needs_fixes";
14
+ return "pass";
15
+ }
16
+ export function verdictExitCode(verdict) {
17
+ switch (verdict) {
18
+ case "pass":
19
+ return 0;
20
+ case "needs_fixes":
21
+ return 1;
22
+ case "high_rejection_risk":
23
+ return 2;
24
+ }
25
+ }