feat: type the plugin host record, move plugin strings out of core and add lint checks

This commit is contained in:
LukeGus committed 2026-09-29 13:08:39 -05:00
1 parent bba06f7ff6
commit b8bbcd2196
626 files changed
+23884 -4590

No files matched your search

+59 -22
View File
@@ -7,7 +7,8 @@
* action, slot or permission id that starts with one ("docker.open"). The
* shell (src/ui) also may not spell a view a plugin owns (a tab, panel or
* dashboard card id from a manifest). What core needs from a plugin comes
* through the registries instead.
* through the registries instead. Regex literals are read too, so
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
*
* Tests and locales are exempt, and so is src/backend/upgrade/: the one-time
* 2.8 to 2.9 data moves have to name the plugin each piece of data moves to,
@@ -22,17 +23,22 @@
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SRC = path.join(ROOT, "src");
const UI = path.join(SRC, "ui");
const PLUGINS = path.join(ROOT, "plugins");
const EXEMPT_DIRS = new Set(["tests", "locales"]);
const EXEMPT_PATHS = [path.join(SRC, "backend", "upgrade")];
const SSH_CONNECT = path.join(SRC, "backend", "hosts", "connect");
const SSH_TERMS = new Set(["totp"]);
function manifests() {
const dir = path.join(ROOT, "plugins");
function paths(root) {
const src = path.join(root, "src");
return {
root,
src,
ui: path.join(src, "ui"),
plugins: path.join(root, "plugins"),
exempt: [path.join(src, "backend", "upgrade")],
sshConnect: path.join(src, "backend", "hosts", "connect"),
};
}
function manifests(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
@@ -41,10 +47,10 @@ function manifests() {
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function names() {
function names(pluginsDir) {
const ids = new Set();
const views = new Set();
for (const manifest of manifests()) {
for (const manifest of manifests(pluginsDir)) {
ids.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
@@ -58,13 +64,13 @@ function names() {
return { ids, views };
}
function walk(dir, out) {
if (EXEMPT_PATHS.includes(dir)) return out;
function walk(dir, exempt, out) {
if (exempt.includes(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXEMPT_DIRS.has(entry.name)) continue;
walk(full, out);
walk(full, exempt, out);
} else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) {
out.push(full);
}
@@ -83,25 +89,43 @@ function literals(source) {
return out;
}
function scan() {
const { ids, views } = names();
/**
* Every regex literal in a file, with escaped slashes undone, so a pattern
* reads the way the path it matches does.
*/
function regexLiterals(source) {
const out = [];
const pattern =
/(^|[=(,:[!&|?{};]|\breturn)\s*\/((?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)\/[dgimsuyv]*/gm;
for (const match of source.matchAll(pattern)) {
const body = match[2];
// A comment, not a pattern.
if (body.startsWith("/") || body.startsWith("*")) continue;
out.push(body.replace(/\\(.)/g, "$1"));
}
return out;
}
function scan(root = path.resolve(__dirname, "..")) {
const { src, ui, plugins, exempt, sshConnect } = paths(root);
const { ids, views } = names(plugins);
const found = {};
const add = (file, what) => {
const key = path.relative(ROOT, file).replaceAll("\\", "/");
const key = path.relative(root, file).replaceAll("\\", "/");
(found[key] ??= new Set()).add(what);
};
for (const file of walk(SRC, [])) {
for (const file of walk(src, exempt, [])) {
const source = fs.readFileSync(file, "utf8");
const inShell = file.startsWith(UI + path.sep);
const inConnect = file.startsWith(SSH_CONNECT + path.sep);
const inShell = file.startsWith(ui + path.sep);
const inConnect = file.startsWith(sshConnect + path.sep);
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
const target = match[1].startsWith(".")
? path.resolve(path.dirname(file), match[1])
: "";
if (target.startsWith(PLUGINS + path.sep)) {
if (target.startsWith(plugins + path.sep)) {
add(file, `import ${match[1]}`);
}
}
@@ -118,6 +142,17 @@ function scan() {
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
if (prefix && ids.has(prefix[1])) add(file, text);
}
for (const pattern of regexLiterals(source)) {
for (const route of pattern.matchAll(
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
)) {
if (ids.has(route[1])) add(file, `/${pattern}/`);
}
const sshTerm = inConnect && SSH_TERMS.has(pattern);
if (ids.has(pattern.replace(/^\^|\$$/g, "")) && !sshTerm) {
add(file, `/${pattern}/`);
}
}
}
return Object.fromEntries(
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
@@ -140,4 +175,6 @@ function main() {
}
}
main();
if (require.main === module) main();
module.exports = { scan, regexLiterals };
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, regexLiterals } = require("./check-core-plugin-ids.cjs") as {
scan: (root: string) => Record<string, string[]>;
regexLiterals: (source: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "core-plugin-ids-"));
const all = {
"plugins/docker/manifest.json": JSON.stringify({
id: "docker",
contributes: { tabs: [{ id: "docker-tab" }] },
}),
...files,
};
for (const [file, content] of Object.entries(all)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("regexLiterals", () => {
it("reads patterns with their slashes unescaped, not divisions or comments", () => {
const source = [
"if (/^\\/plugin-api\\/snippets/.test(p)) return x / 2;",
"const r = a.match(/[a/b]c/g); // not /here/",
"return /docker/;",
].join("\n");
expect(regexLiterals(source)).toEqual([
"^/plugin-api/snippets",
"[a/b]c",
"docker",
]);
});
});
describe("scan", () => {
it("passes core code that names no plugin", () => {
const dir = fixture({
"src/backend/ok.ts": 'const route = "/plugin-api/" + id;\n',
});
expect(scan(dir)).toEqual({});
});
it("fails a plugin route in a string or a regex literal", () => {
const dir = fixture({
"src/backend/string.ts": 'const a = "/plugin-api/docker/list";\n',
"src/backend/regex.ts":
"const b = /^\\/plugin-api\\/docker(\\/|$)/.test(url);\n",
});
expect(scan(dir)).toEqual({
"src/backend/regex.ts": ["/^/plugin-api/docker(/|$)/"],
"src/backend/string.ts": ["/plugin-api/docker"],
});
});
it("fails a bare id, and a view id in the shell", () => {
const dir = fixture({
"src/backend/id.ts": 'if (pluginId === "docker") {}\n',
"src/ui/view.ts": 'open("docker-tab");\n',
"src/ui/tests/skip.ts": 'open("docker");\n',
});
expect(scan(dir)).toEqual({
"src/backend/id.ts": ["docker"],
"src/ui/view.ts": ["docker-tab"],
});
});
});
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env node
/**
* A plugin's strings live in its own locales/en.json.
*
* A plugin's i18next namespace falls back to core's, so a plugin can use
* `common.*` without copying it. Any other key a plugin uses has to be in the
* plugin's own en.json: one that only resolves through the fallback breaks
* the moment core renames or drops it, and core cannot tell it is in use.
*
* Reads t("...") calls and i18n key props (titleKey, labelKey and the rest)
* in plugins/<id>/src, and the *Key values in plugins/<id>/manifest.json.
* A key with a namespace ("docker:hosts.x") is checked against that plugin.
*
* --list prints what is missing per plugin, with whether core has the key.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
const CORE_LOCALE = path.join(ROOT, "src", "ui", "locales", "en.json");
const SHARED_PREFIXES = ["common."];
const KEY = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
const KEY_PROPS = new Set([
"titleKey",
"labelKey",
"descriptionKey",
"noHostMessageKey",
"paletteKey",
"hintKey",
"messageKey",
"placeholderKey",
"tooltipKey",
"i18nKey",
"enableLabelKey",
"enableDescriptionKey",
"consequenceKey",
"groupKey",
"emptyKey",
"nameKey",
]);
const PLURAL_SUFFIXES = [
"_zero",
"_one",
"_two",
"_few",
"_many",
"_other",
"_plural",
];
function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
/**
* Whether a dotted key resolves in a locale tree, the way i18next finds it:
* a segment may itself hold dots ("services.use"), and a last segment may be
* a plural form.
*/
function hasKey(tree, key) {
const find = (node, parts) => {
if (parts.length === 0) return node !== undefined;
if (!node || typeof node !== "object") return false;
for (let end = parts.length; end >= 1; end--) {
const name = parts.slice(0, end).join(".");
if (name in node && find(node[name], parts.slice(end))) return true;
if (
end === parts.length &&
PLURAL_SUFFIXES.some((suffix) => `${name}${suffix}` in node)
) {
return true;
}
}
return false;
};
return find(tree, key.split("."));
}
function isShared(key) {
return SHARED_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function walk(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
}
return out;
}
/** Drops comments, so an example in one is not read as a call. */
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* Every literal i18n key a source file uses. A prefix a key is built from at
* run time ends in ".*".
*/
function keysInSource(text) {
const source = stripComments(text);
const keys = new Set();
for (const match of source.matchAll(
/\bt\(\s*(["'])((?:(?!\1)[^\\\n$`])+)\1/g,
)) {
keys.add(match[2]);
}
for (const match of source.matchAll(
/\b([A-Za-z]+Key)\s*[:=]\s*\{?\s*(["'])((?:(?!\2)[^\\\n])+)\2/g,
)) {
if (KEY_PROPS.has(match[1])) keys.add(match[3]);
}
// A key built at run time: `hosts.sharing.levels.${level}.label` needs the
// whole hosts.sharing.levels subtree. A one-segment prefix says too little
// to check.
for (const match of source.matchAll(
/\bt\(\s*`((?:[A-Za-z0-9_-]+\.){2,})\$\{/g,
)) {
keys.add(`${match[1]}*`);
}
return [...keys].filter((key) =>
KEY.test(key.replace(/^[a-z0-9-]+:/, "").replace(/\.\*$/, "")),
);
}
function keysInManifest(value, out = new Set()) {
if (Array.isArray(value)) {
for (const item of value) keysInManifest(item, out);
} else if (value && typeof value === "object") {
for (const [name, child] of Object.entries(value)) {
if (KEY_PROPS.has(name) && typeof child === "string") out.add(child);
else keysInManifest(child, out);
}
}
return out;
}
function pluginIds(pluginsDir) {
return fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(pluginsDir, id, "manifest.json")))
.sort();
}
/**
* Keys each plugin uses but does not define, other than the shared ones.
* Returns { [pluginId]: [{ key, file, inCore }] }.
*/
function scan({ pluginsDir = PLUGINS, coreLocale = CORE_LOCALE } = {}) {
const core = fs.existsSync(coreLocale) ? readJson(coreLocale) : {};
const ids = pluginIds(pluginsDir);
const locales = new Map(
ids.map((id) => {
const file = path.join(pluginsDir, id, "locales", "en.json");
return [id, fs.existsSync(file) ? readJson(file) : {}];
}),
);
const missing = {};
const check = (owner, rawKey, file) => {
const colon = rawKey.indexOf(":");
const namespace = colon > 0 ? rawKey.slice(0, colon) : owner;
const key = colon > 0 ? rawKey.slice(colon + 1) : rawKey;
if (isShared(key)) return;
const tree = locales.get(namespace);
const lookup = key.endsWith(".*") ? key.slice(0, -2) : key;
if (tree && hasKey(tree, lookup)) return;
const inCore = hasKey(core, lookup);
// A bare word that is nowhere is a local helper named t, not a string.
if (!key.includes(".") && !inCore) return;
const list = (missing[owner] ??= []);
if (!list.some((entry) => entry.key === rawKey)) {
list.push({
key: rawKey,
file: path.relative(ROOT, file).replaceAll("\\", "/"),
inCore,
});
}
};
for (const id of ids) {
const manifestFile = path.join(pluginsDir, id, "manifest.json");
for (const key of keysInManifest(readJson(manifestFile))) {
if (KEY.test(key.replace(/^[a-z0-9-]+:/, ""))) {
check(id, key, manifestFile);
}
}
for (const file of walk(path.join(pluginsDir, id, "src"))) {
for (const key of keysInSource(fs.readFileSync(file, "utf8"))) {
check(id, key, file);
}
}
}
for (const list of Object.values(missing)) {
list.sort((a, b) => a.key.localeCompare(b.key));
}
return missing;
}
function main() {
const missing = scan();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(missing, null, 2));
return;
}
const entries = Object.entries(missing);
if (entries.length === 0) return;
console.error(
"Plugins use i18n keys their own locales/en.json does not define:",
);
for (const [id, list] of entries) {
for (const { key, file } of list) {
console.error(` ${id}: ${key} (${file})`);
}
}
process.exit(1);
}
if (require.main === module) main();
module.exports = { scan, keysInSource, hasKey };
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, keysInSource, hasKey } =
require("./check-plugin-locales.cjs") as {
scan: (options: {
pluginsDir: string;
coreLocale: string;
}) => Record<string, Array<{ key: string; file: string; inCore: boolean }>>;
keysInSource: (source: string) => string[];
hasKey: (tree: unknown, key: string) => boolean;
};
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
function fixture(files: Record<string, unknown>) {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-locales-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(dir, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(
full,
typeof content === "string" ? content : JSON.stringify(content),
);
}
return {
pluginsDir: path.join(dir, "plugins"),
coreLocale: path.join(dir, "core.json"),
};
}
describe("keysInSource", () => {
it("reads t() calls, key props and run time prefixes, not comments", () => {
const keys = keysInSource(
[
'const a = t("hosts.one");',
"const b = i18next.t('docker:hosts.two', { x: 1 });",
'app.registerTab("x", X, { titleKey: "nav.three" });',
'<EmptyState messageKey="hosts.four" />',
"t(`hosts.sharing.levels.${level}.label`);",
"t(`hosts.${label}`);",
'// t("hosts.commented")',
'storageKey: "not.a.string.key"',
].join("\n"),
);
expect(keys.sort()).toEqual(
[
"docker:hosts.two",
"hosts.four",
"hosts.one",
"hosts.sharing.levels.*",
"nav.three",
].sort(),
);
});
});
describe("hasKey", () => {
it("finds nested keys, dotted names and plural forms", () => {
const tree = {
a: { b: "x" },
permissions: { "services.use": { title: "t" } },
items: { count_one: "1", count_other: "n" },
};
expect(hasKey(tree, "a.b")).toBe(true);
expect(hasKey(tree, "a")).toBe(true);
expect(hasKey(tree, "permissions.services.use.title")).toBe(true);
expect(hasKey(tree, "items.count")).toBe(true);
expect(hasKey(tree, "a.c")).toBe(false);
});
});
describe("scan", () => {
it("passes keys a plugin defines and the shared common ones", () => {
const options = fixture({
"core.json": { common: { save: "Save" }, hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo", titleKey: "title" },
"plugins/demo/locales/en.json": { title: "Demo", hosts: { name: "N" } },
"plugins/demo/src/frontend/index.tsx":
't("common.save"); t("hosts.name"); t("title");',
});
expect(scan(options)).toEqual({});
});
it("fails a key that only resolves through core's fallback", () => {
const options = fixture({
"core.json": { hosts: { name: "Name" } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx": 't("hosts.name");',
});
expect(scan(options)).toEqual({
demo: [
{
key: "hosts.name",
file: expect.stringContaining("index.tsx"),
inCore: true,
},
],
});
});
it("checks a namespaced key against that plugin and a prefix as a subtree", () => {
const options = fixture({
"core.json": { hosts: { levels: { view: "View" } } },
"plugins/demo/manifest.json": { id: "demo" },
"plugins/demo/locales/en.json": {},
"plugins/demo/src/frontend/index.tsx":
't("other:known.key"); t("other:gone.key"); t(`hosts.levels.${l}`);',
"plugins/other/manifest.json": { id: "other" },
"plugins/other/locales/en.json": { known: { key: "k" } },
});
expect(scan(options).demo.map((entry) => entry.key)).toEqual([
"hosts.levels.*",
"other:gone.key",
]);
});
});
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* @termix/plugin-sdk/ui is public API, implemented by
* src/ui/plugin-host/sdk-ui.ts. Adding to it is a contract change and
* removing from it is a breaking one, so neither should happen by accident.
*
* Resolves every name the module exports (through its `export *` lines too)
* and fails when that differs from ALLOWED. A new export goes on the list in
* the same change, which is what makes it a deliberate one. Only generic
* shell pieces belong here: anything one feature needs lives in its plugin.
*
* --list prints what the module exports now.
*/
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"ActionSlot",
"Alert",
"AlertDescription",
"AlertDialog",
"AlertDialogAction",
"AlertDialogCancel",
"AlertDialogContent",
"AlertDialogDescription",
"AlertDialogFooter",
"AlertDialogHeader",
"AlertDialogOverlay",
"AlertDialogPortal",
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"Badge",
"BarSeries",
"BarSeriesItem",
"BarSeriesProps",
"BrowserSignInDialog",
"Button",
"ButtonProps",
"Card",
"CardAction",
"CardContent",
"CardDescription",
"CardFooter",
"CardGridCanvas",
"CardHeader",
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"ComponentSlot",
"ConnectionLogPanel",
"ConnectionLogProvider",
"ConnectionOrigin",
"ConnectionScreen",
"ConnectionStage",
"ConnectionStatus",
"CustomKeybinding",
"Dialog",
"DialogClose",
"DialogContent",
"DialogDescription",
"DialogFooter",
"DialogHeader",
"DialogOverlay",
"DialogPortal",
"DialogTitle",
"DialogTrigger",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
"DropdownMenuGroup",
"DropdownMenuItem",
"DropdownMenuLabel",
"DropdownMenuPortal",
"DropdownMenuRadioGroup",
"DropdownMenuRadioItem",
"DropdownMenuSeparator",
"DropdownMenuShortcut",
"DropdownMenuSub",
"DropdownMenuSubContent",
"DropdownMenuSubTrigger",
"DropdownMenuTrigger",
"EmptyState",
"FOLDER_COLORS",
"FakeSwitch",
"FullScreenAppPhase",
"FullScreenAppWrapper",
"GridCardCatalogEntry",
"GridColSpan",
"GridLayout",
"GridSlot",
"HostFeatureFields",
"HostFeatureFieldsProps",
"HostKeyVerificationDialog",
"Input",
"Kbd",
"KbdKey",
"KbdSeparator",
"KeyCombo",
"KeybindingAction",
"KeybindingActionType",
"Label",
"LineChart",
"LineChartSeries",
"LocalCollectedFile",
"LocalDirectoryListing",
"LocalFileEntry",
"LocalFsHomeInfo",
"LocalFsResult",
"LocalTransferOrigin",
"LocalTransferProgress",
"LocalTrashResult",
"LocalWalkFile",
"LocalWalkResult",
"LogContext",
"LogEntry",
"MFAPromptMode",
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
"MetricCard",
"MiniStat",
"PassphraseDialog",
"PasswordInput",
"PluginComponent",
"PluginViewPlaceholder",
"Popover",
"PopoverAnchor",
"PopoverContent",
"PopoverTrigger",
"RadialGauge",
"RadialGaugeProps",
"RecentActivityItem",
"RobustClipboardProvider",
"SSHAuthDialog",
"ScrollArea",
"ScrollBar",
"SectionCard",
"Select",
"Select2",
"SelectContent",
"SelectGroup",
"SelectItem",
"SelectLabel",
"SelectScrollDownButton",
"SelectScrollUpButton",
"SelectSeparator",
"SelectTrigger",
"SelectValue",
"Separator",
"SettingRow",
"Sheet",
"SheetClose",
"SheetContent",
"SheetDescription",
"SheetFooter",
"SheetHeader",
"SheetTitle",
"SheetTrigger",
"Skeleton",
"Slider",
"Sparkline",
"SparklineProps",
"StatRow",
"Switch",
"TOTPDialog",
"Textarea",
"TextareaProps",
"Tooltip",
"TooltipContent",
"TooltipProvider",
"TooltipTrigger",
"UptimeInfo",
"VersionInfo",
"WebSocketConnectionTarget",
"WidgetTitle",
"badgeVariants",
"buildOriginWsUrl",
"buttonVariants",
"cn",
"copyToClipboard",
"createFrontendLogger",
"createQuickConnectHost",
"findMatchingKeybinding",
"getAdaptiveResourceBudget",
"getBasePath",
"getDatabaseHealth",
"getDeviceId",
"getPollingEnvironmentMultiplier",
"getRecentActivity",
"getUptime",
"getVersionInfo",
"globalShortcutHandler",
"guacStateToStage",
"guacStateToStatus",
"hydrateLocalSharedHostAuth",
"isElectron",
"isMacPlatform",
"isQuickConnectHost",
"isTabJumpHotkey",
"linkedServerUrl",
"markAdaptiveResourceUsed",
"pluginWsUrl",
"readFromClipboard",
"resolveConnectionOrigin",
"resolveRemoteHostId",
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runVisibleInterval",
"useAdaptivePolling",
"useAppTheme",
"useConfirmation",
"useConnectionLog",
"useIsMobile",
"useOptionalConnectionLog",
"useTabs",
"useTabsSafe",
];
/** Every name a module exports, resolved the way the compiler sees it. */
function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) {
const ts = require("typescript");
const parsed = ts.getParsedCommandLineOfConfigFile(
tsconfig,
{},
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
);
const program = ts.createProgram([file], {
...(parsed?.options ?? {}),
noEmit: true,
});
const checker = program.getTypeChecker();
const source = program.getSourceFile(file);
const symbol = source && checker.getSymbolAtLocation(source);
if (!symbol) throw new Error(`Cannot read the exports of ${file}`);
return checker
.getExportsOfModule(symbol)
.map((entry) => entry.getName())
.sort();
}
/** What the module adds to, and drops from, the allowlist. */
function compare(names, allowed = ALLOWED) {
const allowedSet = new Set(allowed);
const nameSet = new Set(names);
return {
added: names.filter((name) => !allowedSet.has(name)),
removed: allowed.filter((name) => !nameSet.has(name)),
};
}
function main() {
const names = exportedNames();
if (process.argv.includes("--list")) {
console.log(names.join("\n"));
return;
}
const { added, removed } = compare(names);
if (added.length === 0 && removed.length === 0) return;
if (added.length > 0) {
console.error(
"@termix/plugin-sdk/ui exports names that are not on the allowlist in scripts/check-sdk-ui-exports.cjs:",
);
for (const name of added) console.error(` + ${name}`);
}
if (removed.length > 0) {
console.error(
"@termix/plugin-sdk/ui no longer exports these allowlisted names (a breaking change for plugins):",
);
for (const name of removed) console.error(` - ${name}`);
}
process.exit(1);
}
if (require.main === module) main();
module.exports = { ALLOWED, compare, exportedNames };
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { ALLOWED, compare, exportedNames } =
require("./check-sdk-ui-exports.cjs") as {
ALLOWED: string[];
compare: (
names: string[],
allowed?: string[],
) => { added: string[]; removed: string[] };
exportedNames: (file?: string, tsconfig?: string) => string[];
};
let dir: string | null = null;
afterEach(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
dir = null;
});
describe("compare", () => {
it("names what was added and what was dropped", () => {
expect(compare(["a", "b", "c"], ["a", "b", "d"])).toEqual({
added: ["c"],
removed: ["d"],
});
expect(compare(["a"], ["a"])).toEqual({ added: [], removed: [] });
});
it("keeps the allowlist free of duplicates", () => {
expect(new Set(ALLOWED).size).toBe(ALLOWED.length);
});
});
describe("exportedNames", () => {
it("follows export * and renamed exports", () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sdk-ui-exports-"));
fs.writeFileSync(
path.join(dir, "tsconfig.json"),
JSON.stringify({ compilerOptions: { module: "esnext", strict: true } }),
);
fs.writeFileSync(
path.join(dir, "parts.ts"),
"export const Button = 1;\nexport type ButtonProps = { a: 1 };\n",
);
fs.writeFileSync(
path.join(dir, "entry.ts"),
'export * from "./parts";\nconst theme = 2;\nexport { theme as useAppTheme };\n',
);
expect(
exportedNames(
path.join(dir, "entry.ts"),
path.join(dir, "tsconfig.json"),
),
).toEqual(["Button", "ButtonProps", "useAppTheme"]);
});
});
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Tests mirror the source tree in their own directories and never sit next
* to the code they test. A *.test.ts(x) file may only live under
* src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SKIP = new Set([
"node_modules",
".git",
"dist",
"build",
"release",
"coverage",
".vite",
".turbo",
]);
const TEST_FILE = /\.test\.tsx?$/;
const ALLOWED = [
/^src\/backend\/tests\//,
/^src\/ui\/tests\//,
/^scripts\//,
/^plugins\/[^/]+\/tests\//,
];
function walk(dir, root, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, root, out);
else if (TEST_FILE.test(entry.name)) {
out.push(path.relative(root, full).replaceAll("\\", "/"));
}
}
return out;
}
/** Test files outside the directories tests belong in. */
function misplaced(root = ROOT) {
return walk(root, root, [])
.filter((file) => !ALLOWED.some((pattern) => pattern.test(file)))
.sort();
}
function main() {
const found = misplaced();
if (found.length === 0) return;
console.error(
"Tests belong in src/backend/tests/, src/ui/tests/, scripts/ or plugins/<id>/tests/, mirroring the source:",
);
for (const file of found) console.error(` ${file}`);
process.exit(1);
}
if (require.main === module) main();
module.exports = { misplaced };
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { misplaced } = require("./check-test-locations.cjs") as {
misplaced: (root: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: string[]): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "test-locations-"));
for (const file of files) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, "");
}
return root;
}
describe("misplaced", () => {
it("accepts the mirrored test trees", () => {
const dir = fixture([
"src/backend/tests/utils/a.test.ts",
"src/ui/tests/lib/b.test.tsx",
"scripts/c.test.ts",
"plugins/docker/tests/backend/d.test.ts",
"plugins/docker/node_modules/pkg/e.test.ts",
"plugins/docker/dist/f.test.ts",
]);
expect(misplaced(dir)).toEqual([]);
});
it("refuses a test next to its source", () => {
const dir = fixture([
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
"plugins/docker/src/frontend/c.test.tsx",
"packages/plugin-sdk/src/d.test.ts",
"src/backend/utils/not-a-test.ts",
]);
expect(misplaced(dir)).toEqual([
"packages/plugin-sdk/src/d.test.ts",
"plugins/docker/src/frontend/c.test.tsx",
"src/backend/utils/a.test.ts",
"src/ui/lib/b.test.tsx",
]);
});
});