feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([
|
||||
"VITE_BUILD_ID",
|
||||
"VITE_COMMIT_SHA",
|
||||
"RELEASE_ID",
|
||||
"CI_RUNNER_IMAGE",
|
||||
"SOURCE_DATE_EPOCH",
|
||||
]);
|
||||
|
||||
export function ciBuildEnvironmentFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter(
|
||||
(name) => !environment[name]?.trim(),
|
||||
).map((name) => `missing required CI build environment: ${name}`);
|
||||
|
||||
const commitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (commitSha && !isValidCommitSha(commitSha)) {
|
||||
failures.push(
|
||||
"VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID",
|
||||
);
|
||||
}
|
||||
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (sourceDateEpoch && !isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
failures.push("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
|
||||
const runnerImage = environment.CI_RUNNER_IMAGE?.trim();
|
||||
if (
|
||||
runnerImage &&
|
||||
!/@sha256:[0-9a-f]{64}$/i.test(runnerImage)
|
||||
) {
|
||||
failures.push(
|
||||
"CI_RUNNER_IMAGE must end with an immutable @sha256 image digest",
|
||||
);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function assertCiBuildEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const failures = ciBuildEnvironmentFailures(environment);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(failures.join("; "));
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidCommitSha(value: string) {
|
||||
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
|
||||
}
|
||||
|
||||
export function isValidSourceDateEpoch(value: string) {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const milliseconds = Number(value) * 1_000;
|
||||
return Number.isSafeInteger(milliseconds) && Number.isFinite(
|
||||
new Date(milliseconds).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
export function ciCheckoutIdentityFailures(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
checkout: { commitSha: string; sourceDateEpoch: string },
|
||||
) {
|
||||
if (environment.CI !== "true") return [];
|
||||
|
||||
const failures = [];
|
||||
const configuredCommitSha = environment.VITE_COMMIT_SHA?.trim();
|
||||
if (
|
||||
configuredCommitSha &&
|
||||
configuredCommitSha.toLowerCase() !== checkout.commitSha.toLowerCase()
|
||||
) {
|
||||
failures.push("VITE_COMMIT_SHA does not identify the checked-out commit");
|
||||
}
|
||||
const configuredEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (configuredEpoch && configuredEpoch !== checkout.sourceDateEpoch) {
|
||||
failures.push(
|
||||
"SOURCE_DATE_EPOCH does not match the checked-out commit timestamp",
|
||||
);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function buildDate(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
) {
|
||||
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
||||
if (!sourceDateEpoch) return new Date();
|
||||
if (!isValidSourceDateEpoch(sourceDateEpoch)) {
|
||||
throw new Error("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
||||
}
|
||||
return new Date(Number(sourceDateEpoch) * 1_000);
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* file: string,
|
||||
* isEntry?: boolean,
|
||||
* imports?: string[]
|
||||
* }} ViteManifestEntry
|
||||
*/
|
||||
type ViteManifestEntry = Readonly<{
|
||||
file: string;
|
||||
isEntry?: boolean;
|
||||
imports?: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Static imports of an entry are part of initial JavaScript. Every remaining
|
||||
* JavaScript output is governed by the lazy-chunk budget.
|
||||
*
|
||||
* @param {Record<string, ViteManifestEntry>} manifest
|
||||
*/
|
||||
export function classifyViteJavascript(manifest) {
|
||||
const initialFiles = new Set();
|
||||
const visitedKeys = new Set();
|
||||
export function classifyViteJavascript(
|
||||
manifest: Readonly<Record<string, ViteManifestEntry>>,
|
||||
) {
|
||||
const initialFiles = new Set<string>();
|
||||
const visitedKeys = new Set<string>();
|
||||
const pendingKeys = Object.entries(manifest)
|
||||
.filter(([, entry]) => entry.isEntry)
|
||||
.map(([key]) => key);
|
||||
const missingImports = [];
|
||||
const missingImports: string[] = [];
|
||||
|
||||
while (pendingKeys.length > 0) {
|
||||
const key = /** @type {string} */ (pendingKeys.pop());
|
||||
const key = pendingKeys.pop();
|
||||
if (key === undefined) break;
|
||||
if (visitedKeys.has(key)) continue;
|
||||
visitedKeys.add(key);
|
||||
const entry = manifest[key];
|
||||
@@ -68,15 +68,10 @@ const fieldEvidenceInputSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {string | undefined} configuredMinimum
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export function validateFieldEvidenceInput(
|
||||
input,
|
||||
configuredMinimum,
|
||||
now = new Date(),
|
||||
input: unknown,
|
||||
configuredMinimum: string | undefined,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const parsed = fieldEvidenceInputSchema.safeParse(input);
|
||||
const failures = parsed.success
|
||||
@@ -4,15 +4,21 @@ const LOOPBACK_IPV4 = /^127(?:\.\d{1,3}){3}$/;
|
||||
* A release gate must not promote a local preview server as live hosting
|
||||
* evidence.
|
||||
*
|
||||
* @param {string} value
|
||||
* @returns {
|
||||
* | { passed: true; reason: null; url: URL; observedOrigin: string }
|
||||
* | { passed: false; reason: string; url: URL | null; observedOrigin: string | null }
|
||||
* }
|
||||
*/
|
||||
export function classifyLiveHostingBaseUrl(value) {
|
||||
/** @type {URL} */
|
||||
let url;
|
||||
export function classifyLiveHostingBaseUrl(value: string):
|
||||
| {
|
||||
passed: true;
|
||||
reason: null;
|
||||
url: URL;
|
||||
observedOrigin: string;
|
||||
}
|
||||
| {
|
||||
passed: false;
|
||||
reason: string;
|
||||
url: URL | null;
|
||||
observedOrigin: string | null;
|
||||
} {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
export const MANUAL_A11Y_ROUTE_IDS = Object.freeze(
|
||||
Object.values(ROUTE_REGISTRY).map((route) => route.routeId),
|
||||
@@ -15,19 +15,18 @@ const REVIEW_FIELDS = Object.freeze([
|
||||
"Screen reader",
|
||||
]);
|
||||
|
||||
/** @param {string} content */
|
||||
export function validateManualA11yEvidence(content) {
|
||||
export function validateManualA11yEvidence(content: string) {
|
||||
const fields = Object.fromEntries(
|
||||
content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => /^([^:]+):\s*(.*)$/.exec(line))
|
||||
.filter(Boolean)
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => [
|
||||
/** @type {RegExpExecArray} */ (match)[1].trim(),
|
||||
/** @type {RegExpExecArray} */ (match)[2].trim(),
|
||||
match[1].trim(),
|
||||
match[2].trim(),
|
||||
]),
|
||||
);
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
if (fields.Status !== "reviewed") failures.push("Status");
|
||||
if (!fields["Route ID"]) failures.push("Route ID");
|
||||
if (!fields["Release ID"]) failures.push("Release ID");
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstat, readdir, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
import {
|
||||
build,
|
||||
normalizePath,
|
||||
type Plugin,
|
||||
version as viteVersion,
|
||||
} from "vite";
|
||||
|
||||
const RECIPE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||
const DECLARATION_FILE = /\.d\.[cm]?ts$/;
|
||||
|
||||
type EmittedOutput =
|
||||
| Readonly<{
|
||||
type: "chunk";
|
||||
fileName: string;
|
||||
code: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
type: "asset";
|
||||
fileName: string;
|
||||
source: string | Uint8Array;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleOutput = Readonly<{
|
||||
fileName: string;
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type OptionalRecipeBundleMeasurement = Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
sourceFileCount: number;
|
||||
toolchain: Readonly<{
|
||||
bundler: "vite";
|
||||
viteVersion: string;
|
||||
mode: "production";
|
||||
target: "es2022";
|
||||
format: "es";
|
||||
minifier: "esbuild";
|
||||
treeshake: false;
|
||||
compression: "node-zlib-gzip";
|
||||
}>;
|
||||
outputs: readonly OptionalRecipeBundleOutput[];
|
||||
bytes: number;
|
||||
gzipBytes: number;
|
||||
bundleBudgetGzipBytes: number;
|
||||
remainingGzipBytes: number;
|
||||
sha256: string;
|
||||
passed: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Builds an uncomposed reference runtime as a synthetic production consumer.
|
||||
* Every catalog-owned source module is exposed as an entry namespace and
|
||||
* tree-shaking is disabled so internal fail-closed paths remain in the budget.
|
||||
*/
|
||||
export async function measureOptionalRecipeBundle(input: Readonly<{
|
||||
recipeId: string;
|
||||
sourceRoots: readonly string[];
|
||||
bundleBudgetGzipBytes: number;
|
||||
workspaceRoot?: string;
|
||||
}>): Promise<OptionalRecipeBundleMeasurement> {
|
||||
const recipeId = validateRecipeId(input.recipeId);
|
||||
const bundleBudgetGzipBytes = positiveSafeInteger(
|
||||
input.bundleBudgetGzipBytes,
|
||||
"Optional recipe bundle budget",
|
||||
);
|
||||
const workspaceRoot = await realpath(
|
||||
path.resolve(input.workspaceRoot ?? process.cwd()),
|
||||
);
|
||||
const sourceRoots = validateSourceRoots(input.sourceRoots);
|
||||
const sourceFiles = await resolveSourceFiles(
|
||||
workspaceRoot,
|
||||
sourceRoots,
|
||||
);
|
||||
const virtualEntry =
|
||||
`virtual:optional-reference-runtime-entry/${recipeId}`;
|
||||
const resolvedVirtualEntry = `\0${virtualEntry}`;
|
||||
const entrySource = sourceFiles
|
||||
.map(
|
||||
(sourceFile, index) =>
|
||||
`export * as source${index} from ${JSON.stringify(
|
||||
viteSourceSpecifier(sourceFile),
|
||||
)};`,
|
||||
)
|
||||
.join("\n")
|
||||
.concat("\n");
|
||||
const preservePublicEntryPlugin = {
|
||||
name: "optional-reference-runtime-entry",
|
||||
enforce: "pre",
|
||||
resolveId(id) {
|
||||
return id === virtualEntry ? resolvedVirtualEntry : null;
|
||||
},
|
||||
load(id) {
|
||||
return id === resolvedVirtualEntry ? entrySource : null;
|
||||
},
|
||||
options(options) {
|
||||
return {
|
||||
...options,
|
||||
preserveEntrySignatures: "strict",
|
||||
};
|
||||
},
|
||||
} satisfies Plugin;
|
||||
const buildResult = await build({
|
||||
root: workspaceRoot,
|
||||
configFile: false,
|
||||
envFile: false,
|
||||
mode: "production",
|
||||
publicDir: false,
|
||||
clearScreen: false,
|
||||
logLevel: "silent",
|
||||
plugins: [preservePublicEntryPlugin],
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: "esbuild",
|
||||
sourcemap: false,
|
||||
write: false,
|
||||
emptyOutDir: false,
|
||||
copyPublicDir: false,
|
||||
cssCodeSplit: false,
|
||||
reportCompressedSize: false,
|
||||
rollupOptions: {
|
||||
input: virtualEntry,
|
||||
// Budget the complete selected runtime, including internal fail-closed
|
||||
// guards that a synthetic consumer cannot predict it will exercise.
|
||||
treeshake: false,
|
||||
output: {
|
||||
format: "es",
|
||||
entryFileNames: `${recipeId}.js`,
|
||||
chunkFileNames: `${recipeId}-chunk-[hash].js`,
|
||||
assetFileNames: `${recipeId}-asset-[name]-[hash][extname]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const emitted = emittedOutputs(buildResult);
|
||||
if (emitted.length === 0) {
|
||||
throw new TypeError("Optional recipe bundle emitted no output.");
|
||||
}
|
||||
const outputs = emitted
|
||||
.map((output) => {
|
||||
const bytes = outputBytes(output);
|
||||
return Object.freeze({
|
||||
fileName: output.fileName,
|
||||
bytes: bytes.byteLength,
|
||||
gzipBytes: gzipSync(bytes).byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
})
|
||||
.sort((left, right) => compareText(left.fileName, right.fileName));
|
||||
const aggregateHash = createHash("sha256");
|
||||
for (const output of outputs) {
|
||||
aggregateHash.update(output.fileName);
|
||||
aggregateHash.update("\0");
|
||||
aggregateHash.update(output.sha256);
|
||||
aggregateHash.update("\0");
|
||||
}
|
||||
const bytes = outputs.reduce(
|
||||
(total, output) => total + output.bytes,
|
||||
0,
|
||||
);
|
||||
const gzipBytes = outputs.reduce(
|
||||
(total, output) => total + output.gzipBytes,
|
||||
0,
|
||||
);
|
||||
|
||||
return Object.freeze({
|
||||
recipeId,
|
||||
sourceRoots: Object.freeze([...sourceRoots]),
|
||||
sourceFileCount: sourceFiles.length,
|
||||
toolchain: Object.freeze({
|
||||
bundler: "vite" as const,
|
||||
viteVersion,
|
||||
mode: "production" as const,
|
||||
target: "es2022" as const,
|
||||
format: "es" as const,
|
||||
minifier: "esbuild" as const,
|
||||
treeshake: false as const,
|
||||
compression: "node-zlib-gzip" as const,
|
||||
}),
|
||||
outputs: Object.freeze(outputs),
|
||||
bytes,
|
||||
gzipBytes,
|
||||
bundleBudgetGzipBytes,
|
||||
remainingGzipBytes: bundleBudgetGzipBytes - gzipBytes,
|
||||
sha256: aggregateHash.digest("hex"),
|
||||
passed: gzipBytes <= bundleBudgetGzipBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveSourceFiles(
|
||||
workspaceRoot: string,
|
||||
sourceRoots: readonly string[],
|
||||
): Promise<readonly string[]> {
|
||||
const sourceBoundary = await realpath(path.join(workspaceRoot, "src"));
|
||||
const discovered: string[] = [];
|
||||
for (const sourceRoot of sourceRoots) {
|
||||
const target = path.resolve(workspaceRoot, sourceRoot);
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
const rootMetadata = await lstat(target);
|
||||
if (rootMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source root cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(await realpath(target), sourceBoundary);
|
||||
if (
|
||||
rootMetadata.isFile() &&
|
||||
(!SOURCE_EXTENSION.test(target) || DECLARATION_FILE.test(target))
|
||||
) {
|
||||
throw new TypeError("Optional recipe source root is not executable source.");
|
||||
}
|
||||
discovered.push(
|
||||
...(await collectExecutableSources(target, sourceBoundary)),
|
||||
);
|
||||
}
|
||||
const unique = [...new Set(discovered)].sort((left, right) =>
|
||||
compareText(
|
||||
normalizePath(path.relative(workspaceRoot, left)),
|
||||
normalizePath(path.relative(workspaceRoot, right)),
|
||||
),
|
||||
);
|
||||
if (unique.length === 0) {
|
||||
throw new TypeError("Optional recipe source roots contain no executable source.");
|
||||
}
|
||||
return Object.freeze(unique);
|
||||
}
|
||||
|
||||
async function collectExecutableSources(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): Promise<string[]> {
|
||||
const metadata = await lstat(target);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError("Optional recipe source cannot be a symlink.");
|
||||
}
|
||||
assertInsideSourceBoundary(target, sourceBoundary);
|
||||
if (metadata.isFile()) {
|
||||
return SOURCE_EXTENSION.test(target) && !DECLARATION_FILE.test(target)
|
||||
? [target]
|
||||
: [];
|
||||
}
|
||||
if (!metadata.isDirectory()) return [];
|
||||
const entries = (await readdir(target, { withFileTypes: true })).sort(
|
||||
(left, right) => compareText(left.name, right.name),
|
||||
);
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) =>
|
||||
collectExecutableSources(
|
||||
path.join(target, entry.name),
|
||||
sourceBoundary,
|
||||
),
|
||||
),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function emittedOutputs(value: unknown): readonly EmittedOutput[] {
|
||||
const buildOutputs = Array.isArray(value) ? value : [value];
|
||||
const emitted: EmittedOutput[] = [];
|
||||
for (const buildOutput of buildOutputs) {
|
||||
if (!isRecord(buildOutput) || !Array.isArray(buildOutput.output)) {
|
||||
throw new TypeError("Optional recipe bundle output is invalid.");
|
||||
}
|
||||
for (const output of buildOutput.output) {
|
||||
if (!isRecord(output)) {
|
||||
throw new TypeError("Optional recipe emitted output is invalid.");
|
||||
}
|
||||
if (
|
||||
output.type === "chunk" &&
|
||||
typeof output.fileName === "string" &&
|
||||
typeof output.code === "string"
|
||||
) {
|
||||
emitted.push({
|
||||
type: "chunk",
|
||||
fileName: output.fileName,
|
||||
code: output.code,
|
||||
});
|
||||
} else if (
|
||||
output.type === "asset" &&
|
||||
typeof output.fileName === "string" &&
|
||||
(typeof output.source === "string" ||
|
||||
output.source instanceof Uint8Array)
|
||||
) {
|
||||
emitted.push({
|
||||
type: "asset",
|
||||
fileName: output.fileName,
|
||||
source: output.source,
|
||||
});
|
||||
} else {
|
||||
throw new TypeError("Optional recipe emitted output shape is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
}
|
||||
|
||||
function outputBytes(output: EmittedOutput): Buffer {
|
||||
if (output.type === "chunk") {
|
||||
return Buffer.from(output.code, "utf8");
|
||||
}
|
||||
return Buffer.from(output.source);
|
||||
}
|
||||
|
||||
function validateRecipeId(value: unknown): string {
|
||||
if (typeof value !== "string" || !RECIPE_ID.test(value)) {
|
||||
throw new TypeError("Optional recipe ID is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSourceRoots(value: unknown): readonly string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length === 0 ||
|
||||
value.length > 32 ||
|
||||
value.some(
|
||||
(sourceRoot) =>
|
||||
typeof sourceRoot !== "string" ||
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot
|
||||
.split("/")
|
||||
.some(
|
||||
(segment) =>
|
||||
segment.length === 0 || segment === "." || segment === "..",
|
||||
),
|
||||
) ||
|
||||
new Set(value).size !== value.length
|
||||
) {
|
||||
throw new TypeError("Optional recipe source roots are invalid.");
|
||||
}
|
||||
return Object.freeze([...value].sort(compareText));
|
||||
}
|
||||
|
||||
function assertInsideSourceBoundary(
|
||||
target: string,
|
||||
sourceBoundary: string,
|
||||
): void {
|
||||
const relative = path.relative(sourceBoundary, target);
|
||||
if (
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative)
|
||||
) {
|
||||
throw new TypeError("Optional recipe source escaped the source boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
function viteSourceSpecifier(sourceFile: string): string {
|
||||
return pathToFileURL(sourceFile).href;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown, name: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
throw new TypeError(`${name} is invalid.`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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",
|
||||
] as const);
|
||||
|
||||
const lifecycleRecipes: ReadonlySet<string> = new Set([
|
||||
"analytics-error-sink",
|
||||
"browser-permission",
|
||||
"client-workflow",
|
||||
"file-transfer",
|
||||
"generated-api",
|
||||
"multi-tab",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
"web-worker",
|
||||
]);
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
export type OptionalRecipeSourceViolation = Readonly<{
|
||||
ruleId: string;
|
||||
path: string;
|
||||
}>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function nonEmptyStrings(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.trim().length > 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function packageVersions(value: unknown): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(recordValue(value)).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validateRecipeCatalog(
|
||||
input: unknown,
|
||||
packageDocument: Document,
|
||||
): string[] {
|
||||
const document = recordValue(input);
|
||||
const violations: string[] = [];
|
||||
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 recipes = recordRows(document.recipes);
|
||||
const actualIds = recipes.map((recipe) => String(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 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",
|
||||
] as const) {
|
||||
const value = recipe[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
for (const field of [
|
||||
"forbiddenWhen",
|
||||
"failureKinds",
|
||||
"securityPrivacy",
|
||||
"removal",
|
||||
] as const) {
|
||||
if (!nonEmptyStrings(recipe[field])) {
|
||||
violations.push(`${id}:MISSING_${field.toUpperCase()}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof recipe.bundleBudgetGzipBytes !== "number" ||
|
||||
!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 = {
|
||||
...packageVersions(packageDocument.dependencies),
|
||||
...packageVersions(packageDocument.devDependencies),
|
||||
};
|
||||
const packageScripts = packageVersions(packageDocument.scripts);
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.referenceRuntime === undefined) continue;
|
||||
const runtime = recordValue(recipe.referenceRuntime);
|
||||
const id = typeof recipe.id === "string" ? recipe.id : "unknown";
|
||||
if (
|
||||
runtime.status !== "AVAILABLE_NOT_COMPOSED" ||
|
||||
runtime.productionComposition !== false
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_COMPOSITION`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.sourceRoots)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_ROOTS`);
|
||||
} else if (
|
||||
runtime.sourceRoots.some(
|
||||
(sourceRoot) =>
|
||||
!sourceRoot.startsWith("src/") ||
|
||||
sourceRoot.includes("\\") ||
|
||||
sourceRoot.split("/").includes(".."),
|
||||
)
|
||||
) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_SOURCE_BOUNDARY`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.coveredCapabilities)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CAPABILITIES`);
|
||||
}
|
||||
if (!nonEmptyStrings(runtime.conformanceScripts)) {
|
||||
violations.push(`${id}:REFERENCE_RUNTIME_CONFORMANCE`);
|
||||
} else {
|
||||
for (const script of runtime.conformanceScripts) {
|
||||
if (!(script in packageScripts)) {
|
||||
violations.push(`${id}:UNKNOWN_CONFORMANCE_SCRIPT:${script}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const vendorPatterns = Array.isArray(document.vendorPackagePatterns)
|
||||
? document.vendorPackagePatterns.filter(
|
||||
(entry): entry is string => typeof entry === "string",
|
||||
)
|
||||
: [];
|
||||
for (const pattern of vendorPatterns) {
|
||||
const wildcard = pattern.endsWith("*");
|
||||
const prefix = 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;
|
||||
}
|
||||
|
||||
export async function sourceFiles(directory: string): Promise<string[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
isRecord(error) &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const groups: string[][] = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory()
|
||||
? sourceFiles(target)
|
||||
: /\.(?:[cm]?[jt]s|[jt]sx)$/.test(entry.name)
|
||||
? [target]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
export async function scanOptionalRecipeSources(
|
||||
root: string,
|
||||
{ scanProductionBoundary = true }: Readonly<{
|
||||
scanProductionBoundary?: boolean;
|
||||
}> = {},
|
||||
): Promise<OptionalRecipeSourceViolation[]> {
|
||||
const violations: OptionalRecipeSourceViolation[] = [];
|
||||
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])
|
||||
.filter((specifier): specifier is string => specifier !== undefined);
|
||||
|
||||
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 productionRelative =
|
||||
path.basename(path.resolve(root)) === "src"
|
||||
? `src/${relativeToRoot}`
|
||||
: relativeToRoot;
|
||||
const isCompositionSource =
|
||||
/(?:^|\/)src\/bootstrap\//.test(productionRelative) ||
|
||||
/(?:^|\/)src\/features\/installed-feature-(?:adapters|runtimes)\./.test(
|
||||
productionRelative,
|
||||
);
|
||||
if (
|
||||
scanProductionBoundary &&
|
||||
isCompositionSource &&
|
||||
(imports.some((specifier) =>
|
||||
/(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|$)/.test(
|
||||
specifier,
|
||||
),
|
||||
) ||
|
||||
/["'][^"'\r\n]*(?:^|\/)adapters\/(?:browser-file-storage|browser-files|browser-transfer|cache-storage|storage\/(?:indexeddb|opfs))(?:\/|["'])/u.test(
|
||||
content,
|
||||
))
|
||||
) {
|
||||
violations.push({
|
||||
ruleId: "REFERENCE_RUNTIME_COMPOSED_WITHOUT_SELECTION",
|
||||
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;
|
||||
}
|
||||
|
||||
export async function scanProductionBundle(
|
||||
distRoot: string,
|
||||
): Promise<string[]> {
|
||||
const violations: string[] = [];
|
||||
const forbiddenRuntimeMarkers = [
|
||||
"frontend-optional-recipe-must-not-reach-production",
|
||||
"Browser file runtime hard limits are invalid.",
|
||||
"Object URL allocation failed",
|
||||
"Storage pressure policy is invalid.",
|
||||
"IndexedDB runtime configuration is invalid.",
|
||||
"Invalid IndexedDB schema migration.",
|
||||
"OPFS runtime policy is invalid.",
|
||||
"OPFS operation failed.",
|
||||
"Public Cache Storage policy is invalid.",
|
||||
"Public cache validation failed.",
|
||||
"Presigned capability vault limit is invalid.",
|
||||
"Resumable upload policy is invalid.",
|
||||
"Image CDN policy registry is invalid.",
|
||||
] as const;
|
||||
for (const file of await sourceFiles(distRoot)) {
|
||||
const content = await readFile(file, "utf8");
|
||||
if (forbiddenRuntimeMarkers.some((marker) => content.includes(marker))) {
|
||||
violations.push(path.relative(process.cwd(), file));
|
||||
}
|
||||
}
|
||||
|
||||
const viteManifestPath = path.join(distRoot, ".vite/manifest.json");
|
||||
const emittedModuleInventoryPath = path.join(
|
||||
distRoot,
|
||||
".vite/module-inventory.json",
|
||||
);
|
||||
const moduleInventoryCandidates = [
|
||||
emittedModuleInventoryPath,
|
||||
...(path.resolve(distRoot) === path.resolve("dist")
|
||||
? ["artifacts/quality/vite-module-inventory.json"]
|
||||
: []),
|
||||
];
|
||||
const viteManifestExists = await readFile(viteManifestPath, "utf8")
|
||||
.then(() => true)
|
||||
.catch((error: unknown) => {
|
||||
if (isRecord(error) && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
});
|
||||
if (!viteManifestExists) return [...new Set(violations)];
|
||||
|
||||
let inventory: unknown;
|
||||
let moduleInventoryPath = emittedModuleInventoryPath;
|
||||
for (const candidate of moduleInventoryCandidates) {
|
||||
try {
|
||||
inventory = JSON.parse(await readFile(candidate, "utf8"));
|
||||
moduleInventoryPath = candidate;
|
||||
break;
|
||||
} catch {
|
||||
// A generated build may move the inventory out of the deploy directory.
|
||||
}
|
||||
}
|
||||
if (inventory === undefined) {
|
||||
violations.push(
|
||||
path.relative(process.cwd(), moduleInventoryPath),
|
||||
);
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
const inventoryDocument = recordValue(inventory);
|
||||
const chunks = recordRows(inventoryDocument.chunks);
|
||||
if (
|
||||
inventoryDocument.schemaVersion !== 1 ||
|
||||
!Array.isArray(inventoryDocument.chunks) ||
|
||||
chunks.length !== inventoryDocument.chunks.length
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
|
||||
const forbiddenSourcePrefixes = [
|
||||
"src/application/ports/browser-file-storage/",
|
||||
"src/application/ports/browser-transfer/",
|
||||
"src/adapters/browser-file-storage/",
|
||||
"src/adapters/browser-files/",
|
||||
"src/adapters/browser-transfer/",
|
||||
"src/adapters/cache-storage/",
|
||||
"src/adapters/storage/indexeddb/",
|
||||
"src/adapters/storage/opfs/",
|
||||
] as const;
|
||||
for (const chunk of chunks) {
|
||||
if (
|
||||
typeof chunk.fileName !== "string" ||
|
||||
!Array.isArray(chunk.modules) ||
|
||||
chunk.modules.some((moduleId) => typeof moduleId !== "string")
|
||||
) {
|
||||
violations.push(path.relative(process.cwd(), moduleInventoryPath));
|
||||
continue;
|
||||
}
|
||||
for (const moduleId of chunk.modules as string[]) {
|
||||
if (
|
||||
forbiddenSourcePrefixes.some((prefix) =>
|
||||
moduleId.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
violations.push(`${chunk.fileName}:${moduleId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(violations)];
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type RealtimeBoundaryRuleId =
|
||||
| "NATIVE_REALTIME_API_OUTSIDE_ADAPTER"
|
||||
| "PRESENTATION_INTERVAL_OWNER"
|
||||
| "UNSELECTED_REALTIME_RUNTIME_COMPOSED";
|
||||
|
||||
export type RealtimeBoundaryViolation = Readonly<{
|
||||
ruleId: RealtimeBoundaryRuleId;
|
||||
file: string;
|
||||
line: number;
|
||||
}>;
|
||||
|
||||
const SOURCE_EXTENSION = /\.(?:[cm]?ts|tsx)$/u;
|
||||
const OWNED_NATIVE_ROOTS = [
|
||||
"src/adapters/realtime/",
|
||||
"src/adapters/web-push/",
|
||||
] as const;
|
||||
const REALTIME_ADAPTER_IMPORT =
|
||||
/(?:from\s*|import\s*\()\s*["'][^"']*\/adapters\/(?:realtime|web-push)(?:\/[^"']*)?["']/gu;
|
||||
const NATIVE_REALTIME_PATTERNS = [
|
||||
/\bnew\s+(?:WebSocket|EventSource|Notification)\s*\(/gu,
|
||||
/\bNotification\s*\.\s*requestPermission\s*\(/gu,
|
||||
/\.\s*showNotification\s*\(/gu,
|
||||
/\.\s*pushManager\s*\.\s*(?:subscribe|getSubscription)\s*\(/gu,
|
||||
/\bReflect\s*\.\s*get\s*\([^,]+,\s*["'](?:WebSocket|EventSource|Notification|pushManager)["']/gu,
|
||||
] as const;
|
||||
const PRESENTATION_INTERVAL = /\bsetInterval\s*\(/gu;
|
||||
|
||||
export async function scanRealtimeBoundaries(
|
||||
sourceRoot: string,
|
||||
): Promise<readonly RealtimeBoundaryViolation[]> {
|
||||
const absoluteRoot = path.resolve(sourceRoot);
|
||||
const files = await collectSourceFiles(absoluteRoot);
|
||||
const violations: RealtimeBoundaryViolation[] = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
const logicalFile = logicalSourcePath(absoluteRoot, file);
|
||||
inspectFile(source, logicalFile, violations);
|
||||
}
|
||||
return Object.freeze(
|
||||
violations
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.ruleId.localeCompare(right.ruleId),
|
||||
)
|
||||
.map((violation) => Object.freeze(violation)),
|
||||
);
|
||||
}
|
||||
|
||||
function inspectFile(
|
||||
source: string,
|
||||
logicalFile: string,
|
||||
violations: RealtimeBoundaryViolation[],
|
||||
): void {
|
||||
const nativeOwned = OWNED_NATIVE_ROOTS.some((root) =>
|
||||
logicalFile.startsWith(root),
|
||||
);
|
||||
const presentationOwned =
|
||||
logicalFile.startsWith("src/presentation/") ||
|
||||
/^src\/features\/[^/]+\/presentation\//u.test(logicalFile);
|
||||
const compositionBoundary =
|
||||
logicalFile.startsWith("src/bootstrap/") ||
|
||||
/^src\/features\/installed-feature-/u.test(logicalFile);
|
||||
|
||||
const report = (
|
||||
ruleId: RealtimeBoundaryRuleId,
|
||||
index: number,
|
||||
): void => {
|
||||
violations.push({
|
||||
ruleId,
|
||||
file: logicalFile,
|
||||
line: lineAt(source, index),
|
||||
});
|
||||
};
|
||||
|
||||
if (!nativeOwned) {
|
||||
for (const pattern of NATIVE_REALTIME_PATTERNS) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
report(
|
||||
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
|
||||
match.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (presentationOwned) {
|
||||
for (const match of source.matchAll(PRESENTATION_INTERVAL)) {
|
||||
report("PRESENTATION_INTERVAL_OWNER", match.index);
|
||||
}
|
||||
}
|
||||
if (compositionBoundary) {
|
||||
for (const match of source.matchAll(REALTIME_ADAPTER_IMPORT)) {
|
||||
report("UNSELECTED_REALTIME_RUNTIME_COMPOSED", match.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSourceFiles(
|
||||
directory: string,
|
||||
): Promise<readonly string[]> {
|
||||
const output: string[] = [];
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(directory, entry.name);
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
!["node_modules", "dist", "artifacts", ".tmp"].includes(
|
||||
entry.name,
|
||||
)
|
||||
) {
|
||||
output.push(...(await collectSourceFiles(resolved)));
|
||||
} else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) {
|
||||
output.push(resolved);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function logicalSourcePath(root: string, file: string): string {
|
||||
const workspaceRelative = path
|
||||
.relative(process.cwd(), file)
|
||||
.split(path.sep)
|
||||
.join("/");
|
||||
if (root === path.resolve("src")) return workspaceRelative;
|
||||
return path.relative(root, file).split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1;
|
||||
for (let offset = 0; offset < index; offset += 1) {
|
||||
if (source.charCodeAt(offset) === 10) line += 1;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -5,17 +5,36 @@ export const COMPATIBILITY_IMPACTS = Object.freeze([
|
||||
"additive",
|
||||
"behavior-change",
|
||||
"breaking",
|
||||
]);
|
||||
] as const);
|
||||
|
||||
type CompatibilityImpact = (typeof COMPATIBILITY_IMPACTS)[number];
|
||||
type RegistryRecord = Record<string, unknown> & {
|
||||
registryId?: unknown;
|
||||
contract?: unknown;
|
||||
rows?: unknown;
|
||||
};
|
||||
type RegistryChange = {
|
||||
changeId: string;
|
||||
registryId: string;
|
||||
rowName: string;
|
||||
field: string;
|
||||
kind: string;
|
||||
impact: CompatibilityImpact;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
};
|
||||
type RegistryDiff = Readonly<{
|
||||
impact: CompatibilityImpact;
|
||||
changes: readonly RegistryChange[];
|
||||
}>;
|
||||
|
||||
const impactRank = new Map(
|
||||
COMPATIBILITY_IMPACTS.map((impact, index) => [impact, index]),
|
||||
);
|
||||
|
||||
/** @param {unknown} value @returns {unknown} */
|
||||
export function canonicalizeRegistryValue(value) {
|
||||
export function canonicalizeRegistryValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const projected =
|
||||
/** @type {unknown[]} */ (value.map(canonicalizeRegistryValue));
|
||||
const projected: unknown[] = value.map(canonicalizeRegistryValue);
|
||||
return projected.every(
|
||||
(item) =>
|
||||
item === null ||
|
||||
@@ -36,64 +55,64 @@ export function canonicalizeRegistryValue(value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value @returns {string} */
|
||||
export function canonicalRegistryJson(value) {
|
||||
export function canonicalRegistryJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalizeRegistryValue(value)) ?? "undefined";
|
||||
}
|
||||
|
||||
/** @param {unknown} snapshot */
|
||||
export function registrySnapshotDigest(snapshot) {
|
||||
export function registrySnapshotDigest(snapshot: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(canonicalRegistryJson(snapshot))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string} current @param {string} candidate */
|
||||
function strongestImpact(current, candidate) {
|
||||
function strongestImpact(
|
||||
current: CompatibilityImpact,
|
||||
candidate: CompatibilityImpact,
|
||||
): CompatibilityImpact {
|
||||
return (impactRank.get(candidate) ?? 0) > (impactRank.get(current) ?? 0)
|
||||
? candidate
|
||||
: current;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function valueType(value) {
|
||||
function valueType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registryId
|
||||
* @param {string} rowName
|
||||
* @param {string} field
|
||||
* @param {string} kind
|
||||
*/
|
||||
function changeId(registryId, rowName, field, kind) {
|
||||
function changeId(
|
||||
registryId: string,
|
||||
rowName: string,
|
||||
field: string,
|
||||
kind: string,
|
||||
): string {
|
||||
return `${registryId}:${rowName}:${field}:${kind}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a semantic diff. Object key and primitive-array ordering is
|
||||
* canonicalized before comparison and therefore cannot create a false change.
|
||||
*
|
||||
* @param {Readonly<Record<string, unknown>>} before
|
||||
* @param {Readonly<Record<string, unknown>>} after
|
||||
*/
|
||||
export function diffRegistrySnapshots(before, after) {
|
||||
const changes = /** @type {Array<Record<string, unknown>>} */ ([]);
|
||||
let impact = "none";
|
||||
const beforeRegistries =
|
||||
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (before.registries ?? []).map(
|
||||
(registry) => [String(registry.registryId), registry],
|
||||
),
|
||||
));
|
||||
const afterRegistries =
|
||||
/** @type {Map<string, Record<string, unknown>>} */ (new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (after.registries ?? []).map(
|
||||
(registry) => [String(registry.registryId), registry],
|
||||
),
|
||||
));
|
||||
export function diffRegistrySnapshots(
|
||||
before: Readonly<Record<string, unknown>>,
|
||||
after: Readonly<Record<string, unknown>>,
|
||||
): RegistryDiff {
|
||||
const changes: RegistryChange[] = [];
|
||||
let impact: CompatibilityImpact = "none";
|
||||
const beforeRegistryRows = (before.registries ?? []) as RegistryRecord[];
|
||||
const beforeRegistries = new Map<string, RegistryRecord>(
|
||||
beforeRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const afterRegistryRows = (after.registries ?? []) as RegistryRecord[];
|
||||
const afterRegistries = new Map<string, RegistryRecord>(
|
||||
afterRegistryRows.map((registry) => [
|
||||
String(registry.registryId),
|
||||
registry,
|
||||
]),
|
||||
);
|
||||
const registryIds = new Set([
|
||||
...beforeRegistries.keys(),
|
||||
...afterRegistries.keys(),
|
||||
@@ -116,10 +135,8 @@ export function diffRegistrySnapshots(before, after) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousContract =
|
||||
/** @type {Record<string, unknown>} */ (previous.contract ?? {});
|
||||
const currentContract =
|
||||
/** @type {Record<string, unknown>} */ (current.contract ?? {});
|
||||
const previousContract = (previous.contract ?? {}) as Record<string, unknown>;
|
||||
const currentContract = (current.contract ?? {}) as Record<string, unknown>;
|
||||
const contractFields = new Set([
|
||||
...Object.keys(previousContract),
|
||||
...Object.keys(currentContract),
|
||||
@@ -156,16 +173,16 @@ export function diffRegistrySnapshots(before, after) {
|
||||
}
|
||||
|
||||
const breakingFields = new Set(
|
||||
/** @type {string[]} */ (
|
||||
currentContract.breakingFields ?? []
|
||||
),
|
||||
(currentContract.breakingFields ?? []) as string[],
|
||||
);
|
||||
const beforeRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (
|
||||
previous.rows ?? {}
|
||||
);
|
||||
const afterRows =
|
||||
/** @type {Record<string, Record<string, unknown>>} */ (current.rows ?? {});
|
||||
const beforeRows = (previous.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const afterRows = (current.rows ?? {}) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const rowNames = new Set([
|
||||
...Object.keys(beforeRows),
|
||||
...Object.keys(afterRows),
|
||||
@@ -209,8 +226,8 @@ export function diffRegistrySnapshots(before, after) {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let kind;
|
||||
let changeImpact;
|
||||
let kind: string;
|
||||
let changeImpact: CompatibilityImpact;
|
||||
if (!beforeHas) {
|
||||
kind = "field-added";
|
||||
changeImpact = "additive";
|
||||
@@ -261,11 +278,10 @@ export function diffRegistrySnapshots(before, after) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} snapshot
|
||||
* @param {Readonly<Record<string, unknown>>} approval
|
||||
*/
|
||||
export function verifyRegistryBaselineApproval(snapshot, approval) {
|
||||
export function verifyRegistryBaselineApproval(
|
||||
snapshot: Readonly<Record<string, unknown>>,
|
||||
approval: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const actualDigest = registrySnapshotDigest(snapshot);
|
||||
const approvedDigest = approval.snapshotDigest;
|
||||
return Object.freeze({
|
||||
@@ -281,17 +297,15 @@ export function verifyRegistryBaselineApproval(snapshot, approval) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof diffRegistrySnapshots>} diff
|
||||
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
||||
*/
|
||||
export function validateBreakingEvidence(diff, evidenceFile) {
|
||||
const evidence = new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
evidenceFile.changes ?? []
|
||||
).map((entry) => [entry.changeId, entry]),
|
||||
export function validateBreakingEvidence(
|
||||
diff: RegistryDiff,
|
||||
evidenceFile: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const entries = (evidenceFile.changes ?? []) as Array<Record<string, unknown>>;
|
||||
const evidence = new Map<string, Record<string, unknown>>(
|
||||
entries.map((entry) => [String(entry.changeId), entry]),
|
||||
);
|
||||
const failures = [];
|
||||
const failures: string[] = [];
|
||||
for (const change of diff.changes.filter(
|
||||
(entry) => entry.impact === "breaking",
|
||||
)) {
|
||||
@@ -307,7 +321,8 @@ export function validateBreakingEvidence(diff, evidenceFile) {
|
||||
"rollback",
|
||||
"owner",
|
||||
]) {
|
||||
if (typeof entry[field] !== "string" || entry[field].trim().length === 0) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
failures.push(
|
||||
`breaking change ${change.changeId} missing non-empty ${field}`,
|
||||
);
|
||||
@@ -1,547 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
/** @param {unknown} value @returns {unknown} */
|
||||
export function canonicalizeSupplyChainValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(canonicalizeSupplyChainValue)
|
||||
.sort((left, right) =>
|
||||
JSON.stringify(left).localeCompare(JSON.stringify(right)),
|
||||
);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function supplyChainDigest(value) {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** @param {string} lockfile */
|
||||
export function parsePnpmLockfilePackages(lockfile) {
|
||||
const entries =
|
||||
/** @type {Array<{name: string, version: string, integrity: string}>} */ (
|
||||
[]
|
||||
);
|
||||
let inPackages = false;
|
||||
/** @type {{name: string, version: string, integrity: string} | null} */
|
||||
let current = null;
|
||||
|
||||
for (const line of lockfile.split(/\r?\n/)) {
|
||||
if (line === "packages:") {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "snapshots:") {
|
||||
if (current) entries.push(current);
|
||||
break;
|
||||
}
|
||||
if (!inPackages) continue;
|
||||
const packageMatch = line.match(/^ {2}(\S.*):$/);
|
||||
if (packageMatch) {
|
||||
if (current) entries.push(current);
|
||||
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
|
||||
const separator = key.lastIndexOf("@");
|
||||
current = {
|
||||
name: key.slice(0, separator),
|
||||
version: key.slice(separator + 1),
|
||||
integrity: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
|
||||
if (current && integrityMatch) {
|
||||
current.integrity = integrityMatch[1];
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {string} integrity */
|
||||
export function isValidSha512Integrity(integrity) {
|
||||
if (!integrity.startsWith("sha512-")) return false;
|
||||
try {
|
||||
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeLicense(raw) {
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === "object" &&
|
||||
"type" in raw &&
|
||||
typeof raw.type === "string"
|
||||
) {
|
||||
return raw.type;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
const licenses = raw.map(normalizeLicense).filter(
|
||||
(license) => license !== "NOASSERTION",
|
||||
);
|
||||
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
|
||||
}
|
||||
return "NOASSERTION";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} root
|
||||
* @param {Readonly<Record<string, string>>} directProduction
|
||||
* @param {Readonly<Record<string, string>>} directDevelopment
|
||||
*/
|
||||
export async function flattenPnpmDependencyTree(
|
||||
root,
|
||||
directProduction,
|
||||
directDevelopment,
|
||||
) {
|
||||
const records =
|
||||
/** @type {Map<string, {
|
||||
* name: string,
|
||||
* version: string,
|
||||
* direct: boolean,
|
||||
* scope: "production" | "development",
|
||||
* optional: boolean,
|
||||
* packagePath: string,
|
||||
* dependencies: Set<string>
|
||||
* }>} */ (new Map());
|
||||
const directIds = new Set();
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(directProduction, name) &&
|
||||
rawDependency &&
|
||||
typeof rawDependency === "object" &&
|
||||
!Array.isArray(rawDependency)
|
||||
) {
|
||||
directIds.add(
|
||||
`${name}@${String(
|
||||
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
||||
)) {
|
||||
if (
|
||||
Object.hasOwn(directDevelopment, name) &&
|
||||
rawDependency &&
|
||||
typeof rawDependency === "object" &&
|
||||
!Array.isArray(rawDependency)
|
||||
) {
|
||||
directIds.add(
|
||||
`${name}@${String(
|
||||
/** @type {Record<string, unknown>} */ (rawDependency).version ?? "",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} node
|
||||
* @param {"production" | "development"} scope
|
||||
* @param {boolean} optionalPath
|
||||
*/
|
||||
function visit(node, scope, optionalPath) {
|
||||
for (const [groupName, group] of Object.entries({
|
||||
dependencies: node.dependencies,
|
||||
devDependencies: node.devDependencies,
|
||||
optionalDependencies: node.optionalDependencies,
|
||||
})) {
|
||||
if (!group || typeof group !== "object" || Array.isArray(group)) continue;
|
||||
for (const [name, rawDependency] of Object.entries(group)) {
|
||||
if (
|
||||
!rawDependency ||
|
||||
typeof rawDependency !== "object" ||
|
||||
Array.isArray(rawDependency)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dependency =
|
||||
/** @type {Record<string, unknown>} */ (rawDependency);
|
||||
const version = String(dependency.version ?? "");
|
||||
const packagePath = String(dependency.path ?? "");
|
||||
const identity = `${name}@${version}`;
|
||||
const childScope =
|
||||
scope === "production" && groupName !== "devDependencies"
|
||||
? "production"
|
||||
: "development";
|
||||
const childOptional =
|
||||
optionalPath || groupName === "optionalDependencies";
|
||||
const previous = records.get(identity);
|
||||
const dependencies = previous?.dependencies ?? new Set();
|
||||
for (const childGroup of [
|
||||
dependency.dependencies,
|
||||
dependency.optionalDependencies,
|
||||
]) {
|
||||
if (
|
||||
!childGroup ||
|
||||
typeof childGroup !== "object" ||
|
||||
Array.isArray(childGroup)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const [childName, rawChild] of Object.entries(childGroup)) {
|
||||
if (
|
||||
rawChild &&
|
||||
typeof rawChild === "object" &&
|
||||
!Array.isArray(rawChild)
|
||||
) {
|
||||
dependencies.add(
|
||||
`${childName}@${String(rawChild.version ?? "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.set(identity, {
|
||||
name,
|
||||
version,
|
||||
direct: directIds.has(identity),
|
||||
scope:
|
||||
previous?.scope === "production" || childScope === "production"
|
||||
? "production"
|
||||
: "development",
|
||||
optional: previous ? previous.optional && childOptional : childOptional,
|
||||
packagePath: previous?.packagePath || packagePath,
|
||||
dependencies,
|
||||
});
|
||||
visit(dependency, childScope, childOptional);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productionRoot = {
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.dependencies ?? {}),
|
||||
).filter(([name]) => Object.hasOwn(directProduction, name)),
|
||||
),
|
||||
};
|
||||
const developmentRoot = {
|
||||
devDependencies: Object.fromEntries(
|
||||
Object.entries(
|
||||
/** @type {Record<string, unknown>} */ (root.devDependencies ?? {}),
|
||||
).filter(([name]) => Object.hasOwn(directDevelopment, name)),
|
||||
),
|
||||
};
|
||||
visit(productionRoot, "production", false);
|
||||
visit(developmentRoot, "development", false);
|
||||
|
||||
const result = [];
|
||||
for (const record of records.values()) {
|
||||
let license = "NOASSERTION";
|
||||
let optional = record.optional;
|
||||
if (record.packagePath) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
||||
);
|
||||
license = normalizeLicense(manifest.license ?? manifest.licenses);
|
||||
} catch {
|
||||
// Platform-specific optional packages may not be materialized locally.
|
||||
optional = true;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
direct: record.direct,
|
||||
scope: record.scope,
|
||||
optional,
|
||||
license,
|
||||
dependencies: [...record.dependencies].sort(),
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} before
|
||||
* @param {Readonly<Record<string, unknown>>} after
|
||||
*/
|
||||
export function diffDependencyInventories(before, after) {
|
||||
const beforeRows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (before.dependencies ?? []);
|
||||
const afterRows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (after.dependencies ?? []);
|
||||
const beforeMap = new Map(
|
||||
beforeRows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const afterMap = new Map(
|
||||
afterRows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
||||
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
||||
const changed = [];
|
||||
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
|
||||
if (
|
||||
supplyChainDigest(beforeMap.get(key)) !==
|
||||
supplyChainDigest(afterMap.get(key))
|
||||
) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
const upgrades = [];
|
||||
for (const removedKey of removed) {
|
||||
const previous = beforeMap.get(removedKey);
|
||||
const replacement = added.find(
|
||||
(addedKey) => afterMap.get(addedKey)?.name === previous?.name,
|
||||
);
|
||||
if (replacement) {
|
||||
upgrades.push({
|
||||
name: previous?.name,
|
||||
from: previous?.version,
|
||||
to: afterMap.get(replacement)?.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
added: Object.freeze(added.sort()),
|
||||
removed: Object.freeze(removed.sort()),
|
||||
changed: Object.freeze(changed.sort()),
|
||||
upgrades: Object.freeze(
|
||||
upgrades.sort((left, right) =>
|
||||
String(left.name).localeCompare(String(right.name)),
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} policy
|
||||
*/
|
||||
export function validateLicensePolicy(inventory, policy) {
|
||||
const allowed = new Set(
|
||||
/** @type {string[]} */ (policy.allowedLicenses ?? []),
|
||||
);
|
||||
const denied = /** @type {string[]} */ (policy.deniedLicensePatterns ?? []);
|
||||
const failures = [];
|
||||
const results = [];
|
||||
for (const dependency of /** @type {Array<Record<string, unknown>>} */ (
|
||||
inventory.dependencies ?? []
|
||||
)) {
|
||||
const license = String(dependency.license ?? "NOASSERTION");
|
||||
const explicitlyDenied = denied.some((pattern) =>
|
||||
new RegExp(pattern, "i").test(license),
|
||||
);
|
||||
const unknownAccepted =
|
||||
license === "NOASSERTION" && dependency.optional === true;
|
||||
const passed =
|
||||
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
|
||||
results.push({
|
||||
package: `${dependency.name}@${dependency.version}`,
|
||||
license,
|
||||
passed,
|
||||
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
||||
});
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${dependency.name}@${dependency.version} has disallowed license ${license}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
results: Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof diffDependencyInventories>} diff
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} evidenceFile
|
||||
*/
|
||||
export function validateDependencyReview(diff, inventory, evidenceFile) {
|
||||
const rows =
|
||||
/** @type {Array<Record<string, unknown>>} */ (inventory.dependencies ?? []);
|
||||
const byIdentity = new Map(
|
||||
rows.map((row) => [`${row.name}@${row.version}`, row]),
|
||||
);
|
||||
const evidence = new Map(
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
evidenceFile.changes ?? []
|
||||
).map((entry) => [entry.changeId, entry]),
|
||||
);
|
||||
const highRisk = diff.added.filter((identity) => {
|
||||
const row = byIdentity.get(identity);
|
||||
return row?.direct === true && row.scope === "production";
|
||||
});
|
||||
const failures = [];
|
||||
for (const identity of highRisk) {
|
||||
const changeId = `add:${identity}`;
|
||||
const entry = evidence.get(changeId);
|
||||
if (!entry) {
|
||||
failures.push(`high-risk dependency missing review: ${changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of ["owner", "reviewer", "reason", "rollback"]) {
|
||||
if (typeof entry[field] !== "string" || !entry[field].trim()) {
|
||||
failures.push(`${changeId} missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.owner === entry.reviewer) {
|
||||
failures.push(`${changeId} may not be self-approved`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
highRisk: Object.freeze(highRisk),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank = new Map([
|
||||
["unknown", 0],
|
||||
["low", 1],
|
||||
["moderate", 2],
|
||||
["high", 3],
|
||||
["critical", 4],
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} report
|
||||
* @param {Readonly<Record<string, unknown>>} policy
|
||||
* @param {Readonly<Record<string, unknown>>} exceptionFile
|
||||
* @param {string} lockfileSha256
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export function validateVulnerabilityReport(
|
||||
report,
|
||||
policy,
|
||||
exceptionFile,
|
||||
lockfileSha256,
|
||||
now = new Date(),
|
||||
) {
|
||||
const failures = [];
|
||||
if (report.scannedLockfileSha256 !== lockfileSha256) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (typeof report.provider !== "string" || !report.provider.trim()) {
|
||||
failures.push("vulnerability report provider missing");
|
||||
}
|
||||
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
|
||||
const exceptions =
|
||||
/** @type {Array<Record<string, unknown>>} */ (
|
||||
exceptionFile.exceptions ?? []
|
||||
);
|
||||
const blocking = [];
|
||||
for (const finding of /** @type {Array<Record<string, unknown>>} */ (
|
||||
report.findings ?? []
|
||||
)) {
|
||||
const severity = String(finding.severity ?? "unknown").toLowerCase();
|
||||
if ((severityRank.get(severity) ?? 0) < threshold) continue;
|
||||
const exception = exceptions.find(
|
||||
(entry) =>
|
||||
entry.vulnerabilityId === finding.id &&
|
||||
entry.packageName === finding.packageName,
|
||||
);
|
||||
const expiry =
|
||||
typeof exception?.expiresAt === "string"
|
||||
? Date.parse(exception.expiresAt)
|
||||
: Number.NaN;
|
||||
const validException =
|
||||
exception &&
|
||||
typeof exception.owner === "string" &&
|
||||
exception.owner.trim() &&
|
||||
typeof exception.reviewer === "string" &&
|
||||
exception.reviewer.trim() &&
|
||||
exception.owner !== exception.reviewer &&
|
||||
typeof exception.reason === "string" &&
|
||||
exception.reason.trim() &&
|
||||
Number.isFinite(expiry) &&
|
||||
expiry > now.getTime();
|
||||
if (!validException) {
|
||||
blocking.push(
|
||||
`${finding.id}:${finding.packageName}@${finding.version}:${severity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0 && blocking.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
blocking: Object.freeze(blocking),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Readonly<Record<string, unknown>>} sbom
|
||||
* @param {Readonly<Record<string, unknown>>} inventory
|
||||
* @param {Readonly<Record<string, unknown>>} provenance
|
||||
* @param {string} distDigest
|
||||
*/
|
||||
export function verifySupplyChainCoherence(
|
||||
sbom,
|
||||
inventory,
|
||||
provenance,
|
||||
distDigest,
|
||||
) {
|
||||
const failures = [];
|
||||
const componentCount = Array.isArray(sbom.components)
|
||||
? sbom.components.length
|
||||
: -1;
|
||||
const dependencyCount = Array.isArray(inventory.dependencies)
|
||||
? inventory.dependencies.length
|
||||
: -2;
|
||||
if (componentCount !== dependencyCount) {
|
||||
failures.push("SBOM component count does not match inventory");
|
||||
}
|
||||
const metadata =
|
||||
/** @type {Record<string, unknown>} */ (sbom.metadata ?? {});
|
||||
const properties =
|
||||
/** @type {Array<{name?: string, value?: string}>} */ (
|
||||
metadata.properties ?? []
|
||||
);
|
||||
if (properties.find(
|
||||
/** @param {{name?: string, value?: string}} property */
|
||||
(property) =>
|
||||
property.name === "ca:lockfileSha256" &&
|
||||
property.value === inventory.lockfileSha256,
|
||||
) === undefined) {
|
||||
failures.push("SBOM lockfile digest does not match inventory");
|
||||
}
|
||||
const subject =
|
||||
/** @type {Array<Record<string, unknown>>} */ (provenance.subject ?? [])[0];
|
||||
const subjectDigest =
|
||||
/** @type {Record<string, unknown>} */ (subject?.digest ?? {});
|
||||
if (subjectDigest.sha256 !== distDigest) {
|
||||
failures.push("provenance subject does not match built dist digest");
|
||||
}
|
||||
const predicate =
|
||||
/** @type {Record<string, unknown>} */ (provenance.predicate ?? {});
|
||||
const materials =
|
||||
/** @type {Record<string, unknown>} */ (predicate.materials ?? {});
|
||||
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
|
||||
failures.push("provenance lockfile material does not match inventory");
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
export type DependencyScope = "production" | "development";
|
||||
export type LockfilePackage = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
integrity: string;
|
||||
}>;
|
||||
export type DependencyInventoryRow = Readonly<{
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
license: string;
|
||||
dependencies: readonly string[];
|
||||
}>;
|
||||
export type DependencyUpgrade = Readonly<{
|
||||
name: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}>;
|
||||
export type DependencyInventoryDiff = Readonly<{
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
changed: readonly string[];
|
||||
upgrades: readonly DependencyUpgrade[];
|
||||
}>;
|
||||
|
||||
type MutableDependencyRecord = {
|
||||
name: string;
|
||||
version: string;
|
||||
direct: boolean;
|
||||
scope: DependencyScope;
|
||||
optional: boolean;
|
||||
packagePath: string;
|
||||
dependencies: Set<string>;
|
||||
};
|
||||
|
||||
type Document = Readonly<Record<string, unknown>>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function recordRows(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function stringRows(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function dependencyIdentity(row: Readonly<Record<string, unknown>>): string {
|
||||
return `${String(row.name ?? "")}@${String(row.version ?? "")}`;
|
||||
}
|
||||
|
||||
export function canonicalizeSupplyChainValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(canonicalizeSupplyChainValue)
|
||||
.sort((left, right) =>
|
||||
String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))),
|
||||
);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalizeSupplyChainValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function supplyChainDigest(value: unknown): string {
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonicalizeSupplyChainValue(value)))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function parsePnpmLockfilePackages(
|
||||
lockfile: string,
|
||||
): LockfilePackage[] {
|
||||
const entries: LockfilePackage[] = [];
|
||||
let inPackages = false;
|
||||
let current: { name: string; version: string; integrity: string } | null = null;
|
||||
|
||||
for (const line of lockfile.split(/\r?\n/)) {
|
||||
if (line === "packages:") {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "snapshots:") {
|
||||
if (current) entries.push(current);
|
||||
break;
|
||||
}
|
||||
if (!inPackages) continue;
|
||||
const packageMatch = line.match(/^ {2}(\S.*):$/);
|
||||
if (packageMatch?.[1]) {
|
||||
if (current) entries.push(current);
|
||||
const key = packageMatch[1].replace(/^['"]|['"]$/g, "");
|
||||
const separator = key.lastIndexOf("@");
|
||||
current = {
|
||||
name: key.slice(0, separator),
|
||||
version: key.slice(separator + 1),
|
||||
integrity: "",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
const integrityMatch = line.match(/\bintegrity:\s*([^,}\s]+)/);
|
||||
if (current && integrityMatch?.[1]) {
|
||||
current.integrity = integrityMatch[1];
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidSha512Integrity(integrity: string): boolean {
|
||||
if (!integrity.startsWith("sha512-")) return false;
|
||||
try {
|
||||
return Buffer.from(integrity.slice("sha512-".length), "base64").length === 64;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeLicense(raw: unknown): string {
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
if (isRecord(raw) && typeof raw.type === "string") return raw.type;
|
||||
if (Array.isArray(raw)) {
|
||||
const licenses = raw
|
||||
.map(normalizeLicense)
|
||||
.filter((license) => license !== "NOASSERTION");
|
||||
return licenses.length > 0 ? licenses.join(" OR ") : "NOASSERTION";
|
||||
}
|
||||
return "NOASSERTION";
|
||||
}
|
||||
|
||||
export async function flattenPnpmDependencyTree(
|
||||
root: Record<string, unknown>,
|
||||
directProduction: Readonly<Record<string, string>>,
|
||||
directDevelopment: Readonly<Record<string, string>>,
|
||||
): Promise<DependencyInventoryRow[]> {
|
||||
const records = new Map<string, MutableDependencyRecord>();
|
||||
const directIds = new Set<string>();
|
||||
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.dependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directProduction, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
for (const [name, rawDependency] of Object.entries(
|
||||
recordValue(root.devDependencies),
|
||||
)) {
|
||||
if (Object.hasOwn(directDevelopment, name) && isRecord(rawDependency)) {
|
||||
directIds.add(`${name}@${String(rawDependency.version ?? "")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(
|
||||
node: Record<string, unknown>,
|
||||
scope: DependencyScope,
|
||||
optionalPath: boolean,
|
||||
): void {
|
||||
const groups = {
|
||||
dependencies: node.dependencies,
|
||||
devDependencies: node.devDependencies,
|
||||
optionalDependencies: node.optionalDependencies,
|
||||
};
|
||||
for (const [groupName, group] of Object.entries(groups)) {
|
||||
for (const [name, rawDependency] of Object.entries(recordValue(group))) {
|
||||
if (!isRecord(rawDependency)) continue;
|
||||
const version = String(rawDependency.version ?? "");
|
||||
const packagePath = String(rawDependency.path ?? "");
|
||||
const identity = `${name}@${version}`;
|
||||
const childScope: DependencyScope =
|
||||
scope === "production" && groupName !== "devDependencies"
|
||||
? "production"
|
||||
: "development";
|
||||
const childOptional =
|
||||
optionalPath || groupName === "optionalDependencies";
|
||||
const previous = records.get(identity);
|
||||
const dependencies = previous?.dependencies ?? new Set<string>();
|
||||
for (const childGroup of [
|
||||
rawDependency.dependencies,
|
||||
rawDependency.optionalDependencies,
|
||||
]) {
|
||||
for (const [childName, rawChild] of Object.entries(
|
||||
recordValue(childGroup),
|
||||
)) {
|
||||
if (isRecord(rawChild)) {
|
||||
dependencies.add(
|
||||
`${childName}@${String(rawChild.version ?? "")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
records.set(identity, {
|
||||
name,
|
||||
version,
|
||||
direct: directIds.has(identity),
|
||||
scope:
|
||||
previous?.scope === "production" || childScope === "production"
|
||||
? "production"
|
||||
: "development",
|
||||
optional: previous
|
||||
? previous.optional && childOptional
|
||||
: childOptional,
|
||||
packagePath: previous?.packagePath || packagePath,
|
||||
dependencies,
|
||||
});
|
||||
visit(rawDependency, childScope, childOptional);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const productionRoot: Record<string, unknown> = {
|
||||
dependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.dependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directProduction, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
const developmentRoot: Record<string, unknown> = {
|
||||
devDependencies: Object.fromEntries(
|
||||
Object.entries(recordValue(root.devDependencies)).filter(([name]) =>
|
||||
Object.hasOwn(directDevelopment, name),
|
||||
),
|
||||
),
|
||||
};
|
||||
visit(productionRoot, "production", false);
|
||||
visit(developmentRoot, "development", false);
|
||||
|
||||
const result: DependencyInventoryRow[] = [];
|
||||
for (const record of records.values()) {
|
||||
let license = "NOASSERTION";
|
||||
let optional = record.optional;
|
||||
if (record.packagePath) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
await readFile(`${record.packagePath}/package.json`, "utf8"),
|
||||
);
|
||||
const manifest = recordValue(parsed);
|
||||
license = normalizeLicense(manifest.license ?? manifest.licenses);
|
||||
} catch {
|
||||
// Platform-specific optional packages may not be materialized locally.
|
||||
optional = true;
|
||||
}
|
||||
}
|
||||
result.push({
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
direct: record.direct,
|
||||
scope: record.scope,
|
||||
optional,
|
||||
license,
|
||||
dependencies: [...record.dependencies].sort(),
|
||||
});
|
||||
}
|
||||
return result.sort((left, right) =>
|
||||
`${left.name}@${left.version}`.localeCompare(
|
||||
`${right.name}@${right.version}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function diffDependencyInventories(
|
||||
before: Document,
|
||||
after: Document,
|
||||
): DependencyInventoryDiff {
|
||||
const beforeRows = recordRows(before.dependencies);
|
||||
const afterRows = recordRows(after.dependencies);
|
||||
const beforeMap = new Map(
|
||||
beforeRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const afterMap = new Map(
|
||||
afterRows.map((row) => [dependencyIdentity(row), row] as const),
|
||||
);
|
||||
const added = [...afterMap.keys()].filter((key) => !beforeMap.has(key));
|
||||
const removed = [...beforeMap.keys()].filter((key) => !afterMap.has(key));
|
||||
const changed: string[] = [];
|
||||
for (const key of [...beforeMap.keys()].filter((item) => afterMap.has(item))) {
|
||||
if (supplyChainDigest(beforeMap.get(key)) !== supplyChainDigest(afterMap.get(key))) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
const upgrades: DependencyUpgrade[] = [];
|
||||
for (const removedKey of removed) {
|
||||
const previous = beforeMap.get(removedKey);
|
||||
if (!previous) continue;
|
||||
const replacement = added.find(
|
||||
(addedKey) => afterMap.get(addedKey)?.name === previous.name,
|
||||
);
|
||||
const next = replacement ? afterMap.get(replacement) : undefined;
|
||||
if (next) {
|
||||
upgrades.push({
|
||||
name: String(previous.name ?? ""),
|
||||
from: String(previous.version ?? ""),
|
||||
to: String(next.version ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
added: Object.freeze(added.sort()),
|
||||
removed: Object.freeze(removed.sort()),
|
||||
changed: Object.freeze(changed.sort()),
|
||||
upgrades: Object.freeze(
|
||||
upgrades.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateLicensePolicy(
|
||||
inventory: Document,
|
||||
policy: Document,
|
||||
) {
|
||||
const allowed = new Set(stringRows(policy.allowedLicenses));
|
||||
const denied = stringRows(policy.deniedLicensePatterns);
|
||||
const failures: string[] = [];
|
||||
const results: Array<Readonly<{
|
||||
package: string;
|
||||
license: string;
|
||||
passed: boolean;
|
||||
reason: string | null;
|
||||
}>> = [];
|
||||
for (const dependency of recordRows(inventory.dependencies)) {
|
||||
const license = String(dependency.license ?? "NOASSERTION");
|
||||
const explicitlyDenied = denied.some((pattern) =>
|
||||
new RegExp(pattern, "i").test(license),
|
||||
);
|
||||
const unknownAccepted =
|
||||
license === "NOASSERTION" && dependency.optional === true;
|
||||
const passed =
|
||||
!explicitlyDenied && (allowed.has(license) || unknownAccepted);
|
||||
results.push({
|
||||
package: dependencyIdentity(dependency),
|
||||
license,
|
||||
passed,
|
||||
reason: unknownAccepted ? "platform-optional-not-materialized" : null,
|
||||
});
|
||||
if (!passed) {
|
||||
failures.push(
|
||||
`${dependencyIdentity(dependency)} has disallowed license ${license}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
results: Object.freeze(results),
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDependencyReview(
|
||||
diff: DependencyInventoryDiff,
|
||||
inventory: Document,
|
||||
evidenceFile: Document,
|
||||
) {
|
||||
const byIdentity = new Map(
|
||||
recordRows(inventory.dependencies).map(
|
||||
(row) => [dependencyIdentity(row), row] as const,
|
||||
),
|
||||
);
|
||||
const evidence = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of recordRows(evidenceFile.changes)) {
|
||||
if (typeof entry.changeId === "string") evidence.set(entry.changeId, entry);
|
||||
}
|
||||
const highRisk = diff.added.filter((identity) => {
|
||||
const row = byIdentity.get(identity);
|
||||
return row?.direct === true && row.scope === "production";
|
||||
});
|
||||
const failures: string[] = [];
|
||||
for (const identity of highRisk) {
|
||||
const changeId = `add:${identity}`;
|
||||
const entry = evidence.get(changeId);
|
||||
if (!entry) {
|
||||
failures.push(`high-risk dependency missing review: ${changeId}`);
|
||||
continue;
|
||||
}
|
||||
for (const field of ["owner", "reviewer", "reason", "rollback"] as const) {
|
||||
const value = entry[field];
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
failures.push(`${changeId} missing ${field}`);
|
||||
}
|
||||
}
|
||||
if (entry.owner === entry.reviewer) {
|
||||
failures.push(`${changeId} may not be self-approved`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
highRisk: Object.freeze(highRisk),
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
|
||||
const severityRank: ReadonlyMap<string, number> = new Map([
|
||||
["unknown", 0],
|
||||
["low", 1],
|
||||
["moderate", 2],
|
||||
["high", 3],
|
||||
["critical", 4],
|
||||
]);
|
||||
|
||||
export function validateVulnerabilityReport(
|
||||
report: Document,
|
||||
policy: Document,
|
||||
exceptionFile: Document,
|
||||
lockfileSha256: string,
|
||||
now: Date = new Date(),
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
if (report.scannedLockfileSha256 !== lockfileSha256) {
|
||||
failures.push("vulnerability report lockfile digest mismatch");
|
||||
}
|
||||
if (typeof report.provider !== "string" || !report.provider.trim()) {
|
||||
failures.push("vulnerability report provider missing");
|
||||
}
|
||||
const threshold = severityRank.get(String(policy.blockAtSeverity)) ?? 3;
|
||||
const exceptions = recordRows(exceptionFile.exceptions);
|
||||
const blocking: string[] = [];
|
||||
for (const finding of recordRows(report.findings)) {
|
||||
const severity = String(finding.severity ?? "unknown").toLowerCase();
|
||||
if ((severityRank.get(severity) ?? 0) < threshold) continue;
|
||||
const exception = exceptions.find(
|
||||
(entry) =>
|
||||
entry.vulnerabilityId === finding.id &&
|
||||
entry.packageName === finding.packageName,
|
||||
);
|
||||
const expiry =
|
||||
typeof exception?.expiresAt === "string"
|
||||
? Date.parse(exception.expiresAt)
|
||||
: Number.NaN;
|
||||
const validException = Boolean(
|
||||
exception &&
|
||||
typeof exception.owner === "string" &&
|
||||
exception.owner.trim() &&
|
||||
typeof exception.reviewer === "string" &&
|
||||
exception.reviewer.trim() &&
|
||||
exception.owner !== exception.reviewer &&
|
||||
typeof exception.reason === "string" &&
|
||||
exception.reason.trim() &&
|
||||
Number.isFinite(expiry) &&
|
||||
expiry > now.getTime(),
|
||||
);
|
||||
if (!validException) {
|
||||
blocking.push(
|
||||
`${String(finding.id)}:${String(finding.packageName)}@${String(finding.version)}:${severity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0 && blocking.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
blocking: Object.freeze(blocking),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifySupplyChainCoherence(
|
||||
sbom: Document,
|
||||
inventory: Document,
|
||||
provenance: Document,
|
||||
distDigest: string,
|
||||
) {
|
||||
const failures: string[] = [];
|
||||
const componentCount = Array.isArray(sbom.components)
|
||||
? sbom.components.length
|
||||
: -1;
|
||||
const dependencyCount = Array.isArray(inventory.dependencies)
|
||||
? inventory.dependencies.length
|
||||
: -2;
|
||||
if (componentCount !== dependencyCount) {
|
||||
failures.push("SBOM component count does not match inventory");
|
||||
}
|
||||
const metadata = recordValue(sbom.metadata);
|
||||
const properties = recordRows(metadata.properties);
|
||||
if (
|
||||
properties.find(
|
||||
(property) =>
|
||||
property.name === "ca:lockfileSha256" &&
|
||||
property.value === inventory.lockfileSha256,
|
||||
) === undefined
|
||||
) {
|
||||
failures.push("SBOM lockfile digest does not match inventory");
|
||||
}
|
||||
const subject = recordRows(provenance.subject)[0];
|
||||
const subjectDigest = recordValue(subject?.digest);
|
||||
if (subjectDigest.sha256 !== distDigest) {
|
||||
failures.push("provenance subject does not match built dist digest");
|
||||
}
|
||||
const predicate = recordValue(provenance.predicate);
|
||||
const materials = recordValue(predicate.materials);
|
||||
if (materials.lockfileSha256 !== inventory.lockfileSha256) {
|
||||
failures.push("provenance lockfile material does not match inventory");
|
||||
}
|
||||
return Object.freeze({
|
||||
passed: failures.length === 0,
|
||||
failures: Object.freeze(failures),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
type ModuleInventoryChunk = Readonly<{
|
||||
fileName: string;
|
||||
modules: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Rollup knows the exact source-module set for every emitted chunk. Persisting
|
||||
* that graph makes optional-runtime exclusion verifiable without relying on
|
||||
* minified names, error strings, or source maps.
|
||||
*/
|
||||
export function viteModuleInventoryPlugin(
|
||||
repositoryRoot = process.cwd(),
|
||||
): Plugin {
|
||||
return {
|
||||
name: "frontend-module-inventory",
|
||||
generateBundle(_options, bundle) {
|
||||
const chunks: ModuleInventoryChunk[] = Object.values(bundle)
|
||||
.filter((output) => output.type === "chunk")
|
||||
.map((chunk) => ({
|
||||
fileName: chunk.fileName,
|
||||
modules: Object.freeze(
|
||||
[...new Set(
|
||||
Object.keys(chunk.modules).map((moduleId) =>
|
||||
normalizeModuleId(moduleId, repositoryRoot),
|
||||
),
|
||||
)].sort(),
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.fileName.localeCompare(right.fileName));
|
||||
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: ".vite/module-inventory.json",
|
||||
source: `${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
chunks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModuleId(
|
||||
moduleId: string,
|
||||
repositoryRoot: string,
|
||||
): string {
|
||||
const withoutQuery = moduleId.replace(/^\0/u, "").split("?", 1)[0] ?? "";
|
||||
if (!path.isAbsolute(withoutQuery)) {
|
||||
return withoutQuery.replaceAll("\\", "/");
|
||||
}
|
||||
const relative = path.relative(repositoryRoot, withoutQuery);
|
||||
return relative.startsWith("..")
|
||||
? `external:${path.basename(withoutQuery)}`
|
||||
: relative.replaceAll("\\", "/");
|
||||
}
|
||||
Reference in New Issue
Block a user