chore: harden repository guardrails
This commit is contained in:
@@ -36,3 +36,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Verify repository
|
- name: Verify repository
|
||||||
run: pnpm ci
|
run: pnpm ci
|
||||||
|
|
||||||
|
infrastructure-smoke:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Exercise PostgreSQL and S3 infrastructure
|
||||||
|
run: bash scripts/smoke-infrastructure.sh
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pnpm infrastructure:up
|
|||||||
pnpm verify
|
pnpm verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check.
|
||||||
|
|
||||||
The development services bind only to loopback:
|
The development services bind only to loopback:
|
||||||
|
|
||||||
- PostgreSQL: `127.0.0.1:5432`
|
- PostgreSQL: `127.0.0.1:5432`
|
||||||
|
|||||||
@@ -17,24 +17,36 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
|
|
||||||
- Core never imports a feature module, surface, Builder implementation, or app.
|
- Core never imports a feature module, surface, Builder implementation, or app.
|
||||||
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
|
||||||
- A module cannot contain `.tsx`, `.jsx`, `.css`, `.scss`, `.less`, `.vue`, or `.svelte` source
|
- A module cannot contain presentation source, including JSX/TSX, single-file components,
|
||||||
and cannot depend on a UI framework. Modules expose data and behavior only.
|
Astro/Marko/MDX/HTML views, stylesheets, or SVG presentation assets, and cannot depend on UI
|
||||||
|
frameworks or shared UI-role packages. Modules expose data and behavior only.
|
||||||
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
- A surface never imports Core or a feature module. It uses public API/client contracts.
|
||||||
- Separate surfaces do not import each other's internals.
|
- Separate surfaces do not import each other's internals.
|
||||||
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
- Builder code composes contracts and surface renderer identifiers; it does not import module or
|
||||||
surface internals.
|
surface internals.
|
||||||
- Contract and SDK packages cannot depend upward on implementations.
|
- Contract and SDK packages cannot depend upward on implementations.
|
||||||
- Workspace package consumers use declared `exports`, not private subpaths.
|
- Workspace package consumers use declared `exports`, not private subpaths.
|
||||||
|
- Every internal dependency uses a `workspace:` range and every imported workspace package is
|
||||||
|
declared by the consumer.
|
||||||
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||||
|
- Workspace package names are unique and shared packages declare a recognized architectural role.
|
||||||
- Circular source and workspace-package dependencies are rejected.
|
- Circular source and workspace-package dependencies are rejected.
|
||||||
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
|
||||||
direct table imports and foreign keys.
|
direct table imports and foreign keys.
|
||||||
|
|
||||||
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
|
||||||
rejects forbidden dependency directions even before production packages fill every directory.
|
rejects every currently machine-checkable repository rule even before production packages fill
|
||||||
|
every directory. The UI guard rejects component, view, template, stylesheet, shared UI-role, and
|
||||||
|
common UI-framework dependencies inside modules. This deny list is defense in depth; package
|
||||||
|
review remains required when adopting a new frontend technology.
|
||||||
|
|
||||||
|
Cross-module table ownership, foreign keys, and schema namespace rules require Drizzle metadata
|
||||||
|
and are intentionally deferred to the Phase 3 schema validator.
|
||||||
|
|
||||||
## Generated code
|
## Generated code
|
||||||
|
|
||||||
Generated artifacts are reproducible outputs with a named generator and validation step. A
|
Generated artifacts are reproducible outputs with a named generator and validation step. A
|
||||||
generated module bootstrap may import discovered module entry points at an application
|
generated module bootstrap under the root `generated/` boundary may import discovered module entry
|
||||||
composition edge, but Core itself remains feature-agnostic. Generated files are never an excuse
|
points, and apps may import that generated bootstrap. Handwritten app and Core code still cannot
|
||||||
for a hand-maintained central module registry.
|
import feature modules directly. Generated files are never an excuse for a hand-maintained central
|
||||||
|
module registry.
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ pnpm build # Turborepo package build graph
|
|||||||
pnpm verify # every code gate above
|
pnpm verify # every code gate above
|
||||||
```
|
```
|
||||||
|
|
||||||
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`.
|
The lint, typecheck, and test commands run both repository-root checks and every matching
|
||||||
|
package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts;
|
||||||
|
the root verification command discovers them automatically.
|
||||||
|
|
||||||
|
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. A separate CI job
|
||||||
|
runs the live infrastructure smoke test so code-only local verification does not require Docker.
|
||||||
|
|
||||||
## Development infrastructure
|
## Development infrastructure
|
||||||
|
|
||||||
@@ -42,6 +47,16 @@ pnpm infrastructure:down
|
|||||||
pnpm infrastructure:logs
|
pnpm infrastructure:logs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Exercise both services with isolated, throwaway credentials and Docker-assigned host ports:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm infrastructure:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke test verifies PostgreSQL password authentication and authenticated S3 list, put, get,
|
||||||
|
head, and delete operations. It also proves invalid database passwords, anonymous object access,
|
||||||
|
and invalid S3 signatures are rejected. Its containers, network, and volumes are removed on exit.
|
||||||
|
|
||||||
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
|
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
|
||||||
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
|
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
|
||||||
idempotently at startup.
|
idempotently at startup.
|
||||||
@@ -49,5 +64,5 @@ idempotently at startup.
|
|||||||
The checked-in credentials are safe only for local development. Deployed secrets must come from
|
The checked-in credentials are safe only for local development. Deployed secrets must come from
|
||||||
an environment-appropriate secret provider and must not be committed.
|
an environment-appropriate secret provider and must not be committed.
|
||||||
|
|
||||||
Docker is not installed in every contributor environment. `pnpm infrastructure:config` and live
|
Docker is not installed in every contributor environment. Compose validation and live service
|
||||||
service health therefore run separately from the code-only `pnpm verify` gate.
|
health therefore run separately from the code-only `pnpm verify` gate.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
|
||||||
POSTGRES_INITDB_ARGS: --data-checksums
|
POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
|
|||||||
+7
-3
@@ -18,11 +18,15 @@
|
|||||||
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
"infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet",
|
||||||
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
|
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
|
||||||
"infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow",
|
"infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow",
|
||||||
|
"infrastructure:smoke": "bash scripts/smoke-infrastructure.sh",
|
||||||
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
|
||||||
"lint": "biome lint .",
|
"lint": "turbo run lint lint:root",
|
||||||
"test": "vitest run",
|
"lint:root": "biome lint .",
|
||||||
|
"test": "turbo run test test:root",
|
||||||
|
"test:root": "vitest run --config vitest.config.ts",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json",
|
"typecheck": "turbo run typecheck typecheck:root",
|
||||||
|
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
||||||
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
|
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
|
- "builders/*"
|
||||||
- "core"
|
- "core"
|
||||||
- "modules/*"
|
- "modules/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|||||||
+429
-19
@@ -1,6 +1,7 @@
|
|||||||
import { readdir, readFile, stat } from "node:fs/promises";
|
import { readdir, readFile, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { parse } from "yaml";
|
||||||
|
|
||||||
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
|
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ interface WorkspacePackage {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceConfiguration {
|
||||||
|
packages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoundaryViolation {
|
export interface BoundaryViolation {
|
||||||
from: string;
|
from: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -42,19 +47,82 @@ export interface BoundaryResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
|
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([
|
const MODULE_UI_PACKAGES = new Set([
|
||||||
"@angular/core",
|
"@angular/core",
|
||||||
|
"@builder.io/qwik",
|
||||||
|
"@emotion/react",
|
||||||
|
"@emotion/styled",
|
||||||
|
"@lit/react",
|
||||||
|
"@mui/material",
|
||||||
|
"@preact/signals",
|
||||||
|
"@storybook/react",
|
||||||
|
"@tamagui/core",
|
||||||
|
"alpinejs",
|
||||||
|
"astro",
|
||||||
|
"bootstrap",
|
||||||
|
"bulma",
|
||||||
"expo",
|
"expo",
|
||||||
|
"gatsby",
|
||||||
|
"htmx.org",
|
||||||
|
"lit",
|
||||||
|
"marko",
|
||||||
|
"nativewind",
|
||||||
"next",
|
"next",
|
||||||
|
"nuxt",
|
||||||
|
"preact",
|
||||||
|
"qwik",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react-native",
|
"react-native",
|
||||||
|
"react-router-dom",
|
||||||
|
"remix",
|
||||||
"solid-js",
|
"solid-js",
|
||||||
|
"solid-start",
|
||||||
|
"styled-components",
|
||||||
"svelte",
|
"svelte",
|
||||||
|
"tamagui",
|
||||||
"tailwindcss",
|
"tailwindcss",
|
||||||
|
"unocss",
|
||||||
"vue",
|
"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([
|
const IGNORED_DIRECTORIES = new Set([
|
||||||
".git",
|
".git",
|
||||||
".next",
|
".next",
|
||||||
@@ -69,6 +137,19 @@ const DEPENDENCY_FIELDS = [
|
|||||||
"optionalDependencies",
|
"optionalDependencies",
|
||||||
"peerDependencies",
|
"peerDependencies",
|
||||||
] as const;
|
] 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 {
|
function normalizePath(value: string): string {
|
||||||
return value.split(path.sep).join("/").replace(/^\.\//, "");
|
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) {
|
if (to.layer === "module" && from.owner !== to.owner) {
|
||||||
return "modules-do-not-import-other-modules";
|
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") {
|
if (from.layer === "surface") {
|
||||||
@@ -144,6 +231,10 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
|||||||
return "builder-composes-contracts";
|
return "builder-composes-contracts";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (from.layer === "app" && ["app", "builder", "module"].includes(to.layer)) {
|
||||||
|
return "apps-are-composition-roots";
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
from.layer === "shared" &&
|
from.layer === "shared" &&
|
||||||
from.role === "contracts" &&
|
from.role === "contracts" &&
|
||||||
@@ -154,12 +245,24 @@ function boundaryRule(from: Location, to: Location): string | undefined {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
from.layer === "shared" &&
|
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)
|
["app", "builder", "core", "module", "surface"].includes(to.layer)
|
||||||
) {
|
) {
|
||||||
return "sdks-do-not-depend-on-implementations";
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +274,28 @@ function packageNameFromSpecifier(specifier: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isModuleUiPackage(packageName: string): boolean {
|
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> {
|
async function isFile(filePath: string): Promise<boolean> {
|
||||||
@@ -210,13 +334,56 @@ async function walkFiles(root: string, current = root): Promise<string[]> {
|
|||||||
return files;
|
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[] {
|
function collectImportSpecifiers(sourceText: string): string[] {
|
||||||
const specifiers = new Set<string>();
|
const specifiers = new Set<string>();
|
||||||
const staticImport =
|
const staticImport = /\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu;
|
||||||
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu;
|
const sideEffectImport = /\bimport\s*["']([^"'\n]+)["']/gu;
|
||||||
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/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)) {
|
for (const match of sourceText.matchAll(pattern)) {
|
||||||
const specifier = match[1];
|
const specifier = match[1];
|
||||||
if (specifier) {
|
if (specifier) {
|
||||||
@@ -313,41 +480,210 @@ function exportedSubpath(
|
|||||||
specifier: string,
|
specifier: string,
|
||||||
packageName: string,
|
packageName: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const suffix = specifier.slice(packageName.length);
|
function hasAvailableTarget(target: unknown): boolean {
|
||||||
if (!suffix) {
|
if (typeof target === "string") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(target)) {
|
||||||
if (!manifest.exports || typeof manifest.exports !== "object") {
|
return target.some(hasAvailableTarget);
|
||||||
|
}
|
||||||
|
if (target && typeof target === "object") {
|
||||||
|
return Object.values(target).some(hasAvailableTarget);
|
||||||
|
}
|
||||||
return false;
|
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}`;
|
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> {
|
export async function checkBoundaries(rootDirectory: string): Promise<BoundaryResult> {
|
||||||
const root = path.resolve(rootDirectory);
|
const root = path.resolve(rootDirectory);
|
||||||
const allFiles = await walkFiles(root);
|
const allFiles = await walkFiles(root);
|
||||||
|
const workspacePatterns = await readWorkspacePatterns(root);
|
||||||
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
|
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
|
||||||
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
|
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
|
||||||
const workspacePackages = new Map<string, WorkspacePackage>();
|
const workspacePackages = new Map<string, WorkspacePackage>();
|
||||||
const packageByDirectory = new Map<string, WorkspacePackage>();
|
const packageByDirectory = new Map<string, WorkspacePackage>();
|
||||||
const violations: BoundaryViolation[] = [];
|
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) {
|
for (const manifestFile of manifestFiles) {
|
||||||
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
|
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
|
||||||
if (!manifest.name || manifestFile === path.join(root, "package.json")) {
|
if (!manifest.name) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const directory = path.dirname(manifestFile);
|
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);
|
const location = describeRepositoryPath(relativeDirectory);
|
||||||
if (manifest.ekklesia?.layer) {
|
if (manifest.ekklesia?.layer) {
|
||||||
location.role = 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 };
|
const workspacePackage = { directory, location, manifest, name: manifest.name };
|
||||||
|
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);
|
workspacePackages.set(manifest.name, workspacePackage);
|
||||||
|
}
|
||||||
packageByDirectory.set(directory, workspacePackage);
|
packageByDirectory.set(directory, workspacePackage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,8 +691,13 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
|||||||
for (const workspacePackage of workspacePackages.values()) {
|
for (const workspacePackage of workspacePackages.values()) {
|
||||||
packageGraph.set(workspacePackage.name, new Set());
|
packageGraph.set(workspacePackage.name, new Set());
|
||||||
for (const dependencyField of DEPENDENCY_FIELDS) {
|
for (const dependencyField of DEPENDENCY_FIELDS) {
|
||||||
for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) {
|
for (const [dependencyName, range] of Object.entries(
|
||||||
if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) {
|
workspacePackage.manifest[dependencyField] ?? {},
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
workspacePackage.location.layer === "module" &&
|
||||||
|
isModuleUiDependency(dependencyName, range)
|
||||||
|
) {
|
||||||
violations.push({
|
violations.push({
|
||||||
from: workspacePackage.location.relativePath,
|
from: workspacePackage.location.relativePath,
|
||||||
message: `Module package depends on UI framework ${dependencyName}.`,
|
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) {
|
if (!targetPackage) {
|
||||||
continue;
|
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);
|
packageGraph.get(workspacePackage.name)?.add(targetPackage.name);
|
||||||
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
|
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
|
||||||
if (rule) {
|
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)) {
|
for (const cycle of findCycles(packageGraph)) {
|
||||||
violations.push({
|
violations.push({
|
||||||
from: cycle[0] ?? "unknown-package",
|
from: cycle[0] ?? "unknown-package",
|
||||||
@@ -393,7 +761,10 @@ export async function checkBoundaries(rootDirectory: string): Promise<BoundaryRe
|
|||||||
for (const repositoryFile of allFiles) {
|
for (const repositoryFile of allFiles) {
|
||||||
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
const relativeFile = normalizePath(path.relative(root, repositoryFile));
|
||||||
const location = describeRepositoryPath(relativeFile);
|
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({
|
violations.push({
|
||||||
from: relativeFile,
|
from: relativeFile,
|
||||||
message: "UI source files belong in a surface, not a module.",
|
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)));
|
to = describeRepositoryPath(normalizePath(path.relative(root, targetFile)));
|
||||||
if (await isFile(targetFile)) {
|
if (await isFile(targetFile)) {
|
||||||
sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, 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 {
|
} 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) {
|
if (targetPackage) {
|
||||||
to = targetPackage.location;
|
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({
|
violations.push({
|
||||||
from: relativeFile,
|
from: relativeFile,
|
||||||
message: `${specifier} is not a public package export.`,
|
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."
|
||||||
@@ -12,21 +12,93 @@ describe("repository dependency boundaries", () => {
|
|||||||
expect(result.violations).toEqual([]);
|
expect(result.violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts generated module composition only through the generated bootstrap boundary", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "legal-generated-bootstrap"));
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors explicit pnpm workspace exclusions", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "workspace-exclusion"));
|
||||||
|
expect(result.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["core-imports-module", "core-is-feature-agnostic"],
|
["core-imports-module", "core-is-feature-agnostic"],
|
||||||
|
["app-imports-module", "apps-are-composition-roots"],
|
||||||
|
["module-imports-core", "modules-use-public-platform-contracts"],
|
||||||
["module-imports-surface", "modules-use-public-platform-contracts"],
|
["module-imports-surface", "modules-use-public-platform-contracts"],
|
||||||
|
["package-boundary-direction", "modules-use-public-platform-contracts"],
|
||||||
["cross-module-import", "modules-do-not-import-other-modules"],
|
["cross-module-import", "modules-do-not-import-other-modules"],
|
||||||
|
["surface-imports-module", "surfaces-use-public-contracts"],
|
||||||
|
["cross-surface-import", "surfaces-are-independent"],
|
||||||
|
["builder-imports-module", "builder-composes-contracts"],
|
||||||
|
["contracts-import-core", "contracts-do-not-depend-upward"],
|
||||||
|
["sdk-imports-core", "sdks-do-not-depend-on-implementations"],
|
||||||
|
["shared-role-order", "sdks-do-not-depend-on-implementations"],
|
||||||
|
["package-without-exports", "workspace-imports-use-public-exports"],
|
||||||
|
["private-package-import", "workspace-imports-use-public-exports"],
|
||||||
|
["non-workspace-version", "internal-dependencies-use-workspace-protocol"],
|
||||||
|
["workspace-alias", "workspace-dependency-aliases-are-forbidden"],
|
||||||
|
["undeclared-workspace-dependency", "workspace-dependencies-are-declared"],
|
||||||
|
["duplicate-package-name", "workspace-package-names-are-unique"],
|
||||||
|
["unknown-shared-role", "shared-packages-declare-known-roles"],
|
||||||
|
["workspace-unsupported-pattern", "workspace-patterns-use-supported-syntax"],
|
||||||
|
["package-cycle", "no-circular-package-dependencies"],
|
||||||
["module-has-ui", "modules-do-not-own-ui"],
|
["module-has-ui", "modules-do-not-own-ui"],
|
||||||
|
["module-ui-framework", "modules-do-not-own-ui"],
|
||||||
|
["module-imports-ui-package", "modules-use-public-platform-contracts"],
|
||||||
|
["cross-package-relative", "workspace-imports-use-public-exports"],
|
||||||
["cycle", "no-circular-source-dependencies"],
|
["cycle", "no-circular-source-dependencies"],
|
||||||
])("rejects %s", async (fixture, expectedRule) => {
|
])("rejects %s", async (fixture, expectedRule) => {
|
||||||
const result = await checkBoundaries(path.join(fixtures, fixture));
|
const result = await checkBoundaries(path.join(fixtures, fixture));
|
||||||
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects both component and stylesheet files inside a module", async () => {
|
it("rejects component, stylesheet, and case-insensitive view files inside a module", async () => {
|
||||||
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
|
||||||
expect(
|
expect(
|
||||||
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||||
|
).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects UI framework dependencies, imports, and view formats inside a module", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "module-ui-framework"));
|
||||||
|
expect(
|
||||||
|
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires workspace: ranges in every dependency field", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "non-workspace-version"));
|
||||||
|
expect(
|
||||||
|
result.violations.filter(
|
||||||
|
(violation) => violation.rule === "internal-dependencies-use-workspace-protocol",
|
||||||
|
),
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves forbidden workspace aliases before applying layer rules", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "workspace-alias"));
|
||||||
|
expect(
|
||||||
|
result.violations.filter(
|
||||||
|
(violation) => violation.rule === "workspace-dependency-aliases-are-forbidden",
|
||||||
|
),
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
|
expect(result.violations.map((violation) => violation.rule)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"workspace-dependency-aliases-are-forbidden",
|
||||||
|
"modules-use-public-platform-contracts",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents contracts and SDKs from depending upward through shared packages", async () => {
|
||||||
|
const result = await checkBoundaries(path.join(fixtures, "shared-role-order"));
|
||||||
|
expect(result.violations.map((violation) => violation.rule)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"contracts-do-not-depend-upward",
|
||||||
|
"sdks-do-not-depend-on-implementations",
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { calendar } from "../../../modules/calendar/src/index.js";
|
||||||
|
|
||||||
|
export const invalidAppFeature = calendar;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { calendar } from "../../../modules/calendar/src/index.js";
|
||||||
|
|
||||||
|
export const invalidBuilderFeature = calendar;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const core = "core";
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { core } from "../../../core/src/index.js";
|
||||||
|
|
||||||
|
export const invalidContract = core;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { contract } from "../../packages/contracts/src/index.js";
|
||||||
|
|
||||||
|
export const invalidPrivatePath = contract;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const contract = "contract";
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { website } from "../../../surfaces/website/src/index.js";
|
||||||
|
|
||||||
|
export const invalidSurfaceDependency = website;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const website = "website";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/duplicate",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const one = 1;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/duplicate",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const two = 2;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { installedModules } from "../../../generated/module-registry.js";
|
||||||
|
|
||||||
|
export const bootstrap = installedModules;
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { calendar } from "../modules/calendar/src/index.js";
|
||||||
|
|
||||||
|
export const installedModules = [calendar];
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/conditional",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const conditional = "conditional";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/consumer",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/conditional": "workspace:*",
|
||||||
|
"@ekklesia/contracts": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HealthContract } from "@ekklesia/contracts";
|
||||||
|
import { conditional } from "@ekklesia/conditional";
|
||||||
|
import {
|
||||||
|
publicValue,
|
||||||
|
} from "@ekklesia/contracts/public/value";
|
||||||
|
|
||||||
|
export const health: HealthContract = { status: "ok" };
|
||||||
|
export { conditional };
|
||||||
|
export { publicValue };
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./public/*": "./src/public/*.ts"
|
||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "contracts"
|
"layer": "contracts"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const publicValue = "public";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<main>Modules do not own UI.</main>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const core = "core";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { core } from "../../../core/src/index.js";
|
||||||
|
|
||||||
|
export const invalidCoreImport = core;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/ui": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { button } from "@ekklesia/ui";
|
||||||
|
|
||||||
|
export const invalidModuleUiDependency = button;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const button = "button";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"compat": "npm:[email protected]",
|
||||||
|
"preact": "10.27.2"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { h } from "preact";
|
||||||
|
|
||||||
|
export const invalidUiFramework = h;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Modules do not own views
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ekklesia/testing": "1.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@ekklesia/module-sdk": "~1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@ekklesia/surface-sdk": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const core = "core";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const contract = "contract";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "module-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const moduleSdk = "module-sdk";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/surface-sdk",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "surface-sdk"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const surfaceSdk = "surface-sdk";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/testing",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "testing"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const testing = "testing";
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/surface-dashboard": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/surface-dashboard",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const dashboard = "dashboard";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/one",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/two": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const one = 1;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/two",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/one": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const two = 2;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { contract } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export const invalidImport = contract;
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const contract = "contract";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/core",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { privateValue } from "@ekklesia/contracts/abcdzz";
|
||||||
|
|
||||||
|
export const invalidPrivateImport = privateValue;
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./a*cdzz": "./src/index.ts",
|
||||||
|
"./ab*zz": null
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const privateValue = "private";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const core = "core";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { core } from "../../../core/src/index.js";
|
||||||
|
|
||||||
|
export const invalidSdk = core;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { moduleSdk } from "../../module-sdk/src/index.js";
|
||||||
|
|
||||||
|
export const invalidContractDependency = moduleSdk;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { surfaceSdk } from "../../surface-sdk/src/index.js";
|
||||||
|
|
||||||
|
export const moduleSdk = surfaceSdk;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const surfaceSdk = "surface-sdk";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const calendar = "calendar";
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { calendar } from "../../../modules/calendar/src/index.js";
|
||||||
|
|
||||||
|
export const invalidSurfaceFeature = calendar;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/consumer",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import { contract } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export const invalidUndeclaredDependency = contract;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const contract = "contract";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/mystery",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "mystery"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const mystery = "mystery";
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/module-calendar",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:@ekklesia/surface-dashboard@*",
|
||||||
|
"dashboard-data": "workspace:@ekklesia/surface-dashboard@*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { dashboard } from "dashboard-data";
|
||||||
|
import { hiddenDashboard } from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
export const invalidAlias = [dashboard, hiddenDashboard];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "contracts"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const hiddenDashboard = "contracts";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/surface-dashboard",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const dashboard = "dashboard";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/included",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const excluded = true;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/included",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const included = true;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "!packages/excluded"
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/example",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "tooling"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const example = true;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/{example,other}"
|
||||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
|
|
||||||
interface ComposeService {
|
interface ComposeService {
|
||||||
|
environment?: Record<string, string>;
|
||||||
healthcheck?: unknown;
|
healthcheck?: unknown;
|
||||||
image?: string;
|
image?: string;
|
||||||
ports?: string[];
|
ports?: string[];
|
||||||
@@ -16,6 +17,18 @@ interface ComposeDocument {
|
|||||||
volumes?: Record<string, unknown>;
|
volumes?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PackageDocument {
|
||||||
|
scripts?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurboDocument {
|
||||||
|
tasks?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceDocument {
|
||||||
|
packages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
describe("development infrastructure", () => {
|
describe("development infrastructure", () => {
|
||||||
@@ -32,6 +45,7 @@ describe("development infrastructure", () => {
|
|||||||
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
|
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
|
||||||
expect(postgres?.healthcheck).toBeDefined();
|
expect(postgres?.healthcheck).toBeDefined();
|
||||||
expect(objectStorage?.healthcheck).toBeDefined();
|
expect(objectStorage?.healthcheck).toBeDefined();
|
||||||
|
expect(postgres?.environment?.POSTGRES_INITDB_ARGS).toContain("--auth-host=scram-sha-256");
|
||||||
|
|
||||||
for (const service of [postgres, objectStorage]) {
|
for (const service of [postgres, objectStorage]) {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
@@ -44,18 +58,47 @@ describe("development infrastructure", () => {
|
|||||||
expect(compose.volumes).toHaveProperty("object-storage-data");
|
expect(compose.volumes).toHaveProperty("object-storage-data");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("has a parseable CI workflow with a single repository gate", async () => {
|
it("has a parseable CI workflow with static and live infrastructure gates", async () => {
|
||||||
const source = await readFile(
|
const source = await readFile(
|
||||||
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
|
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const workflow = parse(source) as {
|
const workflow = parse(source) as {
|
||||||
jobs?: { verify?: { steps?: Array<{ run?: string }> } };
|
jobs?: {
|
||||||
|
"infrastructure-smoke"?: { steps?: Array<{ run?: string }> };
|
||||||
|
verify?: { steps?: Array<{ run?: string }> };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
|
const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
|
||||||
|
const infrastructureCommands =
|
||||||
|
workflow.jobs?.["infrastructure-smoke"]?.steps?.flatMap((step) => step.run ?? []) ?? [];
|
||||||
|
|
||||||
expect(workflow.jobs?.verify).toBeDefined();
|
expect(workflow.jobs?.verify).toBeDefined();
|
||||||
expect(commands).toContain("pnpm install --frozen-lockfile");
|
expect(commands).toContain("pnpm install --frozen-lockfile");
|
||||||
expect(commands).toContain("pnpm ci");
|
expect(commands).toContain("pnpm ci");
|
||||||
|
expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes every official platform layer in the workspace", async () => {
|
||||||
|
const source = await readFile(path.join(repositoryRoot, "pnpm-workspace.yaml"), "utf8");
|
||||||
|
const workspace = parse(source) as WorkspaceDocument;
|
||||||
|
|
||||||
|
expect(workspace.packages).toEqual(
|
||||||
|
expect.arrayContaining(["apps/*", "builders/*", "core", "modules/*", "surfaces/*"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches root and future package quality tasks through Turborepo", async () => {
|
||||||
|
const packageSource = await readFile(path.join(repositoryRoot, "package.json"), "utf8");
|
||||||
|
const turboSource = await readFile(path.join(repositoryRoot, "turbo.json"), "utf8");
|
||||||
|
const packageDocument = JSON.parse(packageSource) as PackageDocument;
|
||||||
|
const turboDocument = JSON.parse(turboSource) as TurboDocument;
|
||||||
|
|
||||||
|
expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root");
|
||||||
|
expect(packageDocument.scripts?.test).toBe("turbo run test test:root");
|
||||||
|
expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck:root");
|
||||||
|
expect(turboDocument.tasks).toHaveProperty("//#lint:root");
|
||||||
|
expect(turboDocument.tasks).toHaveProperty("//#test:root");
|
||||||
|
expect(turboDocument.tasks).toHaveProperty("//#typecheck:root");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-3
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"globalDependencies": ["biome.json", "vitest.config.ts"],
|
||||||
"ui": "stream",
|
"ui": "stream",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
@@ -7,16 +8,24 @@
|
|||||||
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"dependsOn": ["^lint"],
|
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["coverage/**"]
|
"outputs": []
|
||||||
},
|
},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^typecheck"],
|
"dependsOn": ["^typecheck"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
|
},
|
||||||
|
"//#lint:root": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"//#test:root": {
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"//#typecheck:root": {
|
||||||
|
"outputs": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user