feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
measureOptionalRecipeBundle,
|
||||
type OptionalRecipeBundleMeasurement,
|
||||
} from "./lib/optional-recipe-bundle.ts";
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
|
||||
type GateViolation = Readonly<{
|
||||
ruleId: string;
|
||||
path: string;
|
||||
detail?: string;
|
||||
}>;
|
||||
|
||||
type ReferenceRuntimeBundleInput = Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
bundleBudgetGzipBytes: number;
|
||||
}>;
|
||||
|
||||
const argument = (name: string, fallback: string): string => {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index === -1 ? fallback : (process.argv[index + 1] ?? fallback);
|
||||
};
|
||||
|
||||
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"),
|
||||
) as Record<string, unknown>;
|
||||
const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : [];
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const catalogViolations = validateRecipeCatalog(catalog, packageDocument);
|
||||
const runtimeSourceViolations = (
|
||||
await Promise.all(
|
||||
recipes.flatMap((recipe: unknown) => {
|
||||
if (!recipe || typeof recipe !== "object") return [];
|
||||
const row = recipe as Record<string, unknown>;
|
||||
const runtime = row.referenceRuntime;
|
||||
if (!runtime || typeof runtime !== "object") return [];
|
||||
const sourceRoots = (runtime as Record<string, unknown>).sourceRoots;
|
||||
if (!Array.isArray(sourceRoots)) return [];
|
||||
return sourceRoots
|
||||
.filter(
|
||||
(sourceRoot): sourceRoot is string =>
|
||||
typeof sourceRoot === "string",
|
||||
)
|
||||
.map(async (sourceRoot) => ({
|
||||
sourceRoot,
|
||||
exists: await stat(sourceRoot)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
}));
|
||||
}),
|
||||
)
|
||||
).filter(({ exists }) => !exists);
|
||||
const {
|
||||
inputs: referenceRuntimeBundleInputs,
|
||||
violations: referenceRuntimeBundleConfigurationViolations,
|
||||
} = referenceRuntimeBundleInputsFrom(recipes);
|
||||
const referenceRuntimeBundles: OptionalRecipeBundleMeasurement[] = [];
|
||||
const referenceRuntimeBundleMeasurementViolations: GateViolation[] = [];
|
||||
for (const bundleInput of referenceRuntimeBundleInputs) {
|
||||
try {
|
||||
const measurement = await measureOptionalRecipeBundle(bundleInput);
|
||||
referenceRuntimeBundles.push(measurement);
|
||||
if (!measurement.passed) {
|
||||
referenceRuntimeBundleMeasurementViolations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_BUDGET_EXCEEDED",
|
||||
path: measurement.recipeId,
|
||||
detail:
|
||||
`${measurement.gzipBytes} > ` +
|
||||
`${measurement.bundleBudgetGzipBytes} gzip bytes`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
referenceRuntimeBundleMeasurementViolations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_MEASUREMENT_FAILED",
|
||||
path: bundleInput.recipeId,
|
||||
detail: safeErrorSummary(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
referenceRuntimeBundles.sort((left, right) =>
|
||||
compareText(left.recipeId, right.recipeId),
|
||||
);
|
||||
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
|
||||
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const bundleViolations = await scanProductionBundle(distRoot);
|
||||
const violations: GateViolation[] = [
|
||||
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
|
||||
...runtimeSourceViolations.map(({ sourceRoot }) => ({
|
||||
ruleId: "REFERENCE_RUNTIME_SOURCE_MISSING",
|
||||
path: sourceRoot,
|
||||
})),
|
||||
...sourceViolations,
|
||||
...bundleViolations.map((path) => ({
|
||||
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
|
||||
path,
|
||||
})),
|
||||
...(requireDist && !bundlePresent
|
||||
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
|
||||
: []),
|
||||
...referenceRuntimeBundleConfigurationViolations,
|
||||
...referenceRuntimeBundleMeasurementViolations,
|
||||
];
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
decisionId: "VD-10",
|
||||
selectedCapabilities: [],
|
||||
referenceRuntimes: recipes
|
||||
.filter(
|
||||
(recipe: unknown): recipe is Record<string, unknown> =>
|
||||
Boolean(
|
||||
recipe &&
|
||||
typeof recipe === "object" &&
|
||||
(recipe as Record<string, unknown>).referenceRuntime,
|
||||
),
|
||||
)
|
||||
.map((recipe: Record<string, unknown>) => ({
|
||||
id: recipe.id,
|
||||
referenceRuntime: recipe.referenceRuntime,
|
||||
})),
|
||||
recipeCount: recipes.length,
|
||||
productionRuntimeDependencies:
|
||||
catalog.productionRuntimeDependencies ?? null,
|
||||
referenceRuntimeBundleBudgets: referenceRuntimeBundles,
|
||||
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}` +
|
||||
(violation.detail ? ` (${violation.detail})` : ""),
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const referenceRuntimeBudgetStatus =
|
||||
referenceRuntimeBundles.length > 0
|
||||
? `, budgets=${referenceRuntimeBundles
|
||||
.map(
|
||||
(measurement) =>
|
||||
`${measurement.recipeId}:${measurement.gzipBytes}/` +
|
||||
measurement.bundleBudgetGzipBytes,
|
||||
)
|
||||
.join(",")} gzip bytes`
|
||||
: "";
|
||||
process.stdout.write(
|
||||
`Optional recipes: PASS (${report.recipeCount} optional capabilities, ${report.referenceRuntimes.length} uncomposed reference runtimes, bundle=${report.bundleStatus}${referenceRuntimeBudgetStatus})\n`,
|
||||
);
|
||||
|
||||
function referenceRuntimeBundleInputsFrom(
|
||||
recipeRows: readonly unknown[],
|
||||
): Readonly<{
|
||||
inputs: readonly ReferenceRuntimeBundleInput[];
|
||||
violations: readonly GateViolation[];
|
||||
}> {
|
||||
const inputs: ReferenceRuntimeBundleInput[] = [];
|
||||
const violations: GateViolation[] = [];
|
||||
for (const recipe of recipeRows) {
|
||||
if (!recipe || typeof recipe !== "object") continue;
|
||||
const row = recipe as Record<string, unknown>;
|
||||
if (row.referenceRuntime === undefined) continue;
|
||||
const recipeId =
|
||||
typeof row.id === "string" ? row.id : "unknown-reference-runtime";
|
||||
const runtime = row.referenceRuntime;
|
||||
const sourceRoots =
|
||||
runtime && typeof runtime === "object"
|
||||
? (runtime as Record<string, unknown>).sourceRoots
|
||||
: null;
|
||||
if (
|
||||
!Array.isArray(sourceRoots) ||
|
||||
!sourceRoots.every(
|
||||
(sourceRoot): sourceRoot is string =>
|
||||
typeof sourceRoot === "string",
|
||||
) ||
|
||||
!Number.isSafeInteger(row.bundleBudgetGzipBytes) ||
|
||||
(row.bundleBudgetGzipBytes as number) < 1
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_BUNDLE_CONFIGURATION_INVALID",
|
||||
path: recipeId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
inputs.push(
|
||||
Object.freeze({
|
||||
recipeId,
|
||||
sourceRoots: Object.freeze([...sourceRoots]),
|
||||
bundleBudgetGzipBytes: row.bundleBudgetGzipBytes as number,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
inputs: Object.freeze(
|
||||
inputs.sort((left, right) =>
|
||||
compareText(left.recipeId, right.recipeId),
|
||||
),
|
||||
),
|
||||
violations: Object.freeze(violations),
|
||||
});
|
||||
}
|
||||
|
||||
function safeErrorSummary(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "unknown measurement failure";
|
||||
return message
|
||||
.split(/\r?\n/, 1)[0]
|
||||
?.replaceAll(process.cwd(), ".")
|
||||
.slice(0, 512) ?? "unknown measurement failure";
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user