From 31e529f00011e79dee74388248431ebeb2e1e8a7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 20:34:21 +0100 Subject: [PATCH] ci: guard extension wildcard reexports --- package.json | 1 + scripts/check-changed.mjs | 1 + .../check-extension-wildcard-reexports.mjs | 109 ++++++++++++++++++ scripts/check.mjs | 4 + test/scripts/changed-lanes.test.ts | 10 ++ ...check-extension-wildcard-reexports.test.ts | 31 +++++ 6 files changed, 156 insertions(+) create mode 100644 scripts/check-extension-wildcard-reexports.mjs create mode 100644 test/scripts/check-extension-wildcard-reexports.test.ts diff --git a/package.json b/package.json index c23b2aae34e..c05cee34e2e 100644 --- a/package.json +++ b/package.json @@ -1447,6 +1447,7 @@ "lint:extensions": "node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.extensions.json extensions", "lint:extensions:bundled": "node scripts/run-bundled-extension-oxlint.mjs", "lint:extensions:channels": "node scripts/run-extension-channel-oxlint.mjs", + "lint:extensions:no-guarded-wildcard-reexports": "node scripts/check-extension-wildcard-reexports.mjs", "lint:extensions:no-plugin-sdk-internal": "node scripts/check-extension-plugin-sdk-boundary.mjs --mode=plugin-sdk-internal", "lint:extensions:no-plugin-sdk-wildcard-reexports": "node scripts/check-plugin-sdk-wildcard-reexports.mjs", "lint:extensions:no-relative-outside-package": "node scripts/check-extension-plugin-sdk-boundary.mjs --mode=relative-outside-package", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 158893f7efa..38f0a7d77c0 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -55,6 +55,7 @@ export function createChangedCheckPlan(result, options = {}) { add("conflict markers", ["check:no-conflict-markers"]); add("changelog attributions", ["check:changelog-attributions"]); + add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]); add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); if (result.docsOnly) { diff --git a/scripts/check-extension-wildcard-reexports.mjs b/scripts/check-extension-wildcard-reexports.mjs new file mode 100644 index 00000000000..9260c55e9d9 --- /dev/null +++ b/scripts/check-extension-wildcard-reexports.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const GUARDED_PUBLIC_API_BARRELS = [ + "extensions/discord/api.ts", + "extensions/slack/api.ts", + "extensions/telegram/api.ts", + "extensions/whatsapp/api.ts", +]; + +const LOCAL_WILDCARD_REEXPORT_PATTERN = /^\s*export\s+(?:type\s+)?\*\s+from\s+["'](?:\.{1,2}\/)/u; + +async function walkFiles(rootDir, predicate) { + const files = []; + async function visit(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") { + continue; + } + const filePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await visit(filePath); + continue; + } + if (entry.isFile() && predicate(filePath)) { + files.push(filePath); + } + } + } + await visit(rootDir); + return files.toSorted((left, right) => left.localeCompare(right)); +} + +async function listGuardedFiles(rootDir = repoRoot) { + const runtimeApiFiles = await walkFiles(path.join(rootDir, "extensions"), (filePath) => + filePath.endsWith(`${path.sep}runtime-api.ts`), + ); + const files = new Set(runtimeApiFiles); + for (const relativePath of GUARDED_PUBLIC_API_BARRELS) { + const filePath = path.join(rootDir, relativePath); + try { + const stat = await fs.stat(filePath); + if (stat.isFile()) { + files.add(filePath); + } + } catch (error) { + if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") { + throw error; + } + } + } + return [...files].toSorted((left, right) => left.localeCompare(right)); +} + +export function findLocalWildcardReexports(source) { + return source + .split(/\r?\n/u) + .map((text, index) => ({ line: index + 1, text })) + .filter(({ text }) => LOCAL_WILDCARD_REEXPORT_PATTERN.test(text)); +} + +export async function collectExtensionWildcardReexports(rootDir = repoRoot) { + const files = await listGuardedFiles(rootDir); + const violations = []; + for (const filePath of files) { + const source = await fs.readFile(filePath, "utf8"); + for (const match of findLocalWildcardReexports(source)) { + violations.push({ + file: path.relative(rootDir, filePath).split(path.sep).join("/"), + line: match.line, + text: match.text.trim(), + }); + } + } + return violations; +} + +export async function main(argv = process.argv.slice(2), io = process) { + const json = argv.includes("--json"); + const violations = await collectExtensionWildcardReexports(); + + if (json) { + io.stdout.write(`${JSON.stringify(violations, null, 2)}\n`); + return violations.length === 0 ? 0 : 1; + } + + if (violations.length === 0) { + io.stdout.write("No guarded extension wildcard re-exports found.\n"); + return 0; + } + + io.stderr.write("Found guarded extension wildcard re-exports:\n"); + for (const violation of violations) { + io.stderr.write(`- ${violation.file}:${violation.line} ${violation.text}\n`); + } + io.stderr.write("Use explicit named exports so runtime and public API barrels stay pinned.\n"); + return 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const exitCode = await main(); + process.exit(exitCode); +} diff --git a/scripts/check.mjs b/scripts/check.mjs index 6bb4f4aafa0..9c4763e4503 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -31,6 +31,10 @@ export async function main(argv = process.argv.slice(2)) { commands: [ { name: "conflict markers", args: ["check:no-conflict-markers"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, + { + name: "guarded extension wildcard re-exports", + args: ["lint:extensions:no-guarded-wildcard-reexports"], + }, { name: "plugin-sdk wildcard re-exports", args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index fc153b34e05..614ec5a8371 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -259,6 +259,7 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.name)).toEqual([ "conflict markers", "changelog attributions", + "guarded extension wildcard re-exports", "plugin-sdk wildcard re-exports", "typecheck core tests", "lint core", @@ -547,6 +548,7 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).toEqual([ "check:no-conflict-markers", "check:changelog-attributions", + "lint:extensions:no-guarded-wildcard-reexports", "lint:extensions:no-plugin-sdk-wildcard-reexports", "release-metadata:check", "ios:version:check", @@ -679,6 +681,10 @@ describe("scripts/changed-lanes", () => { expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, + { + name: "guarded extension wildcard re-exports", + args: ["lint:extensions:no-guarded-wildcard-reexports"], + }, { name: "plugin-sdk wildcard re-exports", args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], @@ -694,6 +700,10 @@ describe("scripts/changed-lanes", () => { expect(plan.commands).toEqual([ { name: "conflict markers", args: ["check:no-conflict-markers"] }, { name: "changelog attributions", args: ["check:changelog-attributions"] }, + { + name: "guarded extension wildcard re-exports", + args: ["lint:extensions:no-guarded-wildcard-reexports"], + }, { name: "plugin-sdk wildcard re-exports", args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], diff --git a/test/scripts/check-extension-wildcard-reexports.test.ts b/test/scripts/check-extension-wildcard-reexports.test.ts new file mode 100644 index 00000000000..705a11c1289 --- /dev/null +++ b/test/scripts/check-extension-wildcard-reexports.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { findLocalWildcardReexports } from "../../scripts/check-extension-wildcard-reexports.mjs"; + +describe("check-extension-wildcard-reexports", () => { + it("flags local wildcard re-exports", () => { + expect( + findLocalWildcardReexports( + [ + 'export * from "./src/runtime-api.js";', + 'export type * from "../api.js";', + 'export { named } from "./src/runtime-api.js";', + ].join("\n"), + ), + ).toEqual([ + { line: 1, text: 'export * from "./src/runtime-api.js";' }, + { line: 2, text: 'export type * from "../api.js";' }, + ]); + }); + + it("allows explicit local exports and external wildcard barrels", () => { + expect( + findLocalWildcardReexports( + [ + 'export { named } from "./src/runtime-api.js";', + 'export type { Named } from "../api.js";', + 'export * from "external-package";', + ].join("\n"), + ), + ).toEqual([]); + }); +});