Files
homarr/scripts/feature-platform/contracts.mts
T
2026-08-14 02:18:46 +02:00

310 lines
13 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
export interface ContractProblem {
message: string;
repair: string;
}
const read = (root: string, path: string) => readFileSync(join(root, path), "utf8");
const containerBody = (source: string, variable: string, opener: "{" | "[") => {
const declaration = source.indexOf(`const ${variable}`);
if (declaration < 0) throw new Error(`Could not find const ${variable}`);
const start = source.indexOf(opener, declaration);
if (start < 0) throw new Error(`Could not find ${opener} for ${variable}`);
const closer = opener === "{" ? "}" : "]";
let depth = 0;
let quote = "";
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'" || character === "`") {
quote = character;
continue;
}
if (character === opener) depth += 1;
if (character === closer) depth -= 1;
if (depth === 0) return source.slice(start + 1, index);
}
throw new Error(`Could not find closing ${closer} for ${variable}`);
};
const objectEntries = (source: string, variable: string) => {
const body = containerBody(source, variable, "{");
const matches = [...body.matchAll(/^ (?:"([^"]+)"|([\w-]+))(?::\s|,\s*$)/gm)];
return new Map(
matches.map((match, index) => [
match[1] ?? match[2],
body.slice((match.index ?? 0) + match[0].length, matches[index + 1]?.index ?? body.length).trim(),
]),
);
};
const arrayStrings = (source: string, variable: string) =>
[...containerBody(source, variable, "[").matchAll(/^ "([^"]+)",?$/gm)].map((match) => match[1]);
export const readObjectRegistry = (root: string, path: string, variable: string) =>
objectEntries(read(root, path), variable);
export const readArrayRegistry = (root: string, path: string, variable: string) =>
arrayStrings(read(root, path), variable);
const compareKeys = (
problems: ContractProblem[],
expectedName: string,
expected: Iterable<string>,
actualName: string,
actual: Iterable<string>,
) => {
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
const missing = [...expectedSet].filter((key) => !actualSet.has(key));
const extra = [...actualSet].filter((key) => !expectedSet.has(key));
if (missing.length === 0 && extra.length === 0) return;
problems.push({
message: `${actualName} does not match ${expectedName} (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`,
repair: `Add or remove the named entries in ${actualName} so it has exactly the same keys as ${expectedName}.`,
});
};
const walkFiles = (directory: string): string[] => {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
if (entry.isDirectory() && [".git", ".turbo", "node_modules"].includes(entry.name)) return [];
const path = join(directory, entry.name);
return entry.isDirectory() ? walkFiles(path) : [path];
});
};
const directStringOrNull = (value: string | undefined) => {
if (!value) return undefined;
if (/^null,?$/.test(value)) return null;
return value.match(/^"([^"]+)"/)?.[1];
};
const documentationPath = (definition: string | undefined) => {
if (!definition) return undefined;
const value = definition.match(/^ documentationUrl: (.+),$/m)?.[1];
if (value === "null") return null;
return value?.match(/^createDocumentationLink\("([^"]+)"\)$/)?.[1];
};
const collectWidgetDefinitions = (root: string) => {
const definitions = new Map<
string,
{ path: string; hasSupportedIntegrations: boolean; maxIntegrations: number | undefined }
>();
for (const path of walkFiles(join(root, "packages/widgets/src"))) {
if (!/\/index\.tsx?$/.test(path)) continue;
const source = readFileSync(path, "utf8");
const kindConstants = new Map(
[...source.matchAll(/(?:export )?const (\w+) = "([^"]+)";/g)].map((match) => [match[1], match[2]]),
);
for (const match of source.matchAll(/createWidgetDefinition\(("([^"]+)"|(\w+)),\s*\{/g)) {
const body = source.slice(match.index, source.indexOf("}).withDynamicImport", match.index));
const kind = match[2] ?? kindConstants.get(match[3]);
if (!kind) continue;
definitions.set(kind, {
path: relative(root, path),
hasSupportedIntegrations: /\n supportedIntegrations:/.test(body),
maxIntegrations: Number(body.match(/\n maxIntegrations:\s*(\d+)/)?.[1]) || undefined,
});
}
}
return definitions;
};
const exportTargets = (value: unknown): string[] => {
if (typeof value === "string") return [value];
if (!value || typeof value !== "object") return [];
return Object.values(value).flatMap(exportTargets);
};
const escapeRegex = (value: string) => value.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
const targetExists = (packageDirectory: string, target: string) => {
if (!target.startsWith("./")) return true;
const absoluteTarget = resolve(packageDirectory, target);
if (!target.includes("*")) return existsSync(absoluteTarget);
const wildcardIndex = absoluteTarget.indexOf("*");
const searchRoot = dirname(absoluteTarget.slice(0, wildcardIndex));
if (!existsSync(searchRoot)) return false;
const pattern = new RegExp(`^${escapeRegex(absoluteTarget).replaceAll("*", "[^/]+")}$`);
return walkFiles(searchRoot).some((path) => pattern.test(path));
};
export const checkPackageExports = (root: string): ContractProblem[] => {
const packageFiles = walkFiles(root).filter(
(path) => path.endsWith("package.json") && !path.includes("/node_modules/") && !path.includes("/.turbo/"),
);
const problems: ContractProblem[] = [];
for (const packageFile of packageFiles) {
const packageJson = JSON.parse(readFileSync(packageFile, "utf8")) as { exports?: unknown };
for (const target of exportTargets(packageJson.exports)) {
if (targetExists(dirname(packageFile), target)) continue;
problems.push({
message: `${relative(root, packageFile)} exports ${target}, but that target does not resolve`,
repair:
"Remove the stale export or add the intended source file; do not leave a public package path pointing at nothing.",
});
}
}
return problems;
};
export const checkFeatureContracts = (root: string): ContractProblem[] => {
const problems: ContractProblem[] = [];
const integrationDefinitions = objectEntries(
read(root, "packages/definitions/src/integration.ts"),
"integrationDefs",
);
const creatorKeys = objectEntries(
read(root, "packages/integrations/src/base/creator.ts"),
"integrationCreators",
).keys();
const integrationSlugs = objectEntries(
read(root, "packages/definitions/src/docs/integration-doc-slugs.ts"),
"integrationDocSlugs",
);
compareKeys(problems, "integrationDefs", integrationDefinitions.keys(), "integrationCreators", creatorKeys);
compareKeys(
problems,
"integrationDefs",
integrationDefinitions.keys(),
"integrationDocSlugs",
integrationSlugs.keys(),
);
for (const [kind, definition] of integrationDefinitions) {
const slug = directStringOrNull(integrationSlugs.get(kind));
const link = documentationPath(definition);
const expected = slug === null ? null : `/docs/integrations/${slug}`;
if (slug === undefined || link !== expected) {
problems.push({
message: `Integration ${kind} has docs slug ${String(slug)} but documentationUrl ${String(link)}`,
repair:
slug === null
? "Set documentationUrl to null."
: `Set documentationUrl to createDocumentationLink("${expected}").`,
});
}
if (slug !== null && slug !== undefined) {
for (const filename of ["index.ts", "index.mdx"]) {
const path = `apps/docs/docs/integrations/${slug}/${filename}`;
if (!existsSync(join(root, path)))
problems.push({
message: `Integration ${kind} points to missing ${path}`,
repair: `Add ${path} or remove the docs slug.`,
});
}
}
}
const widgetKinds = arrayStrings(read(root, "packages/definitions/src/widget.ts"), "widgetKinds");
const manifest = read(root, "packages/widgets/src/manifest.ts");
const definitions = collectWidgetDefinitions(root);
const widgetSlugs = objectEntries(read(root, "packages/definitions/src/docs/widget-doc-slugs.ts"), "widgetDocSlugs");
const support = objectEntries(
read(root, "packages/definitions/src/widget-integration-map.ts"),
"nativeFeatureCapabilities",
);
compareKeys(problems, "widgetKinds", widgetKinds, "moduleLoaders", objectEntries(manifest, "moduleLoaders").keys());
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"componentLoaders",
objectEntries(manifest, "componentLoaders").keys(),
);
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"widgetCatalogIcons",
objectEntries(read(root, "packages/widgets/src/catalog.ts"), "widgetCatalogIcons").keys(),
);
compareKeys(
problems,
"widgetKinds",
widgetKinds,
"widgetImports",
objectEntries(read(root, "packages/widgets/src/index.tsx"), "widgetImports").keys(),
);
compareKeys(problems, "widgetKinds", widgetKinds, "widget definitions", definitions.keys());
compareKeys(problems, "widgetKinds", widgetKinds, "widgetDocSlugs", widgetSlugs.keys());
const supportSource = read(root, "packages/definitions/src/widget-integration-map.ts");
if (!supportSource.includes("widgetIntegrationSupport = createWidgetIntegrationSupport()")) {
problems.push({
message: "widgetIntegrationSupport is not derived from nativeFeatureCapabilities",
repair: "Generate the forward widget support map from the canonical capability descriptor.",
});
}
if (!supportSource.includes("integrationWidgetSupport = createIntegrationWidgetSupport()")) {
problems.push({
message: "integrationWidgetSupport is not derived from nativeFeatureCapabilities",
repair: "Generate the reverse integration support map from the canonical capability descriptor.",
});
}
const translations = JSON.parse(read(root, "packages/translation/src/lang/en.json")) as {
widget?: Record<string, { name?: string; description?: string }>;
};
for (const kind of widgetKinds) {
const translation = translations.widget?.[kind];
if (!translation?.name?.trim() || !translation.description?.trim()) {
problems.push({
message: `Widget ${kind} is missing a non-empty English name or description`,
repair: `Add widget.${kind}.name and widget.${kind}.description to packages/translation/src/lang/en.json.`,
});
}
const slug = directStringOrNull(widgetSlugs.get(kind));
if (slug !== null && slug !== undefined) {
const metadataPath = `apps/docs/docs/widgets/${slug}/index.ts`;
if (!existsSync(join(root, metadataPath)) && !existsSync(join(root, `${metadataPath}x`)))
problems.push({
message: `Widget ${kind} points to missing ${metadataPath}(x)`,
repair: `Add ${metadataPath} or remove the docs slug.`,
});
const pagePath = `apps/docs/docs/widgets/${slug}/index.mdx`;
if (!existsSync(join(root, pagePath)))
problems.push({
message: `Widget ${kind} points to missing ${pagePath}`,
repair: `Add ${pagePath} or remove the docs slug.`,
});
}
const definition = definitions.get(kind);
if (definition?.hasSupportedIntegrations !== support.has(kind)) {
problems.push({
message: `Widget ${kind} integration support differs between ${definition?.path ?? "its definition"} and nativeFeatureCapabilities`,
repair:
"Declare supportedIntegrations in both the widget definition and nativeFeatureCapabilities, or in neither.",
});
}
const serverMaxIntegrations = Number(support.get(kind)?.match(/serverMaxIntegrations:\s*(\d+)/)?.[1]) || undefined;
if (definition?.maxIntegrations !== serverMaxIntegrations) {
problems.push({
message: `Widget ${kind} maxIntegrations ${String(definition?.maxIntegrations)} differs from nativeFeatureCapabilities serverMaxIntegrations ${String(serverMaxIntegrations)}`,
repair: "Declare the same integration limit in the widget definition and nativeFeatureCapabilities.",
});
}
}
for (const kind of support.keys()) {
if (!widgetKinds.includes(kind))
problems.push({
message: `nativeFeatureCapabilities contains unknown widget ${kind}`,
repair: "Remove it or add the widget kind first.",
});
}
problems.push(...checkPackageExports(root));
return problems;
};