feat: add optional frontend adapter recipes

This commit is contained in:
donghyeon-ka
2026-07-26 17:57:04 +09:00
parent 638f5f71bd
commit 6c73b845bd
29 changed files with 2291 additions and 45 deletions
@@ -0,0 +1,93 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
scanOptionalRecipeSources,
validateRecipeCatalog,
} from "./lib/optional-recipes.mjs";
const catalog = JSON.parse(
await readFile("config/recipes/frontend-capability-recipes.json", "utf8"),
);
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
const cleanupCatalog = structuredClone(catalog);
cleanupCatalog.recipes.find(
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "realtime",
).lifecycleMethods = [];
const dependencyCatalog = structuredClone(catalog);
dependencyCatalog.productionRuntimeDependencies = ["zustand"];
const workflowCatalog = structuredClone(catalog);
workflowCatalog.recipes.find(
/** @param {{id: string}} recipe */ (recipe) => recipe.id === "client-workflow",
).serverStatePolicy = "copied-server-state";
const sourceViolations = await scanOptionalRecipeSources(
"tests/fixtures/optional-recipes/forbidden",
{ scanProductionBoundary: false },
);
const productionViolations = await scanOptionalRecipeSources(
"tests/fixtures/optional-recipes/forbidden/production-import",
{ scanProductionBoundary: true },
);
sourceViolations.push(...productionViolations);
const ruleIds = new Set(sourceViolations.map(({ ruleId }) => ruleId));
const results = [
{
id: "cleanup-omission",
passed: validateRecipeCatalog(cleanupCatalog, packageDocument).some(
(violation) => violation === "realtime:CLEANUP_CONTRACT_MISSING",
),
},
{
id: "unselected-runtime-dependency",
passed: validateRecipeCatalog(dependencyCatalog, packageDocument).includes(
"UNSELECTED_RUNTIME_DEPENDENCY",
),
},
{
id: "server-state-policy",
passed: validateRecipeCatalog(workflowCatalog, packageDocument).includes(
"client-workflow:SERVER_STATE_DUPLICATION_POLICY",
),
},
{
id: "vendor-direct-import",
passed: ruleIds.has("VENDOR_IMPORT_OUTSIDE_ADAPTER"),
},
{
id: "credential-leak",
passed: ruleIds.has("CREDENTIAL_LEAK_PATH"),
},
{
id: "server-state-source-duplication",
passed: ruleIds.has("CLIENT_STORE_DUPLICATES_SERVER_STATE"),
},
{
id: "production-imports-recipe",
passed: ruleIds.has("PRODUCTION_IMPORTS_RECIPE"),
},
];
const report = {
schemaVersion: 1,
results,
passed: results.every(({ passed }) => passed),
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(
"artifacts/quality/optional-recipe-fixtures.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!report.passed) {
process.stderr.write(
`Optional recipe negative fixtures failed: ${results
.filter(({ passed }) => !passed)
.map(({ id }) => id)
.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipe negative fixtures: PASS (${results.length} forbidden cases rejected)\n`,
);
+74
View File
@@ -0,0 +1,74 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import {
scanOptionalRecipeSources,
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.mjs";
/** @param {string} name @param {string} fallback */
const argument = (name, fallback) => {
const index = process.argv.indexOf(name);
return index === -1 ? fallback : process.argv[index + 1];
};
const catalogPath = argument(
"--catalog",
"config/recipes/frontend-capability-recipes.json",
);
const sourceRoot = argument("--source-root", "src");
const distRoot = argument("--dist-root", "dist");
const artifactPath = argument(
"--artifact",
"artifacts/quality/optional-recipes.json",
);
const requireDist = process.argv.includes("--require-dist");
const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
const packageDocument = JSON.parse(await readFile("package.json", "utf8"));
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
.then(() => true)
.catch(() => false);
const bundleViolations = await scanProductionBundle(distRoot);
const violations = [
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
...sourceViolations,
...bundleViolations.map((path) => ({
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
path,
})),
...(requireDist && !bundlePresent
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
: []),
];
const report = {
schemaVersion: 1,
decisionId: "VD-10",
selectedCapabilities: [],
recipeCount: Array.isArray(catalog.recipes) ? catalog.recipes.length : 0,
productionRuntimeDependencies:
catalog.productionRuntimeDependencies ?? null,
bundleStatus: bundlePresent
? bundleViolations.length === 0
? "PASS"
: "FAIL"
: "NOT_BUILT",
violations,
passed: violations.length === 0,
};
await mkdir("artifacts/quality", { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
if (violations.length > 0) {
process.stderr.write(
`Optional recipe contract failed:\n${violations
.map((violation) => `${violation.ruleId}: ${violation.path}`)
.join("\n")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipes: PASS (${report.recipeCount} recipe-only capabilities, bundle=${report.bundleStatus})\n`,
);
+265
View File
@@ -0,0 +1,265 @@
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;
}
+113
View File
@@ -0,0 +1,113 @@
import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
const fixtureRoot = path.resolve(".tmp/optional-recipe-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"config",
"public",
"index.html",
"package.json",
"tsconfig.base.json",
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",
"eslint.config.js",
".dependency-cruiser.cjs",
];
/** @param {string} script */
function runPnpm(script) {
return (
spawnSync(process.execPath, [pnpmCli, script], {
cwd: fixtureRoot,
stdio: "inherit",
}).status === 0
);
}
/** @param {string} directory @returns {Promise<string[]>} */
async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const groups = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesBelow(target) : [target];
}),
);
return groups.flat();
}
await rm(fixtureRoot, { recursive: true, force: true });
await mkdir(fixtureRoot, { recursive: true });
for (const target of copyTargets) {
await cp(target, path.join(fixtureRoot, target), { recursive: true });
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
await rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true });
await rm(path.join(fixtureRoot, "tests/recipes"), {
recursive: true,
force: true,
});
const checks = [
["typecheck", runPnpm("check:types")],
["architecture", runPnpm("check:architecture")],
["test", runPnpm("test:all")],
["build", runPnpm("build")],
];
/** @type {string[]} */
const residue = [];
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
if (!/\.(?:js|css|html|json)$/.test(file)) continue;
const content = await readFile(file, "utf8");
if (content.includes("frontend-optional-recipe-must-not-reach-production")) {
residue.push(path.relative(fixtureRoot, file));
}
}
checks.push(["bundle-residue", residue.length === 0]);
const passed = checks.every(([, result]) => result);
await mkdir("artifacts/tests", { recursive: true });
await writeFile(
"artifacts/tests/optional-recipe-removal.xml",
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<testsuite name="optional-recipe-removal" tests="${checks.length}" failures="${passed ? 0 : 1}">` +
checks
.map(
([name, result]) =>
`<testcase name="${name}">${result ? "" : `<failure>${residue.join(", ")}</failure>`}</testcase>`,
)
.join("") +
`</testsuite>\n`,
);
await rm(fixtureRoot, { recursive: true, force: true });
if (!passed) {
process.stderr.write(
`Optional recipe removal failed: ${checks
.filter(([, result]) => !result)
.map(([name]) => name)
.join(", ")}\n`,
);
process.exit(1);
}
process.stdout.write(
`Optional recipe removal: PASS (${checks.length} base checks)\n`,
);
+2
View File
@@ -24,6 +24,7 @@ const featureOwnedPaths = [
const copyTargets = [
"src",
"tests",
"recipes",
"scripts",
"config",
"public",
@@ -34,6 +35,7 @@ const copyTargets = [
"tsconfig.app.json",
"tsconfig.node.json",
"tsconfig.test.json",
"tsconfig.recipes.json",
"vite.config.js",
"vitest.config.js",
"playwright.config.js",