266 lines
7.7 KiB
JavaScript
266 lines
7.7 KiB
JavaScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
export const REQUIRED_RECIPE_IDS = Object.freeze([
|
|
"analytics-error-sink",
|
|
"browser-permission",
|
|
"client-workflow",
|
|
"feature-flag",
|
|
"file-transfer",
|
|
"generated-api",
|
|
"large-data-ui",
|
|
"multi-tab",
|
|
"offline-indexeddb",
|
|
"realtime",
|
|
"service-worker-pwa",
|
|
"web-worker",
|
|
]);
|
|
|
|
const lifecycleRecipes = new Set([
|
|
"analytics-error-sink",
|
|
"browser-permission",
|
|
"client-workflow",
|
|
"file-transfer",
|
|
"generated-api",
|
|
"multi-tab",
|
|
"offline-indexeddb",
|
|
"realtime",
|
|
"service-worker-pwa",
|
|
"web-worker",
|
|
]);
|
|
|
|
/** @param {unknown} value */
|
|
function nonEmptyStrings(value) {
|
|
return (
|
|
Array.isArray(value) &&
|
|
value.length > 0 &&
|
|
value.every((entry) => typeof entry === "string" && entry.trim().length > 0)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} input
|
|
* @param {Readonly<Record<string, unknown>>} packageDocument
|
|
* @returns {string[]}
|
|
*/
|
|
export function validateRecipeCatalog(input, packageDocument) {
|
|
const document =
|
|
/** @type {Record<string, any>} */ (
|
|
input && typeof input === "object" ? input : {}
|
|
);
|
|
/** @type {string[]} */
|
|
const violations = [];
|
|
if (document.schemaVersion !== 1) violations.push("CATALOG_SCHEMA_VERSION");
|
|
if (document.decisionId !== "VD-10") violations.push("CATALOG_DECISION");
|
|
if (document.defaultStatus !== "NOT_INSTALLED") {
|
|
violations.push("CATALOG_DEFAULT_MUST_BE_NOT_INSTALLED");
|
|
}
|
|
if (
|
|
!Array.isArray(document.productionRuntimeDependencies) ||
|
|
document.productionRuntimeDependencies.length > 0
|
|
) {
|
|
violations.push("UNSELECTED_RUNTIME_DEPENDENCY");
|
|
}
|
|
if (!nonEmptyStrings(document.vendorPackagePatterns)) {
|
|
violations.push("VENDOR_PATTERN_CATALOG");
|
|
}
|
|
if (!Array.isArray(document.recipes)) {
|
|
return [...violations, "RECIPE_CATALOG_MISSING"];
|
|
}
|
|
|
|
const actualIds = document.recipes
|
|
.map(/** @param {Record<string, unknown>} recipe */ (recipe) => recipe.id)
|
|
.sort();
|
|
if (JSON.stringify(actualIds) !== JSON.stringify(REQUIRED_RECIPE_IDS)) {
|
|
violations.push("RECIPE_ID_SET");
|
|
}
|
|
if (new Set(actualIds).size !== actualIds.length) {
|
|
violations.push("RECIPE_ID_DUPLICATE");
|
|
}
|
|
|
|
for (const recipe of document.recipes) {
|
|
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
|
if (recipe.status !== "RECIPE_AVAILABLE") {
|
|
violations.push(`${id}:STATUS_MUST_NOT_CLAIM_INSTALLED`);
|
|
}
|
|
for (const field of [
|
|
"trigger",
|
|
"boundary",
|
|
"port",
|
|
"fake",
|
|
"owner",
|
|
"fallback",
|
|
"serverStatePolicy",
|
|
]) {
|
|
if (typeof recipe[field] !== "string" || recipe[field].trim().length === 0) {
|
|
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
|
}
|
|
}
|
|
for (const field of [
|
|
"forbiddenWhen",
|
|
"failureKinds",
|
|
"securityPrivacy",
|
|
"removal",
|
|
]) {
|
|
if (!nonEmptyStrings(recipe[field])) {
|
|
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
|
}
|
|
}
|
|
if (
|
|
!Number.isInteger(recipe.bundleBudgetGzipBytes) ||
|
|
recipe.bundleBudgetGzipBytes < 1
|
|
) {
|
|
violations.push(`${id}:INVALID_BUNDLE_BUDGET`);
|
|
}
|
|
if (recipe.owner === "frontend-platform") {
|
|
violations.push(`${id}:PROJECT_OWNER_NOT_ASSIGNED`);
|
|
}
|
|
if (
|
|
lifecycleRecipes.has(id) &&
|
|
!nonEmptyStrings(recipe.lifecycleMethods)
|
|
) {
|
|
violations.push(`${id}:CLEANUP_CONTRACT_MISSING`);
|
|
}
|
|
if (
|
|
id === "client-workflow" &&
|
|
recipe.serverStatePolicy !== "reference-only"
|
|
) {
|
|
violations.push(`${id}:SERVER_STATE_DUPLICATION_POLICY`);
|
|
}
|
|
}
|
|
|
|
const dependencies = {
|
|
.../** @type {Record<string, string>} */ (packageDocument.dependencies ?? {}),
|
|
.../** @type {Record<string, string>} */ (
|
|
packageDocument.devDependencies ?? {}
|
|
),
|
|
};
|
|
for (const pattern of document.vendorPackagePatterns ?? []) {
|
|
const wildcard = String(pattern).endsWith("*");
|
|
const prefix = String(pattern).replace(/\/?\*$/, "");
|
|
if (
|
|
Object.keys(dependencies).some(
|
|
(dependency) =>
|
|
dependency === prefix ||
|
|
dependency.startsWith(`${prefix}/`) ||
|
|
(wildcard && dependency.startsWith(prefix)),
|
|
)
|
|
) {
|
|
violations.push(`UNSELECTED_VENDOR_INSTALLED:${prefix}`);
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/** @param {string} directory @returns {Promise<string[]>} */
|
|
export async function sourceFiles(directory) {
|
|
let entries;
|
|
try {
|
|
entries = await readdir(directory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
error.code === "ENOENT"
|
|
) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
const groups = await Promise.all(
|
|
entries.map((entry) => {
|
|
const target = path.join(directory, entry.name);
|
|
return entry.isDirectory()
|
|
? sourceFiles(target)
|
|
: /\.(?:js|jsx|mjs|ts|tsx|mts)$/.test(entry.name)
|
|
? [target]
|
|
: [];
|
|
}),
|
|
);
|
|
return groups.flat();
|
|
}
|
|
|
|
/**
|
|
* @param {string} root
|
|
* @param {{scanProductionBoundary?: boolean}} [options]
|
|
*/
|
|
export async function scanOptionalRecipeSources(
|
|
root,
|
|
{ scanProductionBoundary = true } = {},
|
|
) {
|
|
/** @type {Array<{ruleId: string; path: string}>} */
|
|
const violations = [];
|
|
for (const file of await sourceFiles(root)) {
|
|
const relative = path.relative(process.cwd(), file).replaceAll("\\", "/");
|
|
const relativeToRoot = path.relative(root, file).replaceAll("\\", "/");
|
|
const content = await readFile(file, "utf8");
|
|
const imports = [
|
|
...content.matchAll(
|
|
/(?:from\s*|import\s*\(\s*)["']([^"']+)["']/g,
|
|
),
|
|
].map((match) => match[1]);
|
|
|
|
if (
|
|
scanProductionBoundary &&
|
|
(relativeToRoot.startsWith("src/") ||
|
|
(path.basename(path.resolve(root)) === "src" &&
|
|
!relativeToRoot.startsWith(".."))) &&
|
|
imports.some((specifier) =>
|
|
/(?:^|\/)recipes\/frontend-capabilities(?:\/|$)/.test(specifier),
|
|
)
|
|
) {
|
|
violations.push({ ruleId: "PRODUCTION_IMPORTS_RECIPE", path: relative });
|
|
}
|
|
|
|
const localVendorAdapter =
|
|
relative.includes("recipes/") && relative.includes("/adapters/");
|
|
if (
|
|
!localVendorAdapter &&
|
|
imports.some((specifier) =>
|
|
/^(?:@launchdarkly\/|@sentry\/|@opentelemetry\/|@openapitools\/openapi-generator-cli$|@reduxjs\/toolkit$|@tanstack\/react-virtual$|@uppy\/|firebase(?:\/|$)|idb$|react-window$|redux(?:\/|$)|socket\.io-client$|tus-js-client$|workbox-window$|xstate$|zustand$)/.test(
|
|
specifier,
|
|
),
|
|
)
|
|
) {
|
|
violations.push({ ruleId: "VENDOR_IMPORT_OUTSIDE_ADAPTER", path: relative });
|
|
}
|
|
|
|
if (
|
|
/localStorage\s*\.\s*(?:setItem|getItem)\s*\([^)]*(?:credential|password|secret|token)/is.test(
|
|
content,
|
|
) ||
|
|
/searchParams\s*\.\s*set\s*\(\s*["'](?:credential|password|secret|token)/is.test(
|
|
content,
|
|
) ||
|
|
/(?:record|track|emit)\s*\(\s*\{[\s\S]{0,400}(?:credential|password|secret|token)\s*:/i.test(
|
|
content,
|
|
)
|
|
) {
|
|
violations.push({ ruleId: "CREDENTIAL_LEAK_PATH", path: relative });
|
|
}
|
|
|
|
if (
|
|
/(?:createStore|configureStore|create\s*\()\s*\([\s\S]{0,600}(?:apiResponse|queryData|serverState)\s*:/i.test(
|
|
content,
|
|
)
|
|
) {
|
|
violations.push({ ruleId: "CLIENT_STORE_DUPLICATES_SERVER_STATE", path: relative });
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/** @param {string} distRoot */
|
|
export async function scanProductionBundle(distRoot) {
|
|
/** @type {string[]} */
|
|
const violations = [];
|
|
for (const file of await sourceFiles(distRoot)) {
|
|
const content = await readFile(file, "utf8");
|
|
if (content.includes("frontend-optional-recipe-must-not-reach-production")) {
|
|
violations.push(path.relative(process.cwd(), file));
|
|
}
|
|
}
|
|
return violations;
|
|
}
|