Files
Termix/scripts/check-shell-plugin-ids.cjs
T

122 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
/**
* The shell knows no plugin by name.
*
* Fails when anything under src/ui (tests and locales aside) imports from
* plugins/, or spells a plugin id or a view a plugin owns (a tab, panel or
* dashboard card id from a manifest) as a string literal. What the shell
* needs from a plugin comes through the registries instead.
*
* A few literals are data rather than knowledge of a plugin: host protocol
* names that are columns on the host until Phase B moves them.
* Those are listed with a reason in scripts/shell-plugin-id-allowlist.json,
* which fails too when an entry stops being needed, so it only shrinks.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const UI = path.join(ROOT, "src", "ui");
const ALLOWLIST = path.join(__dirname, "shell-plugin-id-allowlist.json");
function manifests() {
const dir = path.join(ROOT, "plugins");
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name, "manifest.json"))
.filter((file) => fs.existsSync(file))
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function pluginNames() {
const names = new Set();
for (const manifest of manifests()) {
names.add(manifest.id);
const contributes = manifest.contributes ?? {};
for (const list of [
contributes.tabs,
contributes.panels,
contributes.dashboardCards,
]) {
for (const view of list ?? []) names.add(view.id);
}
}
return names;
}
function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "tests" || entry.name === "locales") continue;
walk(full, out);
} else if (/\.(tsx?|mjs|jsx?)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
function scan() {
const names = pluginNames();
const found = {};
const add = (file, what) => {
const key = path.relative(ROOT, file).replaceAll("\\", "/");
(found[key] ??= new Set()).add(what);
};
for (const file of walk(UI, [])) {
const source = fs.readFileSync(file, "utf8");
for (const match of source.matchAll(
/(?:from|import)\s*\(?\s*["']([^"']+)["']/g,
)) {
if (/(^|\/)plugins\//.test(match[1])) add(file, `import ${match[1]}`);
}
for (const match of source.matchAll(/["'`]([a-z][a-z0-9_-]*)["'`]/g)) {
if (names.has(match[1])) add(file, match[1]);
}
}
return Object.fromEntries(
Object.entries(found).map(([file, set]) => [file, [...set].sort()]),
);
}
function main() {
const found = scan();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(found, null, 2));
return;
}
const allow = JSON.parse(fs.readFileSync(ALLOWLIST, "utf8")).files ?? {};
const problems = [];
for (const [file, literals] of Object.entries(found)) {
const allowed = new Set(allow[file]?.literals ?? []);
for (const literal of literals) {
if (!allowed.has(literal)) problems.push(`${file}: ${literal}`);
}
}
for (const [file, entry] of Object.entries(allow)) {
for (const literal of entry.literals ?? []) {
if (!found[file]?.includes(literal)) {
problems.push(
`${file}: allowlisted "${literal}" no longer appears; remove it`,
);
}
}
}
if (problems.length > 0) {
console.error(
"The shell names plugins it should reach through a registry:",
);
for (const problem of problems) console.error(` ${problem}`);
process.exit(1);
}
}
main();