chore: harden repository guardrails
This commit is contained in:
+432
-22
@@ -1,6 +1,7 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parse } from "yaml";
|
||||
|
||||
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
|
||||
|
||||
@@ -28,6 +29,10 @@ interface WorkspacePackage {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface WorkspaceConfiguration {
|
||||
packages?: string[];
|
||||
}
|
||||
|
||||
export interface BoundaryViolation {
|
||||
from: string;
|
||||
message: string;
|
||||
@@ -42,19 +47,82 @@ export interface BoundaryResult {
|
||||
}
|
||||
|
||||
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
||||
const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]);
|
||||
const MODULE_UI_EXTENSIONS = new Set([
|
||||
".astro",
|
||||
".css",
|
||||
".htm",
|
||||
".html",
|
||||
".jsx",
|
||||
".less",
|
||||
".marko",
|
||||
".mdx",
|
||||
".pcss",
|
||||
".postcss",
|
||||
".sass",
|
||||
".scss",
|
||||
".styl",
|
||||
".stylus",
|
||||
".svelte",
|
||||
".svg",
|
||||
".tsx",
|
||||
".vue",
|
||||
]);
|
||||
const MODULE_UI_PACKAGES = new Set([
|
||||
"@angular/core",
|
||||
"@builder.io/qwik",
|
||||
"@emotion/react",
|
||||
"@emotion/styled",
|
||||
"@lit/react",
|
||||
"@mui/material",
|
||||
"@preact/signals",
|
||||
"@storybook/react",
|
||||
"@tamagui/core",
|
||||
"alpinejs",
|
||||
"astro",
|
||||
"bootstrap",
|
||||
"bulma",
|
||||
"expo",
|
||||
"gatsby",
|
||||
"htmx.org",
|
||||
"lit",
|
||||
"marko",
|
||||
"nativewind",
|
||||
"next",
|
||||
"nuxt",
|
||||
"preact",
|
||||
"qwik",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react-native",
|
||||
"react-router-dom",
|
||||
"remix",
|
||||
"solid-js",
|
||||
"solid-start",
|
||||
"styled-components",
|
||||
"svelte",
|
||||
"tamagui",
|
||||
"tailwindcss",
|
||||
"unocss",
|
||||
"vue",
|
||||
"vue-router",
|
||||
"vuetify",
|
||||
]);
|
||||
const MODULE_UI_PACKAGE_PREFIXES = [
|
||||
"@angular/",
|
||||
"@chakra-ui/",
|
||||
"@emotion/",
|
||||
"@mui/",
|
||||
"@radix-ui/",
|
||||
"@remix-run/",
|
||||
"@solidjs/",
|
||||
"@storybook/",
|
||||
"@sveltejs/",
|
||||
"@tamagui/",
|
||||
"@tanstack/react-",
|
||||
"@unocss/",
|
||||
"@vitejs/plugin-react",
|
||||
"@vue/",
|
||||
];
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
".git",
|
||||
".next",
|
||||
@@ -69,6 +137,19 @@ const DEPENDENCY_FIELDS = [
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
] as const;
|
||||
const SHARED_PACKAGE_ROLES = new Set([
|
||||
"api-client",
|
||||
"auth-client",
|
||||
"builder-sdk",
|
||||
"contracts",
|
||||
"design-tokens",
|
||||
"module-sdk",
|
||||
"shared",
|
||||
"surface-sdk",
|
||||
"testing",
|
||||
"tooling",
|
||||
"ui",
|
||||
]);
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
||||
@@ -129,6 +210,12 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
if (to.layer === "module" && from.owner !== to.owner) {
|
||||
return "modules-do-not-import-other-modules";
|
||||
}
|
||||
if (
|
||||
to.layer === "shared" &&
|
||||
!["contracts", "module-sdk", "testing", "tooling"].includes(to.role ?? "")
|
||||
) {
|
||||
return "modules-use-public-platform-contracts";
|
||||
}
|
||||
}
|
||||
|
||||
if (from.layer === "surface") {
|
||||
@@ -144,6 +231,10 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
return "builder-composes-contracts";
|
||||
}
|
||||
|
||||
if (from.layer === "app" && ["app", "builder", "module"].includes(to.layer)) {
|
||||
return "apps-are-composition-roots";
|
||||
}
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
from.role === "contracts" &&
|
||||
@@ -154,12 +245,24 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
||||
|
||||
if (
|
||||
from.layer === "shared" &&
|
||||
["module-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["builder-sdk", "module-sdk", "surface-sdk"].includes(from.role ?? "") &&
|
||||
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||
) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
|
||||
if (from.layer === "shared" && to.layer === "shared") {
|
||||
if (from.role === "contracts" && to.role !== "contracts") {
|
||||
return "contracts-do-not-depend-upward";
|
||||
}
|
||||
if (["module-sdk", "surface-sdk"].includes(from.role ?? "") && to.role !== "contracts") {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
if (from.role === "builder-sdk" && !["contracts", "surface-sdk"].includes(to.role ?? "")) {
|
||||
return "sdks-do-not-depend-on-implementations";
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -171,7 +274,28 @@ function packageNameFromSpecifier(specifier: string): string {
|
||||
}
|
||||
|
||||
function isModuleUiPackage(packageName: string): boolean {
|
||||
return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/");
|
||||
const normalizedName = packageName.toLowerCase();
|
||||
return (
|
||||
MODULE_UI_PACKAGES.has(normalizedName) ||
|
||||
MODULE_UI_PACKAGE_PREFIXES.some((prefix) => normalizedName.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
|
||||
function npmAliasTarget(range: string): string | undefined {
|
||||
if (!range.startsWith("npm:")) {
|
||||
return undefined;
|
||||
}
|
||||
const alias = range.slice(4);
|
||||
if (alias.startsWith("@")) {
|
||||
const versionMarker = alias.lastIndexOf("@");
|
||||
return versionMarker > 0 ? alias.slice(0, versionMarker) : alias;
|
||||
}
|
||||
return alias.split("@")[0];
|
||||
}
|
||||
|
||||
function isModuleUiDependency(packageName: string, range: string): boolean {
|
||||
const aliasTarget = npmAliasTarget(range);
|
||||
return isModuleUiPackage(packageName) || (aliasTarget ? isModuleUiPackage(aliasTarget) : false);
|
||||
}
|
||||
|
||||
async function isFile(filePath: string): Promise<boolean> {
|
||||
@@ -210,13 +334,56 @@ async function walkFiles(root: string, current = root): Promise<string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
function workspacePatternMatches(relativeDirectory: string, pattern: string): boolean {
|
||||
let expression = "^";
|
||||
for (let index = 0; index < pattern.length; index += 1) {
|
||||
const character = pattern[index];
|
||||
if (character === "*" && pattern[index + 1] === "*") {
|
||||
expression += ".*";
|
||||
index += 1;
|
||||
} else if (character === "*") {
|
||||
expression += "[^/]*";
|
||||
} else if (character && /[\\^$.*+?()[\]{}|]/u.test(character)) {
|
||||
expression += `\\${character}`;
|
||||
} else {
|
||||
expression += character;
|
||||
}
|
||||
}
|
||||
expression += "$";
|
||||
return new RegExp(expression, "u").test(relativeDirectory);
|
||||
}
|
||||
|
||||
function workspaceDirectoryIsIncluded(relativeDirectory: string, patterns: string[]): boolean {
|
||||
const included = patterns
|
||||
.filter((pattern) => !pattern.startsWith("!"))
|
||||
.some((pattern) => workspacePatternMatches(relativeDirectory, pattern));
|
||||
const excluded = patterns
|
||||
.filter((pattern) => pattern.startsWith("!"))
|
||||
.some((pattern) => workspacePatternMatches(relativeDirectory, pattern.slice(1)));
|
||||
return included && !excluded;
|
||||
}
|
||||
|
||||
function workspacePatternIsSupported(pattern: string): boolean {
|
||||
const normalizedPattern = pattern.startsWith("!") ? pattern.slice(1) : pattern;
|
||||
return !/[?[\]{}]/u.test(normalizedPattern);
|
||||
}
|
||||
|
||||
async function readWorkspacePatterns(root: string): Promise<string[] | undefined> {
|
||||
const workspaceFile = path.join(root, "pnpm-workspace.yaml");
|
||||
if (!(await isFile(workspaceFile))) {
|
||||
return undefined;
|
||||
}
|
||||
const workspace = parse(await readFile(workspaceFile, "utf8")) as WorkspaceConfiguration;
|
||||
return workspace.packages;
|
||||
}
|
||||
|
||||
function collectImportSpecifiers(sourceText: string): string[] {
|
||||
const specifiers = new Set<string>();
|
||||
const staticImport =
|
||||
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu;
|
||||
const staticImport = /\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu;
|
||||
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
|
||||
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
|
||||
|
||||
for (const pattern of [staticImport, dynamicImport]) {
|
||||
for (const pattern of [staticImport, sideEffectImport, dynamicImport]) {
|
||||
for (const match of sourceText.matchAll(pattern)) {
|
||||
const specifier = match[1];
|
||||
if (specifier) {
|
||||
@@ -313,41 +480,210 @@ function exportedSubpath(
|
||||
specifier: string,
|
||||
packageName: string,
|
||||
): boolean {
|
||||
const suffix = specifier.slice(packageName.length);
|
||||
if (!suffix) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!manifest.exports || typeof manifest.exports !== "object") {
|
||||
function hasAvailableTarget(target: unknown): boolean {
|
||||
if (typeof target === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(target)) {
|
||||
return target.some(hasAvailableTarget);
|
||||
}
|
||||
if (target && typeof target === "object") {
|
||||
return Object.values(target).some(hasAvailableTarget);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const suffix = specifier.slice(packageName.length);
|
||||
const packageExports = manifest.exports;
|
||||
if (packageExports === undefined || packageExports === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!suffix) {
|
||||
if (typeof packageExports === "string") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(packageExports)) {
|
||||
return hasAvailableTarget(packageExports);
|
||||
}
|
||||
if (typeof packageExports !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportMap = packageExports as Record<string, unknown>;
|
||||
const exportKeys = Object.keys(exportMap);
|
||||
if (Object.hasOwn(exportMap, ".")) {
|
||||
return hasAvailableTarget(exportMap["."]);
|
||||
}
|
||||
return exportKeys.some((key) => !key.startsWith(".") && hasAvailableTarget(exportMap[key]));
|
||||
}
|
||||
|
||||
if (typeof packageExports !== "object" || Array.isArray(packageExports)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const exportMap = packageExports as Record<string, unknown>;
|
||||
const requested = `.${suffix}`;
|
||||
return Object.hasOwn(manifest.exports, requested);
|
||||
if (Object.hasOwn(exportMap, requested)) {
|
||||
return hasAvailableTarget(exportMap[requested]);
|
||||
}
|
||||
|
||||
function comparePatternKeys(left: string, right: string): number {
|
||||
const leftBaseLength = left.indexOf("*") + 1;
|
||||
const rightBaseLength = right.indexOf("*") + 1;
|
||||
if (leftBaseLength > rightBaseLength) {
|
||||
return -1;
|
||||
}
|
||||
if (rightBaseLength > leftBaseLength) {
|
||||
return 1;
|
||||
}
|
||||
if (left.length > right.length) {
|
||||
return -1;
|
||||
}
|
||||
if (right.length > left.length) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const matchingPatterns = Object.keys(exportMap)
|
||||
.filter((exportKey) => {
|
||||
const wildcard = exportKey.indexOf("*");
|
||||
if (wildcard === -1) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
requested.startsWith(exportKey.slice(0, wildcard)) &&
|
||||
requested.endsWith(exportKey.slice(wildcard + 1))
|
||||
);
|
||||
})
|
||||
.sort(comparePatternKeys);
|
||||
const matchingPattern = matchingPatterns[0];
|
||||
return matchingPattern ? hasAvailableTarget(exportMap[matchingPattern]) : false;
|
||||
}
|
||||
|
||||
function findContainingPackage(
|
||||
filePath: string,
|
||||
packagesByDepth: WorkspacePackage[],
|
||||
): WorkspacePackage | undefined {
|
||||
return packagesByDepth.find(
|
||||
(workspacePackage) =>
|
||||
filePath === workspacePackage.directory ||
|
||||
filePath.startsWith(`${workspacePackage.directory}${path.sep}`),
|
||||
);
|
||||
}
|
||||
|
||||
function dependencyRange(manifest: PackageManifest, dependencyName: string): string | undefined {
|
||||
for (const dependencyField of DEPENDENCY_FIELDS) {
|
||||
const range = manifest[dependencyField]?.[dependencyName];
|
||||
if (range !== undefined) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function workspaceAliasPackageName(range: string): string | undefined {
|
||||
if (!range.startsWith("workspace:")) {
|
||||
return undefined;
|
||||
}
|
||||
const target = range.slice("workspace:".length);
|
||||
if (target.startsWith("@")) {
|
||||
const versionMarker = target.lastIndexOf("@");
|
||||
return versionMarker > 0 ? target.slice(0, versionMarker) : target;
|
||||
}
|
||||
const versionMarker = target.lastIndexOf("@");
|
||||
return versionMarker > 0 ? target.slice(0, versionMarker) : undefined;
|
||||
}
|
||||
|
||||
function resolveWorkspaceDependency(
|
||||
dependencyName: string,
|
||||
range: string,
|
||||
sourcePackage: WorkspacePackage,
|
||||
workspacePackages: Map<string, WorkspacePackage>,
|
||||
packageByDirectory: Map<string, WorkspacePackage>,
|
||||
): { aliased: boolean; target: WorkspacePackage | undefined } {
|
||||
if (range.startsWith("workspace:.")) {
|
||||
const relativeTarget = range.slice("workspace:".length);
|
||||
return {
|
||||
aliased: true,
|
||||
target: packageByDirectory.get(path.resolve(sourcePackage.directory, relativeTarget)),
|
||||
};
|
||||
}
|
||||
|
||||
const aliasName = workspaceAliasPackageName(range);
|
||||
if (aliasName) {
|
||||
return { aliased: true, target: workspacePackages.get(aliasName) };
|
||||
}
|
||||
|
||||
const directTarget = workspacePackages.get(dependencyName);
|
||||
if (directTarget) {
|
||||
return { aliased: false, target: directTarget };
|
||||
}
|
||||
|
||||
return { aliased: false, target: undefined };
|
||||
}
|
||||
|
||||
export async function checkBoundaries(rootDirectory: string): Promise<BoundaryResult> {
|
||||
const root = path.resolve(rootDirectory);
|
||||
const allFiles = await walkFiles(root);
|
||||
const workspacePatterns = await readWorkspacePatterns(root);
|
||||
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
|
||||
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
|
||||
const workspacePackages = new Map<string, WorkspacePackage>();
|
||||
const packageByDirectory = new Map<string, WorkspacePackage>();
|
||||
const violations: BoundaryViolation[] = [];
|
||||
|
||||
for (const pattern of workspacePatterns ?? []) {
|
||||
if (!workspacePatternIsSupported(pattern)) {
|
||||
violations.push({
|
||||
from: "pnpm-workspace.yaml",
|
||||
message: `Workspace pattern ${pattern} uses unsupported glob syntax.`,
|
||||
rule: "workspace-patterns-use-supported-syntax",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const manifestFile of manifestFiles) {
|
||||
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
|
||||
if (!manifest.name || manifestFile === path.join(root, "package.json")) {
|
||||
if (!manifest.name) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.dirname(manifestFile);
|
||||
const relativeDirectory = normalizePath(path.relative(root, directory));
|
||||
const relativeDirectory = normalizePath(path.relative(root, directory)) || ".";
|
||||
if (
|
||||
directory !== root &&
|
||||
workspacePatterns &&
|
||||
!workspaceDirectoryIsIncluded(relativeDirectory, workspacePatterns)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const location = describeRepositoryPath(relativeDirectory);
|
||||
if (manifest.ekklesia?.layer) {
|
||||
location.role = manifest.ekklesia.layer;
|
||||
}
|
||||
if (
|
||||
location.layer === "shared" &&
|
||||
(!manifest.ekklesia?.layer || !SHARED_PACKAGE_ROLES.has(manifest.ekklesia.layer))
|
||||
) {
|
||||
violations.push({
|
||||
from: relativeDirectory,
|
||||
message: `Shared package ${manifest.name} must declare a recognized ekklesia.layer role.`,
|
||||
rule: "shared-packages-declare-known-roles",
|
||||
});
|
||||
}
|
||||
const workspacePackage = { directory, location, manifest, name: manifest.name };
|
||||
workspacePackages.set(manifest.name, workspacePackage);
|
||||
const existingPackage = workspacePackages.get(manifest.name);
|
||||
if (existingPackage) {
|
||||
violations.push({
|
||||
from: relativeDirectory,
|
||||
message: `Workspace package name ${manifest.name} is already used by ${existingPackage.location.relativePath}.`,
|
||||
rule: "workspace-package-names-are-unique",
|
||||
to: existingPackage.location.relativePath,
|
||||
});
|
||||
} else {
|
||||
workspacePackages.set(manifest.name, workspacePackage);
|
||||
}
|
||||
packageByDirectory.set(directory, workspacePackage);
|
||||
}
|
||||
|
||||
@@ -355,8 +691,13 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
for (const workspacePackage of workspacePackages.values()) {
|
||||
packageGraph.set(workspacePackage.name, new Set());
|
||||
for (const dependencyField of DEPENDENCY_FIELDS) {
|
||||
for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) {
|
||||
if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) {
|
||||
for (const [dependencyName, range] of Object.entries(
|
||||
workspacePackage.manifest[dependencyField] ?? {},
|
||||
)) {
|
||||
if (
|
||||
workspacePackage.location.layer === "module" &&
|
||||
isModuleUiDependency(dependencyName, range)
|
||||
) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `Module package depends on UI framework ${dependencyName}.`,
|
||||
@@ -364,10 +705,33 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
});
|
||||
}
|
||||
|
||||
const targetPackage = workspacePackages.get(dependencyName);
|
||||
const resolvedDependency = resolveWorkspaceDependency(
|
||||
dependencyName,
|
||||
range,
|
||||
workspacePackage,
|
||||
workspacePackages,
|
||||
packageByDirectory,
|
||||
);
|
||||
const targetPackage = resolvedDependency.target;
|
||||
if (!targetPackage) {
|
||||
continue;
|
||||
}
|
||||
if (resolvedDependency.aliased) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `Internal dependency alias ${dependencyName} obscures ${targetPackage.name}; use the real package name.`,
|
||||
rule: "workspace-dependency-aliases-are-forbidden",
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
if (!range.startsWith("workspace:")) {
|
||||
violations.push({
|
||||
from: workspacePackage.location.relativePath,
|
||||
message: `Internal dependency ${dependencyName} must use the workspace: protocol, not ${range}.`,
|
||||
rule: "internal-dependencies-use-workspace-protocol",
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
packageGraph.get(workspacePackage.name)?.add(targetPackage.name);
|
||||
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
|
||||
if (rule) {
|
||||
@@ -382,6 +746,10 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
}
|
||||
}
|
||||
|
||||
const packagesByDepth = [...packageByDirectory.values()].sort(
|
||||
(left, right) => right.directory.length - left.directory.length,
|
||||
);
|
||||
|
||||
for (const cycle of findCycles(packageGraph)) {
|
||||
violations.push({
|
||||
from: cycle[0] ?? "unknown-package",
|
||||
@@ -393,7 +761,10 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
for (const repositoryFile of allFiles) {
|
||||
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||
const location = describeRepositoryPath(relativeFile);
|
||||
if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) {
|
||||
if (
|
||||
location.layer === "module" &&
|
||||
MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile).toLowerCase())
|
||||
) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: "UI source files belong in a surface, not a module.",
|
||||
@@ -428,13 +799,52 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
||||
to = describeRepositoryPath(normalizePath(path.relative(root, targetFile)));
|
||||
if (await isFile(targetFile)) {
|
||||
sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile)));
|
||||
const sourcePackage = findContainingPackage(codeFile, packagesByDepth);
|
||||
const targetPackage = findContainingPackage(targetFile, packagesByDepth);
|
||||
if (
|
||||
sourcePackage &&
|
||||
targetPackage &&
|
||||
sourcePackage.directory !== targetPackage.directory
|
||||
) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `Cross-package import ${specifier} must use ${targetPackage.name}'s public exports.`,
|
||||
rule: "workspace-imports-use-public-exports",
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const targetPackage = workspacePackages.get(dependencyName);
|
||||
const sourcePackage = findContainingPackage(codeFile, packagesByDepth);
|
||||
const declaredRange = sourcePackage
|
||||
? dependencyRange(sourcePackage.manifest, dependencyName)
|
||||
: undefined;
|
||||
const targetPackage =
|
||||
sourcePackage && declaredRange
|
||||
? resolveWorkspaceDependency(
|
||||
dependencyName,
|
||||
declaredRange,
|
||||
sourcePackage,
|
||||
workspacePackages,
|
||||
packageByDirectory,
|
||||
).target
|
||||
: workspacePackages.get(dependencyName);
|
||||
if (targetPackage) {
|
||||
to = targetPackage.location;
|
||||
if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) {
|
||||
if (sourcePackage && sourcePackage.directory !== targetPackage.directory) {
|
||||
if (declaredRange === undefined) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `${sourcePackage.name} imports ${dependencyName} without declaring it.`,
|
||||
rule: "workspace-dependencies-are-declared",
|
||||
to: targetPackage.location.relativePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
const publicSpecifier =
|
||||
dependencyName === targetPackage.name ? specifier : targetPackage.name;
|
||||
if (!exportedSubpath(targetPackage.manifest, publicSpecifier, targetPackage.name)) {
|
||||
violations.push({
|
||||
from: relativeFile,
|
||||
message: `${specifier} is not a public package export.`,
|
||||
|
||||
Executable
+227
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root"
|
||||
|
||||
command -v docker >/dev/null || {
|
||||
echo "Docker is required for the infrastructure smoke test." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v curl >/dev/null || {
|
||||
echo "curl is required for the infrastructure smoke test." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v cmp >/dev/null || {
|
||||
echo "cmp is required for the infrastructure smoke test." >&2
|
||||
exit 1
|
||||
}
|
||||
docker compose version >/dev/null
|
||||
|
||||
curl_help="$(curl --help all)"
|
||||
if [[ "$curl_help" != *"--aws-sigv4"* ]]; then
|
||||
echo "curl must support --aws-sigv4 for authenticated S3 checks." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
raw_project="ekklesia-smoke-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-0}-$$"
|
||||
project="${raw_project//[^a-zA-Z0-9_-]/-}"
|
||||
project="${project,,}"
|
||||
temporary_directory="$(mktemp -d)"
|
||||
payload_file="$temporary_directory/payload.txt"
|
||||
download_file="$temporary_directory/download.txt"
|
||||
bucket_list_file="$temporary_directory/buckets.xml"
|
||||
: >"$bucket_list_file"
|
||||
|
||||
export POSTGRES_DB="ekklesia_smoke"
|
||||
export POSTGRES_USER="ekklesia_smoke"
|
||||
export POSTGRES_PASSWORD="ekklesia-smoke-postgres-password"
|
||||
export POSTGRES_PORT="${EKKLESIA_SMOKE_POSTGRES_PORT:-0}"
|
||||
export S3_ACCESS_KEY="ekklesia-smoke-access"
|
||||
export S3_SECRET_KEY="ekklesia-smoke-secret-key"
|
||||
export S3_BUCKET="ekklesia-smoke"
|
||||
export S3_REGION="us-east-1"
|
||||
export S3_PORT="${EKKLESIA_SMOKE_S3_PORT:-0}"
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "$project"
|
||||
--env-file .env.example
|
||||
--file infrastructure/compose.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
local exit_status=$?
|
||||
local cleanup_output
|
||||
trap - EXIT INT TERM
|
||||
if ((exit_status != 0)); then
|
||||
"${compose[@]}" logs --no-color >&2 || true
|
||||
fi
|
||||
if ! cleanup_output="$(
|
||||
"${compose[@]}" down --volumes --remove-orphans --timeout 10 2>&1
|
||||
)"; then
|
||||
echo "Infrastructure smoke cleanup failed:" >&2
|
||||
echo "$cleanup_output" >&2
|
||||
if ((exit_status == 0)); then
|
||||
exit_status=1
|
||||
fi
|
||||
fi
|
||||
rm -rf -- "$temporary_directory"
|
||||
exit "$exit_status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
"${compose[@]}" config --quiet
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 120
|
||||
|
||||
database_result="$(
|
||||
"${compose[@]}" exec -T \
|
||||
-e "PGPASSWORD=$POSTGRES_PASSWORD" \
|
||||
postgres \
|
||||
psql \
|
||||
--no-psqlrc \
|
||||
--host 127.0.0.1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
--tuples-only \
|
||||
--no-align \
|
||||
--set ON_ERROR_STOP=1 \
|
||||
--command "SELECT current_database(), current_user"
|
||||
)"
|
||||
if [[ "$database_result" != "$POSTGRES_DB|$POSTGRES_USER" ]]; then
|
||||
echo "PostgreSQL returned an unexpected identity: $database_result" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if "${compose[@]}" exec -T \
|
||||
-e "PGPASSWORD=incorrect-password" \
|
||||
postgres \
|
||||
psql \
|
||||
--no-psqlrc \
|
||||
--host 127.0.0.1 \
|
||||
--username "$POSTGRES_USER" \
|
||||
--dbname "$POSTGRES_DB" \
|
||||
--command "SELECT 1" >/dev/null 2>&1; then
|
||||
echo "PostgreSQL unexpectedly accepted an invalid password." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
s3_binding="$("${compose[@]}" port object-storage 8333)"
|
||||
s3_port="${s3_binding##*:}"
|
||||
if [[ ! "$s3_port" =~ ^[0-9]+$ ]]; then
|
||||
echo "Compose returned an invalid S3 port binding: $s3_binding" >&2
|
||||
exit 1
|
||||
fi
|
||||
s3_endpoint="http://127.0.0.1:$s3_port"
|
||||
signed_curl=(
|
||||
curl
|
||||
--silent
|
||||
--show-error
|
||||
--aws-sigv4 "aws:amz:$S3_REGION:s3"
|
||||
--user "$S3_ACCESS_KEY:$S3_SECRET_KEY"
|
||||
)
|
||||
|
||||
s3_ready=false
|
||||
for _ in {1..30}; do
|
||||
status="$(
|
||||
"${signed_curl[@]}" \
|
||||
--output "$bucket_list_file" \
|
||||
--write-out "%{http_code}" \
|
||||
"$s3_endpoint/" || true
|
||||
)"
|
||||
bucket_list="$(<"$bucket_list_file")"
|
||||
if [[ "$status" == "200" && "$bucket_list" == *"<Name>$S3_BUCKET</Name>"* ]]; then
|
||||
s3_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [[ "$s3_ready" != "true" ]]; then
|
||||
echo "The authenticated S3 endpoint and bucket did not become ready." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s' "ekklesia-storage-smoke" >"$payload_file"
|
||||
object_key="smoke/$project/object.txt"
|
||||
object_url="$s3_endpoint/$S3_BUCKET/$object_key"
|
||||
|
||||
put_status="$(
|
||||
"${signed_curl[@]}" \
|
||||
--upload-file "$payload_file" \
|
||||
--header "Content-Type: text/plain" \
|
||||
--output /dev/null \
|
||||
--write-out "%{http_code}" \
|
||||
"$object_url"
|
||||
)"
|
||||
if [[ "$put_status" != "200" ]]; then
|
||||
echo "S3 PUT returned $put_status instead of 200." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
get_status="$(
|
||||
"${signed_curl[@]}" \
|
||||
--output "$download_file" \
|
||||
--write-out "%{http_code}" \
|
||||
"$object_url"
|
||||
)"
|
||||
if [[ "$get_status" != "200" ]] || ! cmp --silent "$payload_file" "$download_file"; then
|
||||
echo "S3 GET did not return the uploaded object unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
head_status="$(
|
||||
"${signed_curl[@]}" --head --output /dev/null --write-out "%{http_code}" "$object_url"
|
||||
)"
|
||||
if [[ "$head_status" != "200" ]]; then
|
||||
echo "S3 HEAD returned $head_status instead of 200." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
anonymous_status="$(
|
||||
curl --silent --show-error --output /dev/null --write-out "%{http_code}" "$object_url"
|
||||
)"
|
||||
if [[ "$anonymous_status" != "403" ]]; then
|
||||
echo "S3 anonymous GET returned $anonymous_status instead of 403." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bad_signature_status="$(
|
||||
curl \
|
||||
--silent \
|
||||
--show-error \
|
||||
--aws-sigv4 "aws:amz:$S3_REGION:s3" \
|
||||
--user "$S3_ACCESS_KEY:incorrect-secret" \
|
||||
--output /dev/null \
|
||||
--write-out "%{http_code}" \
|
||||
"$object_url"
|
||||
)"
|
||||
if [[ "$bad_signature_status" != "403" ]]; then
|
||||
echo "S3 invalid signature returned $bad_signature_status instead of 403." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
delete_status="$(
|
||||
"${signed_curl[@]}" \
|
||||
--request DELETE \
|
||||
--output /dev/null \
|
||||
--write-out "%{http_code}" \
|
||||
"$object_url"
|
||||
)"
|
||||
if [[ "$delete_status" != "204" ]]; then
|
||||
echo "S3 DELETE returned $delete_status instead of 204." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing_status="$(
|
||||
"${signed_curl[@]}" --head --output /dev/null --write-out "%{http_code}" "$object_url"
|
||||
)"
|
||||
if [[ "$missing_status" != "404" ]]; then
|
||||
echo "Deleted S3 object returned $missing_status instead of 404." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Infrastructure smoke test passed for PostgreSQL and authenticated S3 operations."
|
||||
Reference in New Issue
Block a user