diff --git a/.changeset/static-architecture-policy.md b/.changeset/static-architecture-policy.md new file mode 100644 index 000000000..123b53f3c --- /dev/null +++ b/.changeset/static-architecture-policy.md @@ -0,0 +1,7 @@ +--- +"@croco/architecture-policy": minor +"@croco/cli": patch +"create-croco-app": patch +--- + +Add a static architecture policy engine and CLI gate for package/layer boundaries, public entrypoint imports, and generated SaaS app policy manifests. diff --git a/README.md b/README.md index d2e9a632c..1a9e69404 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,7 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기 > 이 섹션은 `pnpm docs:catalog:write`로 생성됩니다. 패키지 이름과 경로는 `packages/*/package.json`에서 읽고, 그룹/성숙도는 `docs/package-catalog.json`에서 관리합니다. -현재 카탈로그는 **108개 public package**를 추적합니다. Private package 2개는 publish 카탈로그에서 제외됩니다. 문서 커버리지 상세는 [docs/package-docs-report.md](docs/package-docs-report.md)를 확인하세요. +현재 카탈로그는 **109개 public package**를 추적합니다. Private package 2개는 publish 카탈로그에서 제외됩니다. 문서 커버리지 상세는 [docs/package-docs-report.md](docs/package-docs-report.md)를 확인하세요. ### Package Groups @@ -382,7 +382,7 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기 | Protocol | API protocol definitions and code generation | 7 | | Transport | Runtime adapters that execute protocol routes | 3 | | Presentation | Frontend, SSR, and presentation-layer adapters | 7 | -| Tooling | CLIs, scaffolds, presets, migration tools, and build-time helpers | 8 | +| Tooling | CLIs, scaffolds, presets, migration tools, and build-time helpers | 9 | ### Maturity Guide @@ -391,7 +391,7 @@ Adapter 경계와 공식 우선순위, compatibility certification checklist는 | 상태 | 의미 | 패키지 수 | | ------------------- | ----------------------------------- | --------: | | 🟢 production-ready | 안정화, 적극 사용 권장 | 23 | -| 🟡 beta | 기능 완성, 실사용 검증 중 | 51 | +| 🟡 beta | 기능 완성, 실사용 검증 중 | 52 | | 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 34 | | ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 | @@ -539,6 +539,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/billing-polar` | Provider | `packages/billing-polar` | README, tests | | `@croco/llm-openai` | Provider | `packages/llm-openai` | README, API, tests | | `@croco/storage-r2` | Provider | `packages/storage-r2` | README, tests | +| `@croco/architecture-policy` | Tooling | `packages/architecture-policy` | README, tests | | `@croco/cli` | Tooling | `packages/cli` | README, tests | | `create-croco-app` | Tooling | `packages/create-croco-app` | README, API, tests | | `@croco/esbuild-plugin` | Tooling | `packages/esbuild-plugin` | README, tests | diff --git a/croco.arch.json b/croco.arch.json new file mode 100644 index 000000000..b427899d1 --- /dev/null +++ b/croco.arch.json @@ -0,0 +1,176 @@ +{ + "schemaVersion": "croco.architecture-policy/v1", + "policyName": "croco-framework", + "packageRoots": ["packages"], + "include": [ + "packages/*/src/**/*.ts", + "packages/*/src/**/*.tsx", + "packages/*/src/**/*.mts", + "packages/*/src/**/*.cts" + ], + "ignore": [ + "packages/*/src/**/__tests__/**", + "packages/*/src/**/tests/**", + "packages/*/src/**/*.spec.ts", + "packages/*/src/**/*.test.ts" + ], + "packageGroups": { + "framework": { + "description": "Build-time and runtime framework primitives without provider or transport implementations.", + "packages": [ + "@croco/*-core", + "@croco/architecture-policy", + "@croco/cache-core", + "@croco/dataloader-core", + "@croco/diagnostics-core", + "@croco/events-core", + "@croco/events-inmemory", + "@croco/framework-*", + "@croco/gid-core", + "@croco/health-core", + "@croco/idempotency-core", + "@croco/pagination-core", + "@croco/problems-core", + "@croco/repository-core", + "@croco/retry-core", + "@croco/tenant-core", + "@croco/tx-core" + ] + }, + "protocols": { + "description": "Protocol contracts and generated protocol artifacts.", + "packages": [ + "@croco/admin-generated", + "@croco/openapi-spec", + "@croco/protocols-*", + "@croco/rpc-codegen" + ] + }, + "transports": { + "description": "Runtime adapters that execute protocol routes.", + "packages": ["@croco/transports-*"] + }, + "integrations": { + "description": "Concrete datastore, SaaS provider, and external service adapters.", + "packages": [ + "@croco/*-drizzle", + "@croco/*-posthog", + "@croco/*-qstash", + "@croco/*-upstash", + "@croco/auth-better-auth", + "@croco/auth-clerk", + "@croco/billing-polar", + "@croco/events-tx", + "@croco/llm-openai", + "@croco/metrics-billing", + "@croco/notifications-resend", + "@croco/search-meilisearch", + "@croco/storage-cloudflare", + "@croco/storage-cloudinary", + "@croco/storage-r2", + "@croco/telemetry-sdk-node", + "@croco/tx-drizzle" + ] + }, + "presentation": { + "description": "Frontend, SSR, and presentation adapters.", + "packages": [ + "@croco/admin-react", + "@croco/frontend-*", + "@croco/meta-vite", + "@croco/presentation-preset" + ] + }, + "app": { + "description": "Repository-owned CLIs, scaffolds, docs, and release tooling.", + "packages": [ + "@croco/cli", + "@croco/docs", + "@croco/esbuild-plugin", + "@croco/migration-runner", + "@croco/oxlint-rules", + "@croco/preset-*", + "@croco/testing", + "create-croco-app" + ] + } + }, + "rules": { + "forbiddenImports": [ + { + "id": "framework-provider-package-boundary", + "description": "Framework and core packages must not depend on concrete provider/runtime implementation packages.", + "from": { + "groups": ["framework"] + }, + "to": { + "packages": [ + "@croco/*-drizzle", + "@croco/*-posthog", + "@croco/*-qstash", + "@croco/*-upstash", + "@croco/auth-better-auth", + "@croco/auth-clerk", + "@croco/billing-polar", + "@croco/events-tx", + "@croco/llm-openai", + "@croco/metrics-billing", + "@croco/notifications-resend", + "@croco/search-meilisearch", + "@croco/storage-cloudflare", + "@croco/storage-cloudinary", + "@croco/storage-r2", + "@croco/telemetry-sdk-node", + "@croco/transports-*", + "@croco/tx-drizzle" + ] + }, + "message": "Framework/core packages cannot import provider or runtime implementation packages.", + "recovery": "Move provider/runtime implementation code behind an adapter package and keep the framework/core package on an explicit contract." + }, + { + "id": "framework-provider-sdk-boundary", + "description": "Framework and core packages must not depend on concrete provider/runtime SDKs.", + "from": { + "groups": ["framework"] + }, + "to": { + "specifiers": [ + "@clerk/*", + "@hono/node-server", + "@polar-sh/*", + "@upstash/*", + "cloudinary", + "cloudinary/*", + "drizzle-orm", + "drizzle-orm/*", + "hono", + "hono/*", + "ioredis", + "ioredis/*" + ] + }, + "message": "Framework/core packages cannot import provider or runtime implementation SDKs.", + "recovery": "Move provider/runtime implementation code behind an adapter package and keep the framework/core package on an explicit contract." + } + ], + "publicEntrypoints": { + "id": "croco-public-entrypoints", + "description": "Production source imports between Croco packages must use declared package exports instead of src/dist internals.", + "includePackages": ["@croco/*"], + "ignoreImports": [ + { + "paths": [ + "packages/*/src/**/__tests__/**", + "packages/*/src/**/tests/**", + "packages/*/src/**/*.spec.ts", + "packages/*/src/**/*.test.ts" + ], + "specifiers": ["@croco/*/src/**"] + } + ], + "message": "Croco packages must import declared public entrypoints instead of private package internals.", + "recovery": "Export the needed surface from the target package or move the test-only alias behind an explicit test/config ignore." + } + } +} diff --git a/docs/architecture-policy.md b/docs/architecture-policy.md new file mode 100644 index 000000000..0973438f6 --- /dev/null +++ b/docs/architecture-policy.md @@ -0,0 +1,36 @@ +# Architecture Policy Check + +`pnpm architecture-policy:check` validates `croco.arch.json` with the +`@croco/architecture-policy` engine. The gate runs through `pnpm check`. + +The policy manifest is a build-time contract for package and layer boundaries: + +- package groups such as `framework`, `protocols`, `transports`, `integrations`, + `presentation`, and `app`; +- forbidden imports from framework/core packages into provider or runtime implementations; +- allowed group edges for generated app packages; +- public entrypoint imports so package consumers do not reach into `src` or `dist` internals; +- deterministic diagnostics with file, line, column, diagnostic code, import specifier, and + recovery guidance. + +This gate is intentionally not a replacement for oxlint, oxfmt, Biome, TypeScript, or +`static-misuse:check`. + +| Gate | Owns | +| --------------------------- | ----------------------------------------------------------------------------------------------------- | +| `architecture-policy:check` | Croco package groups, layer edges, package manifest dependencies, public entrypoint import boundaries | +| `static-misuse:check` | Narrow line-oriented misuse patterns that are easier to express as source text checks | +| `oxlint` / `oxfmt` / Biome | Syntax, style, unused symbols, and lint rules that do not need Croco package context | +| `typecheck` | TypeScript type contracts and emitted declaration compatibility | +| `public-api:check` | Export snapshot drift for publishable package entrypoints | + +Generated SaaS apps receive their own `croco.arch.json` and an +`architecture-policy:check` script: + +```bash +croco architecture-policy check --manifest croco.arch.json +``` + +The generated policy uses the same engine as this repository. Its manifest keeps app entrypoints, +provider packages, generated provider RPC contracts, Croco framework/protocol packages, selected +integrations, and external SDKs in explicit groups before demo smoke tests run. diff --git a/docs/package-catalog.json b/docs/package-catalog.json index fab0cec7e..a39fc0e85 100644 --- a/docs/package-catalog.json +++ b/docs/package-catalog.json @@ -135,6 +135,7 @@ "Tooling": { "description": "CLIs, scaffolds, presets, migration tools, and build-time helpers", "packages": [ + "architecture-policy", "cli", "create-croco-app", "esbuild-plugin", @@ -181,6 +182,7 @@ "access-core", "admin-core", "admin-ops", + "architecture-policy", "billing-polar", "cache-core", "cli", diff --git a/docs/package-docs-baseline.json b/docs/package-docs-baseline.json index c2d45d86c..d393f96c7 100644 --- a/docs/package-docs-baseline.json +++ b/docs/package-docs-baseline.json @@ -7,6 +7,7 @@ "admin-core", "admin-react", "analytics-posthog", + "architecture-policy", "audit-drizzle", "auth-better-auth", "auth-clerk", diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md index eeb205e85..6d5141916 100644 --- a/docs/package-docs-report.md +++ b/docs/package-docs-report.md @@ -6,10 +6,10 @@ | Metric | Count | | ------------------------------ | ----: | -| Public packages | 108 | +| Public packages | 109 | | Private packages skipped | 2 | | Missing package README | 0 | -| Missing generated API docs | 58 | +| Missing generated API docs | 59 | | Missing package test directory | 0 | | Extension matrix packages | 41 | @@ -26,6 +26,7 @@ None. - `@croco/admin-core` (`packages/admin-core`) — legacy baseline - `@croco/admin-react` (`packages/admin-react`) — legacy baseline - `@croco/analytics-posthog` (`packages/analytics-posthog`) — legacy baseline +- `@croco/architecture-policy` (`packages/architecture-policy`) — legacy baseline - `@croco/audit-drizzle` (`packages/audit-drizzle`) — legacy baseline - `@croco/auth-better-auth` (`packages/auth-better-auth`) — legacy baseline - `@croco/auth-clerk` (`packages/auth-clerk`) — legacy baseline @@ -85,7 +86,7 @@ None. | Maturity | Missing API docs | | ------------------- | ---------------: | | 🟢 production-ready | 0 | -| 🟡 beta | 28 | +| 🟡 beta | 29 | | 🔴 alpha/WIP | 30 | | ⚠️ deprecated | 0 | @@ -104,12 +105,12 @@ None. | Protocol | 7 | | Transport | 3 | | Presentation | 7 | -| Tooling | 8 | +| Tooling | 9 | | Maturity | Packages | | ------------------- | -------: | | 🟢 production-ready | 23 | -| 🟡 beta | 51 | +| 🟡 beta | 52 | | 🔴 alpha/WIP | 34 | | ⚠️ deprecated | 0 | diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index 38e1cfdf2..f21340f75 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 354, + "problemCount": 358, "problems": [ { "code": "ACCESS_DENIED", @@ -461,6 +461,114 @@ } ] }, + { + "code": "architecture-policy/manifest-json-parse", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#architecture-policy-manifest-json-parse", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/architecture-policy/src/index.ts", + "line": 19, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "architecture-policy/manifest-schema-version", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#architecture-policy-manifest-schema-version", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/architecture-policy/src/index.ts", + "line": 30, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "architecture-policy/manifest-shape", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#architecture-policy-manifest-shape", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/architecture-policy/src/index.ts", + "line": 9, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "architecture-policy/package-json-parse", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#architecture-policy-package-json-parse", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/architecture-policy/src/index.ts", + "line": 46, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "audit-core/auditable-decorator-misuse", "category": "InternalServerError", diff --git a/package.json b/package.json index d38c7bb84..25a141deb 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "scripts": { "architecture:check:circular": "npx --yes madge --circular --extensions ts packages", "architecture:check:circular:allowlist": "node --experimental-strip-types scripts/verify-circular-allowlist.mts", + "architecture-policy:check": "pnpm --filter @croco/architecture-policy... build && node --experimental-strip-types scripts/architecture-policy-check.mts --manifest croco.arch.json", "build": "turbo build", "setup": "pnpm install && pnpm build && pnpm typecheck && pnpm test", "audit:prod": "pnpm audit --audit-level high --prod", - "check": "pnpm package-manifests:check && pnpm docs:catalog:check && pnpm problem-registry:check && pnpm docs:examples:check && pnpm release-docs:check && pnpm architecture:check:circular:allowlist && pnpm dependency-boundaries:check && pnpm strict-contract-typecheck && pnpm static-misuse:check && pnpm public-api:check && oxlint . && oxfmt --check . --ignore-path=.gitignore --ignore-path=.prettierignore --ignore-path=.oxfmtignore", + "check": "pnpm package-manifests:check && pnpm docs:catalog:check && pnpm problem-registry:check && pnpm docs:examples:check && pnpm release-docs:check && pnpm architecture-policy:check && pnpm architecture:check:circular:allowlist && pnpm dependency-boundaries:check && pnpm strict-contract-typecheck && pnpm static-misuse:check && pnpm public-api:check && oxlint . && oxfmt --check . --ignore-path=.gitignore --ignore-path=.prettierignore --ignore-path=.oxfmtignore", "changeset": "changeset", "deploy": "turbo deploy", "dev": "turbo dev", diff --git a/packages/architecture-policy/README.md b/packages/architecture-policy/README.md new file mode 100644 index 000000000..b7cc93f48 --- /dev/null +++ b/packages/architecture-policy/README.md @@ -0,0 +1,15 @@ +# @croco/architecture-policy + +Declarative static architecture policy engine for Croco repositories and generated apps. + +It validates package groups, forbidden imports, allowed group edges, manifest dependencies, and +public package entrypoint imports before runtime. The user-facing CLI is exposed through +`@croco/cli`: + +```bash +croco architecture-policy check --manifest croco.arch.json +``` + +The engine is intentionally separate from oxlint and format checks. It reasons over Croco package +contracts, generated app manifests, and package export surfaces; syntax and style remain owned by +oxlint, oxfmt, and TypeScript. diff --git a/packages/architecture-policy/package.json b/packages/architecture-policy/package.json new file mode 100644 index 000000000..cfc032ff9 --- /dev/null +++ b/packages/architecture-policy/package.json @@ -0,0 +1,42 @@ +{ + "name": "@croco/architecture-policy", + "version": "0.0.1", + "description": "Static architecture policy engine for Croco package and app boundaries", + "repository": { + "type": "git", + "url": "git+https://github.com/croco-dev/framework.git", + "directory": "packages/architecture-policy" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsup src/index.ts --format esm --clean --dts", + "lint": "oxlint .", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@croco/problems-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "4.0.16" + } +} diff --git a/packages/architecture-policy/src/index.ts b/packages/architecture-policy/src/index.ts new file mode 100644 index 000000000..7fe7bdf4d --- /dev/null +++ b/packages/architecture-policy/src/index.ts @@ -0,0 +1,1177 @@ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { Problem, ProblemCategory } from "@croco/problems-core"; + +export const ARCHITECTURE_POLICY_SCHEMA_VERSION = "croco.architecture-policy/v1"; + +export class ArchitecturePolicyManifestShapeProblem extends Problem { + constructor() { + super( + "architecture-policy/manifest-shape", + ProblemCategory.ValidationError, + "Architecture policy manifest must be a JSON object.", + ); + } +} + +export class ArchitecturePolicyManifestJsonParseProblem extends Problem { + constructor(cause?: Error) { + super( + "architecture-policy/manifest-json-parse", + ProblemCategory.ValidationError, + "Architecture policy manifest must contain valid JSON.", + { cause }, + ); + } +} + +export class ArchitecturePolicyManifestSchemaVersionProblem extends Problem { + constructor(actualSchemaVersion: unknown) { + super( + "architecture-policy/manifest-schema-version", + ProblemCategory.ValidationError, + `Architecture policy manifest schemaVersion must be '${ARCHITECTURE_POLICY_SCHEMA_VERSION}'.`, + { + extensions: { + actualSchemaVersion, + expectedSchemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + }, + }, + ); + } +} + +export class ArchitecturePolicyPackageJsonParseProblem extends Problem { + constructor(packageJsonPath: string, cause?: Error) { + super( + "architecture-policy/package-json-parse", + ProblemCategory.ValidationError, + `Architecture policy package.json must contain valid JSON: ${packageJsonPath}.`, + { + cause, + extensions: { + packageJsonPath, + }, + }, + ); + } +} + +export type ArchitecturePolicyPackageGroup = { + readonly description?: string; + readonly packages?: readonly string[]; + readonly paths?: readonly string[]; +}; + +export type ArchitecturePolicyMatcher = { + readonly groups?: readonly string[]; + readonly packages?: readonly string[]; + readonly paths?: readonly string[]; + readonly specifiers?: readonly string[]; +}; + +export type ArchitectureForbiddenImportRule = { + readonly id: string; + readonly description?: string; + readonly from: ArchitecturePolicyMatcher; + readonly to: ArchitecturePolicyMatcher; + readonly message?: string; + readonly recovery?: string; + readonly appliesTo?: readonly ArchitectureImportSourceKind[]; +}; + +export type ArchitectureAllowedGroupImportRule = { + readonly id: string; + readonly description?: string; + readonly fromGroups: readonly string[]; + readonly allowGroups: readonly string[]; + readonly allowPackages?: readonly string[]; + readonly allowSpecifiers?: readonly string[]; + readonly allowExternal?: boolean; + readonly allowSameGroup?: boolean; + readonly message?: string; + readonly recovery?: string; + readonly appliesTo?: readonly ArchitectureImportSourceKind[]; +}; + +export type ArchitectureEntrypointIgnore = { + readonly paths?: readonly string[]; + readonly packages?: readonly string[]; + readonly specifiers?: readonly string[]; +}; + +export type ArchitecturePublicEntrypointRule = { + readonly id: string; + readonly description?: string; + readonly includePackages?: readonly string[]; + readonly ignoreImports?: readonly ArchitectureEntrypointIgnore[]; + readonly message?: string; + readonly recovery?: string; +}; + +export type ArchitecturePolicyManifest = { + readonly schemaVersion: typeof ARCHITECTURE_POLICY_SCHEMA_VERSION; + readonly policyName?: string; + readonly packageRoots?: readonly string[]; + readonly include?: readonly string[]; + readonly ignore?: readonly string[]; + readonly packageGroups?: Readonly>; + readonly rules?: { + readonly forbiddenImports?: readonly ArchitectureForbiddenImportRule[]; + readonly allowedGroupImports?: readonly ArchitectureAllowedGroupImportRule[]; + readonly publicEntrypoints?: ArchitecturePublicEntrypointRule; + }; +}; + +export type ArchitectureImportSourceKind = "source" | "package-manifest"; + +export type ArchitecturePolicyDiagnosticCode = + | "architecture-policy/disallowed-dependency-edge" + | "architecture-policy/forbidden-import" + | "architecture-policy/private-entrypoint-import"; + +export type ArchitecturePolicyDiagnostic = { + readonly code: ArchitecturePolicyDiagnosticCode; + readonly ruleId: string; + readonly file: string; + readonly line: number; + readonly column: number; + readonly message: string; + readonly excerpt: string; + readonly importSpecifier: string; + readonly sourceKind: ArchitectureImportSourceKind; + readonly sourcePackage: string | null; + readonly sourceGroup: string | null; + readonly targetPackage: string | null; + readonly targetGroup: string | null; + readonly recovery?: string; +}; + +export type ArchitecturePolicyReport = { + readonly status: "pass" | "fail"; + readonly policyName: string | null; + readonly packageCount: number; + readonly checkedFileCount: number; + readonly checkedPackageManifestCount: number; + readonly importCount: number; + readonly diagnostics: readonly ArchitecturePolicyDiagnostic[]; +}; + +export type ArchitecturePolicyCheckOptions = { + readonly rootDir: string; + readonly manifest: ArchitecturePolicyManifest; +}; + +type PackageInfo = { + readonly name: string; + readonly relativeDir: string; + readonly group: string | null; + readonly packageJsonPath: string; + readonly packageJson: Record; + readonly exportSubpaths: readonly string[]; +}; + +type ImportRecord = { + readonly sourceKind: ArchitectureImportSourceKind; + readonly file: string; + readonly line: number; + readonly column: number; + readonly excerpt: string; + readonly specifier: string; + readonly importedPackageName: string | null; + readonly importedSubpath: string | null; + readonly sourcePackage: PackageInfo | null; +}; + +type NormalizedPolicy = { + readonly rootDir: string; + readonly manifest: ArchitecturePolicyManifest; + readonly includePatterns: readonly string[]; + readonly ignorePatterns: readonly string[]; + readonly packageGroups: Readonly>; +}; + +const sourceFilePattern = /\.[cm]?[jt]sx?$/; +const dependencySections = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +] as const; + +export function parseArchitecturePolicyManifest(content: string): ArchitecturePolicyManifest { + let parsed: unknown; + + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + throw new ArchitecturePolicyManifestJsonParseProblem( + error instanceof Error ? error : undefined, + ); + } + + if (!isRecord(parsed)) { + throw new ArchitecturePolicyManifestShapeProblem(); + } + + if (parsed.schemaVersion !== ARCHITECTURE_POLICY_SCHEMA_VERSION) { + throw new ArchitecturePolicyManifestSchemaVersionProblem(parsed.schemaVersion); + } + + return { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + policyName: readOptionalString(parsed.policyName), + packageRoots: readStringArray(parsed.packageRoots), + include: readStringArray(parsed.include), + ignore: readStringArray(parsed.ignore), + packageGroups: readPackageGroups(parsed.packageGroups), + rules: readRules(parsed.rules), + }; +} + +export function readArchitecturePolicyManifest(path: string): ArchitecturePolicyManifest { + return parseArchitecturePolicyManifest(readFileSync(path, "utf-8")); +} + +export function checkArchitecturePolicy( + options: ArchitecturePolicyCheckOptions, +): ArchitecturePolicyReport { + const normalized = normalizePolicy(options); + const packages = readPackages(normalized); + const imports = [ + ...collectSourceImports(normalized, packages), + ...collectPackageManifestImports(normalized, packages), + ].sort(compareImportRecords); + const diagnostics = [...runPolicyRules(normalized, packages, imports)].sort(compareDiagnostics); + + return { + status: diagnostics.length > 0 ? "fail" : "pass", + policyName: normalized.manifest.policyName ?? null, + packageCount: packages.length, + checkedFileCount: new Set( + imports.filter((entry) => entry.sourceKind === "source").map((entry) => entry.file), + ).size, + checkedPackageManifestCount: new Set( + imports.filter((entry) => entry.sourceKind === "package-manifest").map((entry) => entry.file), + ).size, + importCount: imports.length, + diagnostics, + }; +} + +export function formatArchitecturePolicyDiagnostic( + diagnostic: ArchitecturePolicyDiagnostic, +): string { + const target = diagnostic.targetPackage ? ` target=${diagnostic.targetPackage}` : ""; + const group = diagnostic.sourceGroup ? ` sourceGroup=${diagnostic.sourceGroup}` : ""; + + return `ERROR ${diagnostic.code} ${diagnostic.file}:${diagnostic.line}:${diagnostic.column}: ${diagnostic.message} import='${diagnostic.importSpecifier}'${target}${group}`; +} + +function normalizePolicy(options: ArchitecturePolicyCheckOptions): NormalizedPolicy { + const rootDir = resolve(options.rootDir); + + return { + rootDir, + manifest: options.manifest, + includePatterns: + options.manifest.include && options.manifest.include.length > 0 + ? options.manifest.include + : [ + "packages/*/src/**/*.ts", + "packages/*/src/**/*.tsx", + "packages/*/src/**/*.mts", + "packages/*/src/**/*.cts", + ], + ignorePatterns: options.manifest.ignore ?? [], + packageGroups: options.manifest.packageGroups ?? {}, + }; +} + +function readRules(value: unknown): ArchitecturePolicyManifest["rules"] { + if (!isRecord(value)) { + return undefined; + } + + return { + forbiddenImports: readForbiddenImportRules(value.forbiddenImports), + allowedGroupImports: readAllowedGroupImportRules(value.allowedGroupImports), + publicEntrypoints: readPublicEntrypointRule(value.publicEntrypoints), + }; +} + +function readForbiddenImportRules(value: unknown): readonly ArchitectureForbiddenImportRule[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.id !== "string") { + return []; + } + + return [ + { + id: entry.id, + description: readOptionalString(entry.description), + from: readMatcher(entry.from), + to: readMatcher(entry.to), + message: readOptionalString(entry.message), + recovery: readOptionalString(entry.recovery), + appliesTo: readSourceKinds(entry.appliesTo), + }, + ]; + }); +} + +function readAllowedGroupImportRules( + value: unknown, +): readonly ArchitectureAllowedGroupImportRule[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry) => { + if (!isRecord(entry) || typeof entry.id !== "string") { + return []; + } + + return [ + { + id: entry.id, + description: readOptionalString(entry.description), + fromGroups: readStringArray(entry.fromGroups) ?? [], + allowGroups: readStringArray(entry.allowGroups) ?? [], + allowPackages: readStringArray(entry.allowPackages), + allowSpecifiers: readStringArray(entry.allowSpecifiers), + allowExternal: typeof entry.allowExternal === "boolean" ? entry.allowExternal : undefined, + allowSameGroup: + typeof entry.allowSameGroup === "boolean" ? entry.allowSameGroup : undefined, + message: readOptionalString(entry.message), + recovery: readOptionalString(entry.recovery), + appliesTo: readSourceKinds(entry.appliesTo), + }, + ]; + }); +} + +function readPublicEntrypointRule(value: unknown): ArchitecturePublicEntrypointRule | undefined { + if (!isRecord(value) || typeof value.id !== "string") { + return undefined; + } + + return { + id: value.id, + description: readOptionalString(value.description), + includePackages: readStringArray(value.includePackages), + ignoreImports: readEntrypointIgnores(value.ignoreImports), + message: readOptionalString(value.message), + recovery: readOptionalString(value.recovery), + }; +} + +function readEntrypointIgnores(value: unknown): readonly ArchitectureEntrypointIgnore[] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry) => { + if (!isRecord(entry)) { + return []; + } + + return [ + { + paths: readStringArray(entry.paths), + packages: readStringArray(entry.packages), + specifiers: readStringArray(entry.specifiers), + }, + ]; + }); +} + +function readMatcher(value: unknown): ArchitecturePolicyMatcher { + if (!isRecord(value)) { + return {}; + } + + return { + groups: readStringArray(value.groups), + packages: readStringArray(value.packages), + paths: readStringArray(value.paths), + specifiers: readStringArray(value.specifiers), + }; +} + +function readPackageGroups( + value: unknown, +): Readonly> | undefined { + if (!isRecord(value)) { + return undefined; + } + + const groups: Record = {}; + + for (const [groupName, groupValue] of Object.entries(value).sort(([left], [right]) => + left.localeCompare(right), + )) { + if (!isRecord(groupValue)) { + continue; + } + + groups[groupName] = { + description: readOptionalString(groupValue.description), + packages: readStringArray(groupValue.packages), + paths: readStringArray(groupValue.paths), + }; + } + + return groups; +} + +function readSourceKinds(value: unknown): readonly ArchitectureImportSourceKind[] | undefined { + const values = readStringArray(value); + const sourceKinds = values?.filter( + (entry): entry is ArchitectureImportSourceKind => + entry === "source" || entry === "package-manifest", + ); + + return sourceKinds && sourceKinds.length > 0 ? sourceKinds : undefined; +} + +function readStringArray(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readPackages(policy: NormalizedPolicy): readonly PackageInfo[] { + const packageRoots = policy.manifest.packageRoots ?? ["packages"]; + const packageJsonFiles = packageRoots.flatMap((packageRoot) => + findPackageJsonFiles(join(policy.rootDir, packageRoot)), + ); + + return packageJsonFiles + .flatMap((packageJsonPath) => { + const packageJson = readJsonFile(packageJsonPath); + if (!isRecord(packageJson) || typeof packageJson.name !== "string") { + return []; + } + + const relativeDir = toPosixPath(relative(policy.rootDir, dirname(packageJsonPath))); + + return [ + { + name: packageJson.name, + relativeDir, + group: findPackageGroup(policy, packageJson.name, relativeDir), + packageJsonPath, + packageJson, + exportSubpaths: readPackageExportSubpaths(packageJson), + }, + ]; + }) + .sort((left, right) => left.relativeDir.localeCompare(right.relativeDir)); +} + +function findPackageJsonFiles(dir: string, results: string[] = []): string[] { + if (!existsSync(dir)) { + return results; + } + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) { + continue; + } + findPackageJsonFiles(fullPath, results); + continue; + } + + if (entry.isFile() && entry.name === "package.json") { + results.push(fullPath); + } + } + + return results.sort(); +} + +function readJsonFile(path: string): unknown { + const content = readFileSync(path, "utf-8"); + + try { + return JSON.parse(content) as unknown; + } catch (error) { + throw new ArchitecturePolicyPackageJsonParseProblem( + path, + error instanceof Error ? error : undefined, + ); + } +} + +function readPackageExportSubpaths(packageJson: Record): readonly string[] { + const publishConfig = isRecord(packageJson.publishConfig) ? packageJson.publishConfig : undefined; + const exportsField = publishConfig?.exports ?? packageJson.exports; + + if (typeof exportsField === "string") { + return ["."]; + } + + if (!isRecord(exportsField)) { + return ["."]; + } + + return Object.keys(exportsField).sort(); +} + +function collectSourceImports( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], +): readonly ImportRecord[] { + const files = collectIncludedFiles(policy); + + return files.flatMap((filePath) => { + const relativeFile = toPosixPath(relative(policy.rootDir, filePath)); + const sourcePackage = findSourcePackage(packages, relativeFile); + const lines = readFileSync(filePath, "utf-8").split(/\r?\n/); + let inBlockComment = false; + + return lines.flatMap((line, lineIndex) => { + const stripped = stripCommentsFromLine(line, inBlockComment); + inBlockComment = stripped.inBlockComment; + const imports = extractImportSpecifiers(stripped.source); + + return imports.map((entry) => { + const imported = splitPackageSpecifier(entry.specifier); + + return { + sourceKind: "source", + file: relativeFile, + line: lineIndex + 1, + column: entry.column, + excerpt: line.trim(), + specifier: entry.specifier, + importedPackageName: imported.packageName, + importedSubpath: imported.subpath, + sourcePackage, + } satisfies ImportRecord; + }); + }); + }); +} + +function collectPackageManifestImports( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], +): readonly ImportRecord[] { + return packages.flatMap((pkg) => { + const relativeFile = toPosixPath(relative(policy.rootDir, pkg.packageJsonPath)); + const source = readFileSync(pkg.packageJsonPath, "utf-8"); + const lines = source.split(/\r?\n/); + + return dependencySections.flatMap((section) => { + const dependencies = pkg.packageJson[section]; + if (!isRecord(dependencies)) { + return []; + } + + return Object.keys(dependencies) + .sort() + .map((specifier) => { + const location = findJsonPropertyLocation(lines, specifier); + const imported = splitPackageSpecifier(specifier); + + return { + sourceKind: "package-manifest", + file: relativeFile, + line: location.line, + column: location.column, + excerpt: lines[location.line - 1]?.trim() ?? `"${specifier}"`, + specifier, + importedPackageName: imported.packageName, + importedSubpath: imported.subpath, + sourcePackage: pkg, + } satisfies ImportRecord; + }); + }); + }); +} + +function collectIncludedFiles(policy: NormalizedPolicy): readonly string[] { + const files = new Set(); + + for (const pattern of policy.includePatterns) { + const absoluteRoot = join(policy.rootDir, getStaticPatternPrefix(pattern)); + for (const filePath of walkFiles(absoluteRoot)) { + const relativeFile = toPosixPath(relative(policy.rootDir, filePath)); + if ( + sourceFilePattern.test(relativeFile) && + matchesPattern(relativeFile, pattern) && + !policy.ignorePatterns.some((ignorePattern) => matchesPattern(relativeFile, ignorePattern)) + ) { + files.add(filePath); + } + } + } + + return [...files].sort((left, right) => left.localeCompare(right)); +} + +function walkFiles(dir: string, results: string[] = []): string[] { + if (!existsSync(dir)) { + return results; + } + + const stat = statSync(dir); + if (stat.isFile()) { + results.push(dir); + return results; + } + + if (!stat.isDirectory()) { + return results; + } + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + if (shouldSkipDirectory(entry.name)) { + continue; + } + walkFiles(fullPath, results); + continue; + } + + if (entry.isFile()) { + results.push(fullPath); + } + } + + return results.sort(); +} + +function shouldSkipDirectory(name: string): boolean { + return ( + name === "node_modules" || + name === "dist" || + name === "coverage" || + name === ".turbo" || + name === ".git" + ); +} + +function runPolicyRules( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], + imports: readonly ImportRecord[], +): readonly ArchitecturePolicyDiagnostic[] { + return [ + ...runForbiddenImportRules(policy, packages, imports), + ...runAllowedGroupImportRules(policy, packages, imports), + ...runPublicEntrypointRule(policy, packages, imports), + ]; +} + +function runForbiddenImportRules( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], + imports: readonly ImportRecord[], +): readonly ArchitecturePolicyDiagnostic[] { + const rules = policy.manifest.rules?.forbiddenImports ?? []; + + return imports.flatMap((entry) => + rules.flatMap((rule) => { + if (!ruleAppliesToSourceKind(rule.appliesTo, entry.sourceKind)) { + return []; + } + + const context = createImportContext(policy, packages, entry); + if (!matchesSourceMatcher(rule.from, context) || !matchesTargetMatcher(rule.to, context)) { + return []; + } + + return [ + createDiagnostic( + "architecture-policy/forbidden-import", + rule.id, + entry, + context, + rule.message ?? + `${context.sourcePackageName ?? "Unknown package"} cannot import ${entry.specifier}.`, + rule.recovery, + ), + ]; + }), + ); +} + +function runAllowedGroupImportRules( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], + imports: readonly ImportRecord[], +): readonly ArchitecturePolicyDiagnostic[] { + const rules = policy.manifest.rules?.allowedGroupImports ?? []; + + return imports.flatMap((entry) => + rules.flatMap((rule) => { + if (!ruleAppliesToSourceKind(rule.appliesTo, entry.sourceKind)) { + return []; + } + + const context = createImportContext(policy, packages, entry); + if (!context.sourceGroup || !rule.fromGroups.includes(context.sourceGroup)) { + return []; + } + + if (!entry.importedPackageName) { + return []; + } + + if (matchesAnyPattern(entry.specifier, rule.allowSpecifiers ?? [])) { + return []; + } + + if (matchesAnyPattern(entry.importedPackageName, rule.allowPackages ?? [])) { + return []; + } + + if ( + rule.allowSameGroup !== false && + context.targetGroup !== null && + context.sourceGroup === context.targetGroup + ) { + return []; + } + + if (context.targetGroup && rule.allowGroups.includes(context.targetGroup)) { + return []; + } + + if ( + !context.targetGroup && + !context.targetIsWorkspacePackage && + rule.allowExternal === true + ) { + return []; + } + + return [ + createDiagnostic( + "architecture-policy/disallowed-dependency-edge", + rule.id, + entry, + context, + rule.message ?? + `Group '${context.sourceGroup}' cannot import '${context.targetGroup ?? entry.importedPackageName}'.`, + rule.recovery, + ), + ]; + }), + ); +} + +function runPublicEntrypointRule( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], + imports: readonly ImportRecord[], +): readonly ArchitecturePolicyDiagnostic[] { + const rule = policy.manifest.rules?.publicEntrypoints; + if (!rule) { + return []; + } + + return imports.flatMap((entry) => { + if (!entry.importedPackageName || !entry.importedSubpath) { + return []; + } + + if (!matchesAnyPattern(entry.importedPackageName, rule.includePackages ?? ["*"])) { + return []; + } + + const targetPackage = packages.find((pkg) => pkg.name === entry.importedPackageName); + if (!targetPackage) { + return []; + } + + if (isEntrypointImportIgnored(rule, entry)) { + return []; + } + + if (isExportedSubpath(targetPackage, entry.importedSubpath)) { + return []; + } + + const context = createImportContext(policy, packages, entry); + + return [ + createDiagnostic( + "architecture-policy/private-entrypoint-import", + rule.id, + entry, + context, + rule.message ?? + `${entry.importedPackageName}/${entry.importedSubpath} is not a declared public export.`, + rule.recovery, + ), + ]; + }); +} + +function createDiagnostic( + code: ArchitecturePolicyDiagnosticCode, + ruleId: string, + entry: ImportRecord, + context: ImportContext, + message: string, + recovery: string | undefined, +): ArchitecturePolicyDiagnostic { + return { + code, + ruleId, + file: entry.file, + line: entry.line, + column: entry.column, + message, + excerpt: entry.excerpt, + importSpecifier: entry.specifier, + sourceKind: entry.sourceKind, + sourcePackage: context.sourcePackageName, + sourceGroup: context.sourceGroup, + targetPackage: context.targetPackageName, + targetGroup: context.targetGroup, + recovery, + }; +} + +type ImportContext = { + readonly sourcePackageName: string | null; + readonly sourceGroup: string | null; + readonly sourcePath: string; + readonly targetPackageName: string | null; + readonly targetGroup: string | null; + readonly targetPath: string | null; + readonly targetIsWorkspacePackage: boolean; + readonly specifier: string; +}; + +function createImportContext( + policy: NormalizedPolicy, + packages: readonly PackageInfo[], + entry: ImportRecord, +): ImportContext { + const targetPackage = packages.find((pkg) => pkg.name === entry.importedPackageName); + const targetGroup = entry.importedPackageName + ? (targetPackage?.group ?? findPackageGroup(policy, entry.importedPackageName, null)) + : null; + + return { + sourcePackageName: entry.sourcePackage?.name ?? null, + sourceGroup: entry.sourcePackage?.group ?? null, + sourcePath: entry.file, + targetPackageName: entry.importedPackageName, + targetGroup, + targetPath: targetPackage?.relativeDir ?? null, + targetIsWorkspacePackage: targetPackage !== undefined, + specifier: entry.specifier, + }; +} + +function matchesSourceMatcher(matcher: ArchitecturePolicyMatcher, context: ImportContext): boolean { + return ( + matchesOptionalPatterns(context.sourceGroup, matcher.groups) && + matchesOptionalPatterns(context.sourcePackageName, matcher.packages) && + matchesOptionalPatterns(context.sourcePath, matcher.paths) + ); +} + +function matchesTargetMatcher(matcher: ArchitecturePolicyMatcher, context: ImportContext): boolean { + const checks = [ + matcher.groups ? matchesOptionalPatterns(context.targetGroup, matcher.groups) : null, + matcher.packages ? matchesOptionalPatterns(context.targetPackageName, matcher.packages) : null, + matcher.paths ? matchesOptionalPatterns(context.targetPath, matcher.paths) : null, + matcher.specifiers ? matchesOptionalPatterns(context.specifier, matcher.specifiers) : null, + ].filter((value): value is boolean => value !== null); + + return checks.every(Boolean); +} + +function matchesOptionalPatterns( + value: string | null, + patterns: readonly string[] | undefined, +): boolean { + return ( + !patterns || patterns.length === 0 || (value !== null && matchesAnyPattern(value, patterns)) + ); +} + +function matchesAnyPattern(value: string, patterns: readonly string[]): boolean { + return patterns.some((pattern) => matchesPattern(value, pattern)); +} + +function matchesPattern(value: string, pattern: string): boolean { + return patternToRegExp(pattern).test(value); +} + +function patternToRegExp(pattern: string): RegExp { + let source = "^"; + + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index]; + const next = pattern[index + 1]; + + if (char === "*" && next === "*") { + if (pattern[index + 2] === "/") { + source += "(?:.*/)?"; + index += 2; + continue; + } + source += ".*"; + index += 1; + continue; + } + + if (char === "*") { + source += "[^/]*"; + continue; + } + + source += escapeRegExp(char); + } + + return new RegExp(`${source}$`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[\\^$+?.()|[\]{}]/g, "\\$&"); +} + +function findPackageGroup( + policy: NormalizedPolicy, + packageName: string, + relativeDir: string | null, +): string | null { + const groups = Object.entries(policy.packageGroups).sort(([left], [right]) => + left.localeCompare(right), + ); + + for (const [groupName, group] of groups) { + if (matchesAnyPattern(packageName, group.packages ?? [])) { + return groupName; + } + } + + for (const [groupName, group] of groups) { + if (relativeDir && matchesAnyPattern(relativeDir, group.paths ?? [])) { + return groupName; + } + } + + return null; +} + +function splitPackageSpecifier(specifier: string): { + readonly packageName: string | null; + readonly subpath: string | null; +} { + if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.length === 0) { + return { packageName: null, subpath: null }; + } + + if (specifier.startsWith("@")) { + const parts = specifier.split("/"); + const packageName = parts.length >= 2 ? parts.slice(0, 2).join("/") : specifier; + const subpath = parts.length > 2 ? parts.slice(2).join("/") : null; + return { packageName, subpath }; + } + + const [packageName, ...subpathParts] = specifier.split("/"); + return { + packageName: packageName ?? null, + subpath: subpathParts.length > 0 ? subpathParts.join("/") : null, + }; +} + +function extractImportSpecifiers( + line: string, +): readonly { readonly specifier: string; readonly column: number }[] { + const pattern = + /\b(?:import|export)\s+(?:type\s+)?(?:[^'"`]*?\s+from\s+)?['"`]([^'"`]+)['"`]|\bfrom\s+['"`]([^'"`]+)['"`]|\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)|\brequire\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g; + const specifiers: { specifier: string; column: number }[] = []; + + for (const match of line.matchAll(pattern)) { + const specifier = match[1] ?? match[2] ?? match[3] ?? match[4]; + if (!specifier) { + continue; + } + const quoteIndex = line.indexOf(specifier, match.index ?? 0); + specifiers.push({ + specifier, + column: quoteIndex >= 0 ? quoteIndex + 1 : (match.index ?? 0) + 1, + }); + } + + return specifiers; +} + +function stripCommentsFromLine( + line: string, + startsInBlockComment: boolean, +): { readonly source: string; readonly inBlockComment: boolean } { + let source = ""; + let index = 0; + let inBlockComment = startsInBlockComment; + + while (index < line.length) { + if (inBlockComment) { + const endIndex = line.indexOf("*/", index); + if (endIndex === -1) { + return { source, inBlockComment: true }; + } + index = endIndex + 2; + inBlockComment = false; + continue; + } + + const lineCommentIndex = line.indexOf("//", index); + const blockCommentIndex = line.indexOf("/*", index); + + if ( + lineCommentIndex !== -1 && + (blockCommentIndex === -1 || lineCommentIndex < blockCommentIndex) + ) { + source += line.slice(index, lineCommentIndex); + return { source, inBlockComment: false }; + } + + if (blockCommentIndex === -1) { + source += line.slice(index); + return { source, inBlockComment: false }; + } + + source += line.slice(index, blockCommentIndex); + index = blockCommentIndex + 2; + inBlockComment = true; + } + + return { source, inBlockComment }; +} + +function findSourcePackage( + packages: readonly PackageInfo[], + relativeFile: string, +): PackageInfo | null { + return ( + [...packages] + .sort((left, right) => right.relativeDir.length - left.relativeDir.length) + .find( + (pkg) => relativeFile === pkg.relativeDir || relativeFile.startsWith(`${pkg.relativeDir}/`), + ) ?? null + ); +} + +function getStaticPatternPrefix(pattern: string): string { + const wildcardIndex = pattern.search(/[*]/); + if (wildcardIndex === -1) { + return pattern; + } + + const slashIndex = pattern.slice(0, wildcardIndex).lastIndexOf("/"); + return slashIndex === -1 ? "." : pattern.slice(0, slashIndex); +} + +function findJsonPropertyLocation( + lines: readonly string[], + propertyName: string, +): { readonly line: number; readonly column: number } { + const quotedProperty = `"${propertyName}"`; + + for (const [index, line] of lines.entries()) { + const column = line.indexOf(quotedProperty); + if (column !== -1) { + return { line: index + 1, column: column + 1 }; + } + } + + return { line: 1, column: 1 }; +} + +function isExportedSubpath(pkg: PackageInfo, subpath: string): boolean { + const exportKey = `./${subpath}`; + + return pkg.exportSubpaths.some((candidate) => { + if (candidate === exportKey) { + return true; + } + + if (!candidate.includes("*")) { + return false; + } + + return matchesPattern(exportKey, candidate); + }); +} + +function isEntrypointImportIgnored( + rule: ArchitecturePublicEntrypointRule, + entry: ImportRecord, +): boolean { + return (rule.ignoreImports ?? []).some((ignore) => { + const pathMatches = !ignore.paths || matchesAnyPattern(entry.file, ignore.paths); + const packageMatches = + !ignore.packages || + (entry.importedPackageName !== null && + matchesAnyPattern(entry.importedPackageName, ignore.packages)); + const specifierMatches = + !ignore.specifiers || matchesAnyPattern(entry.specifier, ignore.specifiers); + + return pathMatches && packageMatches && specifierMatches; + }); +} + +function ruleAppliesToSourceKind( + appliesTo: readonly ArchitectureImportSourceKind[] | undefined, + sourceKind: ArchitectureImportSourceKind, +): boolean { + return !appliesTo || appliesTo.includes(sourceKind); +} + +function compareImportRecords(left: ImportRecord, right: ImportRecord): number { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column || + left.specifier.localeCompare(right.specifier) + ); +} + +function compareDiagnostics( + left: ArchitecturePolicyDiagnostic, + right: ArchitecturePolicyDiagnostic, +): number { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column || + left.code.localeCompare(right.code) || + left.ruleId.localeCompare(right.ruleId) || + left.importSpecifier.localeCompare(right.importSpecifier) + ); +} + +function toPosixPath(path: string): string { + return path.split("\\").join("/"); +} diff --git a/packages/architecture-policy/src/tests/ArchitecturePolicy.spec.ts b/packages/architecture-policy/src/tests/ArchitecturePolicy.spec.ts new file mode 100644 index 000000000..32b75dbfe --- /dev/null +++ b/packages/architecture-policy/src/tests/ArchitecturePolicy.spec.ts @@ -0,0 +1,592 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { ProblemCategory } from "@croco/problems-core"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + ARCHITECTURE_POLICY_SCHEMA_VERSION, + ArchitecturePolicyManifestJsonParseProblem, + ArchitecturePolicyManifestSchemaVersionProblem, + ArchitecturePolicyManifestShapeProblem, + ArchitecturePolicyPackageJsonParseProblem, + checkArchitecturePolicy, + formatArchitecturePolicyDiagnostic, + parseArchitecturePolicyManifest, + type ArchitecturePolicyManifest, +} from "../index.js"; + +const tempRepos: string[] = []; + +describe("architecture policy engine", () => { + afterEach(() => { + for (const repo of tempRepos.splice(0)) { + rmSync(repo, { force: true, recursive: true }); + } + }); + + it("flags forbidden source imports with deterministic source locations", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core"); + writePackage(repo, "packages/tx-drizzle", "@croco/tx-drizzle"); + writeFile( + repo, + "packages/repository-core/src/index.ts", + [ + 'import { Problem } from "@croco/problems-core";', + 'import { AbstractDrizzleRepository } from "@croco/tx-drizzle";', + "export const value = Problem ?? AbstractDrizzleRepository;", + "", + ].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: crocoManifest(), + }); + + expect(report.status).toBe("fail"); + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/forbidden-import", + file: "packages/repository-core/src/index.ts", + line: 2, + column: 44, + importSpecifier: "@croco/tx-drizzle", + sourcePackage: "@croco/repository-core", + sourceGroup: "framework", + targetPackage: "@croco/tx-drizzle", + targetGroup: "provider", + }), + ]); + expect(formatArchitecturePolicyDiagnostic(report.diagnostics[0])).toContain( + "ERROR architecture-policy/forbidden-import packages/repository-core/src/index.ts:2:44", + ); + }); + + it("flags forbidden multiline source imports", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core"); + writePackage(repo, "packages/tx-drizzle", "@croco/tx-drizzle"); + writeFile( + repo, + "packages/repository-core/src/index.ts", + ["import {", " AbstractDrizzleRepository,", '} from "@croco/tx-drizzle";', ""].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: crocoManifest(), + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/forbidden-import", + line: 3, + column: 9, + importSpecifier: "@croco/tx-drizzle", + }), + ]); + }); + + it("flags forbidden package manifest dependencies", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core", { + dependencies: { + "drizzle-orm": "^0.45.2", + }, + }); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: crocoManifest(), + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/forbidden-import", + file: "packages/repository-core/package.json", + importSpecifier: "drizzle-orm", + sourceKind: "package-manifest", + }), + ]); + }); + + it("enforces allowed group dependency edges for generated apps", () => { + const repo = createTempRepo(); + writePackage(repo, "apps/api-server", "@test/api-server"); + writePackage(repo, "libs/shared/provider-rpc", "@test/provider-rpc"); + writePackage(repo, "libs/shared/provider-database", "@test/provider-database"); + writeFile( + repo, + "apps/api-server/src/index.ts", + [ + 'import { client } from "@test/provider-rpc";', + 'import { db } from "@test/provider-database";', + "export const app = { client, db };", + "", + ].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: generatedAppManifest(), + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/disallowed-dependency-edge", + ruleId: "generated-app-layer-edges", + importSpecifier: "@test/provider-database", + sourceGroup: "app", + targetGroup: "provider", + }), + ]); + }); + + it("passes generated app imports that stay inside the declared policy preset", () => { + const repo = createTempRepo(); + writePackage(repo, "apps/api-server", "@test/api-server", { + dependencies: { + "@croco/protocols-rest": "^0.0.2", + "@test/provider-rpc": "workspace:*", + }, + }); + writePackage(repo, "libs/shared/provider-rpc", "@test/provider-rpc"); + writeFile( + repo, + "apps/api-server/src/index.ts", + [ + 'import { Controller } from "@croco/protocols-rest";', + 'import { client } from "@test/provider-rpc";', + "export const app = { Controller, client };", + "", + ].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: generatedAppManifest(), + }); + + expect(report).toMatchObject({ + status: "pass", + packageCount: 2, + }); + }); + + it("allows external imports without allowing unmapped internal packages", () => { + const repo = createTempRepo(); + writePackage(repo, "apps/api-server", "@test/api-server"); + writePackage(repo, "libs/shared/internal-utils", "@test/internal-utils"); + writeFile( + repo, + "apps/api-server/src/index.ts", + [ + 'import { randomUUID } from "node:crypto";', + 'import { value } from "@test/internal-utils";', + "export const app = { randomUUID, value };", + "", + ].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: generatedAppManifest(), + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/disallowed-dependency-edge", + ruleId: "generated-app-layer-edges", + importSpecifier: "@test/internal-utils", + sourceGroup: "app", + targetGroup: null, + }), + ]); + }); + + it("prioritizes explicit package groups over broader path groups", () => { + const repo = createTempRepo(); + writePackage(repo, "apps/api-server", "@test/api-server"); + writePackage(repo, "libs/shared/provider-rpc", "@test/provider-rpc"); + writeFile( + repo, + "apps/api-server/src/index.ts", + 'import { client } from "@test/provider-rpc";\nexport const app = client;\n', + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["apps", "libs"], + include: ["apps/*/src/**/*.ts", "libs/shared/*/src/**/*.ts"], + packageGroups: { + app: { + paths: ["apps/*"], + }, + "provider-contract": { + packages: ["@test/provider-rpc"], + }, + provider: { + paths: ["libs/shared/provider-*"], + }, + }, + rules: { + allowedGroupImports: [ + { + id: "generated-app-layer-edges", + fromGroups: ["app"], + allowGroups: ["provider-contract"], + }, + ], + }, + }, + }); + + expect(report.status).toBe("pass"); + }); + + it("requires every target matcher field to match", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core"); + writePackage(repo, "packages/tx-drizzle", "@croco/tx-drizzle"); + writeFile( + repo, + "packages/repository-core/src/index.ts", + 'import { tx } from "@croco/tx-drizzle";\nexport const value = tx;\n', + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + packageGroups: { + framework: { + packages: ["@croco/repository-core"], + }, + provider: { + packages: ["@croco/tx-drizzle"], + }, + }, + rules: { + forbiddenImports: [ + { + id: "package-and-specifier-must-both-match", + from: { + groups: ["framework"], + }, + to: { + packages: ["@croco/tx-drizzle"], + specifiers: ["drizzle-orm"], + }, + }, + ], + }, + }, + }); + + expect(report.status).toBe("pass"); + }); + + it("matches target paths against target package directories", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core"); + writePackage(repo, "packages/tx-drizzle", "@croco/tx-drizzle"); + writeFile( + repo, + "packages/repository-core/src/index.ts", + 'import { tx } from "@croco/tx-drizzle";\nexport const value = tx;\n', + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + packageGroups: { + framework: { + packages: ["@croco/repository-core"], + }, + provider: { + paths: ["packages/tx-*"], + }, + }, + rules: { + forbiddenImports: [ + { + id: "target-path-boundary", + from: { + groups: ["framework"], + }, + to: { + paths: ["packages/tx-*"], + }, + }, + ], + }, + }, + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/forbidden-import", + ruleId: "target-path-boundary", + targetPackage: "@croco/tx-drizzle", + }), + ]); + }); + + it("rejects private package entrypoint imports and accepts declared subpath exports", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/app", "@test/app"); + writePackage(repo, "packages/feature", "@test/feature", { + publishConfig: { + exports: { + ".": "./dist/index.js", + "./public": "./dist/public.js", + }, + }, + }); + writeFile( + repo, + "packages/app/src/index.ts", + [ + 'import { ok } from "@test/feature/public";', + 'import { leak } from "@test/feature/src/private";', + "export const value = { ok, leak };", + "", + ].join("\n"), + ); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + rules: { + publicEntrypoints: { + id: "public-entrypoints", + includePackages: ["@test/*"], + }, + }, + }, + }); + + expect(report.diagnostics).toEqual([ + expect.objectContaining({ + code: "architecture-policy/private-entrypoint-import", + importSpecifier: "@test/feature/src/private", + }), + ]); + }); + + it("sorts diagnostics by file and source location", () => { + const repo = createTempRepo(); + writePackage(repo, "packages/repository-core", "@croco/repository-core"); + writePackage(repo, "packages/tx-drizzle", "@croco/tx-drizzle"); + writeFile(repo, "packages/repository-core/src/z.ts", 'import "@croco/tx-drizzle";\n'); + writeFile(repo, "packages/repository-core/src/a.ts", 'import "drizzle-orm";\n'); + + const report = checkArchitecturePolicy({ + rootDir: repo, + manifest: crocoManifest(), + }); + + expect(report.diagnostics.map((diagnostic) => diagnostic.file)).toEqual([ + "packages/repository-core/src/a.ts", + "packages/repository-core/src/z.ts", + ]); + }); + + it("parses the manifest schema version explicitly", () => { + expect( + captureError(() => + parseArchitecturePolicyManifest( + JSON.stringify({ + schemaVersion: "wrong", + }), + ), + ), + ).toMatchObject({ + code: "architecture-policy/manifest-schema-version", + category: ProblemCategory.ValidationError, + detail: "Architecture policy manifest schemaVersion must be 'croco.architecture-policy/v1'.", + }); + expect( + captureError(() => + parseArchitecturePolicyManifest( + JSON.stringify({ + schemaVersion: "wrong", + }), + ), + ), + ).toBeInstanceOf(ArchitecturePolicyManifestSchemaVersionProblem); + }); + + it("parses manifest object shape failures as Problems", () => { + const error = captureError(() => parseArchitecturePolicyManifest("[]")); + + expect(error).toBeInstanceOf(ArchitecturePolicyManifestShapeProblem); + expect(error).toMatchObject({ + code: "architecture-policy/manifest-shape", + category: ProblemCategory.ValidationError, + detail: "Architecture policy manifest must be a JSON object.", + }); + }); + + it("parses manifest JSON syntax failures as Problems", () => { + const error = captureError(() => parseArchitecturePolicyManifest("{")); + + expect(error).toBeInstanceOf(ArchitecturePolicyManifestJsonParseProblem); + expect(error).toMatchObject({ + code: "architecture-policy/manifest-json-parse", + category: ProblemCategory.ValidationError, + detail: "Architecture policy manifest must contain valid JSON.", + }); + }); + + it("parses package JSON syntax failures as Problems", () => { + const repo = createTempRepo(); + writeFile(repo, "packages/repository-core/package.json", "{"); + + const error = captureError(() => + checkArchitecturePolicy({ + rootDir: repo, + manifest: crocoManifest(), + }), + ); + + expect(error).toBeInstanceOf(ArchitecturePolicyPackageJsonParseProblem); + expect(error).toMatchObject({ + code: "architecture-policy/package-json-parse", + category: ProblemCategory.ValidationError, + extensions: { + packageJsonPath: join(repo, "packages/repository-core/package.json"), + }, + }); + }); +}); + +function crocoManifest(): ArchitecturePolicyManifest { + return { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + packageGroups: { + framework: { + packages: ["@croco/*-core", "@croco/framework-*", "@croco/problems-core"], + }, + provider: { + packages: ["@croco/*-drizzle", "@croco/tx-drizzle"], + }, + }, + rules: { + forbiddenImports: [ + { + id: "core-to-provider-package", + from: { + groups: ["framework"], + }, + to: { + packages: ["@croco/*-drizzle", "@croco/tx-drizzle"], + }, + message: "Core/framework packages cannot import provider runtime implementations.", + }, + { + id: "core-to-provider-sdk", + from: { + groups: ["framework"], + }, + to: { + specifiers: ["drizzle-orm", "drizzle-orm/*"], + }, + message: "Core/framework packages cannot import provider runtime implementations.", + }, + ], + }, + }; +} + +function generatedAppManifest(): ArchitecturePolicyManifest { + return { + schemaVersion: ARCHITECTURE_POLICY_SCHEMA_VERSION, + packageRoots: ["apps", "libs"], + include: ["apps/*/src/**/*.ts", "libs/shared/*/src/**/*.ts"], + packageGroups: { + app: { + paths: ["apps/*"], + }, + provider: { + paths: ["libs/shared/provider-*"], + }, + protocol: { + packages: ["@croco/protocols-*"], + }, + }, + rules: { + allowedGroupImports: [ + { + id: "generated-app-layer-edges", + fromGroups: ["app"], + allowGroups: ["protocol"], + allowPackages: ["@test/provider-rpc"], + allowExternal: true, + message: "Generated app packages may import protocols and provider-rpc only.", + }, + ], + publicEntrypoints: { + id: "generated-app-public-entrypoints", + includePackages: ["@croco/*", "@test/*"], + }, + }, + }; +} + +function createTempRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "croco-architecture-policy-")); + tempRepos.push(repo); + return repo; +} + +function writePackage( + repo: string, + relativeDir: string, + packageName: string, + extra: Record = {}, +): void { + writeFile( + repo, + `${relativeDir}/package.json`, + `${JSON.stringify( + { + name: packageName, + ...extra, + }, + null, + 2, + )}\n`, + ); + writeFile(repo, `${relativeDir}/src/index.ts`, "export const value = 1;\n"); +} + +function writeFile(repo: string, relativePath: string, content: string): void { + const filePath = join(repo, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); +} + +function captureError(fn: () => void): unknown { + try { + fn(); + } catch (error) { + return error; + } + + expect.fail("Expected function to throw."); +} diff --git a/packages/architecture-policy/tsconfig.json b/packages/architecture-policy/tsconfig.json new file mode 100644 index 000000000..e4528fed0 --- /dev/null +++ b/packages/architecture-policy/tsconfig.json @@ -0,0 +1,9 @@ +{ + "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"], + "extends": "../../tsconfig/tsconfig.node.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f70f1596..688db3094 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@clack/prompts": "^0.9.1", + "@croco/architecture-policy": "workspace:*", "@croco/framework-context": "workspace:*", "@croco/framework-routes": "workspace:*", "@croco/migration-runner": "workspace:*", diff --git a/packages/cli/src/commands/architecturePolicy.ts b/packages/cli/src/commands/architecturePolicy.ts new file mode 100644 index 000000000..0f9da6a40 --- /dev/null +++ b/packages/cli/src/commands/architecturePolicy.ts @@ -0,0 +1,203 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve, win32 } from "node:path"; +import { defineCommand } from "citty"; +import { + checkArchitecturePolicy, + formatArchitecturePolicyDiagnostic, + parseArchitecturePolicyManifest, +} from "@croco/architecture-policy"; +import { GLOBAL_OPTIONS } from "./options.js"; +import type { ArchitecturePolicyReport } from "@croco/architecture-policy"; + +export type ArchitecturePolicyCheckIo = { + readonly stdout: (message: string) => void; + readonly stderr: (message: string) => void; + readonly readFile: (path: string) => string; + readonly cwd: string; +}; + +type ArchitecturePolicyCheckOptions = { + readonly manifest: string; + readonly root: string | null; + readonly json: boolean; +}; + +type ArchitecturePolicyCheckParseResult = + | { readonly kind: "help" } + | { readonly kind: "invalid"; readonly message: string } + | { readonly kind: "run"; readonly options: ArchitecturePolicyCheckOptions }; + +const defaultIo: ArchitecturePolicyCheckIo = { + stdout: (message) => console.log(message), + stderr: (message) => console.error(message), + readFile: (path) => readFileSync(path, "utf-8"), + cwd: process.cwd(), +}; + +export const architecturePolicyCheck = defineCommand({ + meta: { + name: "check", + description: "Validate static architecture policy manifests", + }, + args: { + ...GLOBAL_OPTIONS, + }, + async run({ rawArgs }) { + process.exitCode = await runArchitecturePolicyCheck(rawArgs); + }, +}); + +export const architecturePolicy = defineCommand({ + meta: { + name: "architecture-policy", + description: "Validate static architecture policy manifests", + }, + subCommands: { + check: architecturePolicyCheck, + }, +}); + +export async function runArchitecturePolicyCheck( + args: readonly string[], + options: { + readonly io?: Partial; + } = {}, +): Promise { + const parsed = parseArchitecturePolicyCheckArgs(args); + const io = { ...defaultIo, ...options.io }; + + if (parsed.kind === "help") { + printArchitecturePolicyCheckHelp(io); + return 0; + } + + if (parsed.kind === "invalid") { + io.stderr(parsed.message); + printArchitecturePolicyCheckHelp(io); + return 1; + } + + try { + const manifestPath = resolvePath(parsed.options.manifest, io.cwd); + const rootDir = resolvePath(parsed.options.root ?? ".", io.cwd); + const manifest = parseArchitecturePolicyManifest(io.readFile(manifestPath)); + const report = checkArchitecturePolicy({ rootDir, manifest }); + + if (parsed.options.json) { + printJsonReport(io, report); + } else if (report.diagnostics.length > 0) { + for (const diagnostic of report.diagnostics) { + io.stdout(formatArchitecturePolicyDiagnostic(diagnostic)); + if (diagnostic.recovery) { + io.stdout(` action: ${diagnostic.recovery}`); + } + io.stdout(` evidence: ${diagnostic.excerpt}`); + } + io.stdout(`Architecture policy check failed with ${report.diagnostics.length} error(s).`); + } else { + io.stdout( + `Architecture policy check passed for ${report.importCount} import(s) across ${report.packageCount} package(s).`, + ); + } + + return report.status === "pass" ? 0 : 1; + } catch (error) { + io.stderr(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +export function parseArchitecturePolicyCheckArgs( + args: readonly string[], +): ArchitecturePolicyCheckParseResult { + if (args.includes("--help") || args.includes("-h")) { + return { kind: "help" }; + } + + const consumedIndexes = getConsumedArgumentIndexes(args); + const manifest = getFlagValue(args, "--manifest"); + const root = getFlagValue(args, "--root"); + const positionalManifest = getFirstPosition(args, consumedIndexes); + const selectedManifest = manifest ?? positionalManifest; + + if (!selectedManifest) { + return { + kind: "invalid", + message: "Missing architecture policy manifest. Pass --manifest .", + }; + } + + return { + kind: "run", + options: { + manifest: selectedManifest, + root, + json: args.includes("--json"), + }, + }; +} + +function printArchitecturePolicyCheckHelp(io: ArchitecturePolicyCheckIo): void { + io.stdout(`Usage: croco architecture-policy check --manifest [--root ] [--json] + croco architecture-policy check [--root ] [--json] + +Options: + --manifest Architecture policy manifest + --root Repository or generated app root. Defaults to the current directory. + --json Print a stable JSON report + --help, -h Show this help message`); +} + +function printJsonReport(io: ArchitecturePolicyCheckIo, report: ArchitecturePolicyReport): void { + io.stdout(`${JSON.stringify(report, null, 2)}\n`); +} + +function getFlagValue(args: readonly string[], flag: string): string | null { + for (let index = args.length - 1; index >= 0; index -= 1) { + if (args[index] !== flag) { + continue; + } + + const value = args[index + 1]; + return value && !value.startsWith("-") ? value : null; + } + + return null; +} + +function getFirstPosition( + args: readonly string[], + consumedIndexes = new Set(), +): string | null { + return args.find((arg, index) => !consumedIndexes.has(index) && !arg.startsWith("-")) ?? null; +} + +function getConsumedArgumentIndexes(args: readonly string[]): Set { + const consumed = new Set(); + const valueFlags = new Set(["--manifest", "--root"]); + const booleanFlags = new Set(["--json"]); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (valueFlags.has(arg)) { + consumed.add(index); + + const value = args[index + 1]; + if (value && !value.startsWith("-")) { + consumed.add(index + 1); + } + continue; + } + + if (booleanFlags.has(arg)) { + consumed.add(index); + } + } + + return consumed; +} + +function resolvePath(path: string, cwd: string): string { + return isAbsolute(path) || win32.isAbsolute(path) ? path : resolve(cwd, path); +} diff --git a/packages/cli/src/commands/root.ts b/packages/cli/src/commands/root.ts index abddeca01..a4d698600 100644 --- a/packages/cli/src/commands/root.ts +++ b/packages/cli/src/commands/root.ts @@ -41,6 +41,11 @@ export function createCrocoCommand() { "Validate Croco contract graph artifacts", async () => (await import("./contracts.js")).contracts as LoadedCommand, ), + "architecture-policy": lazyCommand( + "architecture-policy", + "Validate Croco static architecture policy manifests", + async () => (await import("./architecturePolicy.js")).architecturePolicy as LoadedCommand, + ), di: lazyCommand( "di", "Validate Croco DI graph artifacts", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index eed70e288..0ba9caf0e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,6 +13,12 @@ export { confirmOverwrite, selectMode, textInput, NoTtyError } from "./libs/prom export { codegen } from "./commands/codegen"; export { codegenOpenapi } from "./commands/codegenOpenapi"; export { codegenRpc } from "./commands/codegenRpc"; +export { + architecturePolicy, + architecturePolicyCheck, + parseArchitecturePolicyCheckArgs, + runArchitecturePolicyCheck, +} from "./commands/architecturePolicy"; export { contracts } from "./commands/contracts"; export { contractsCheck, runContractsCheck } from "./commands/contractsCheck"; export { create } from "./commands/create"; @@ -65,6 +71,7 @@ export { export { GLOBAL_OPTIONS } from "./commands/options"; export type { DiCheckDiagnostic, DiCheckIo, DiCheckReport } from "./commands/diCheck"; +export type { ArchitecturePolicyCheckIo } from "./commands/architecturePolicy"; export type { DoctorCheckResult, diff --git a/packages/cli/src/tests/architecturePolicyCheck.spec.ts b/packages/cli/src/tests/architecturePolicyCheck.spec.ts new file mode 100644 index 000000000..1eb7656fe --- /dev/null +++ b/packages/cli/src/tests/architecturePolicyCheck.spec.ts @@ -0,0 +1,219 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { runArchitecturePolicyCheck } from "../commands/architecturePolicy.js"; + +const tempRepos: string[] = []; + +describe("architecturePolicyCheck", () => { + afterEach(() => { + for (const repo of tempRepos.splice(0)) { + rmSync(repo, { force: true, recursive: true }); + } + }); + + it("prints deterministic JSON failures", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await runArchitecturePolicyCheck(["--manifest", "croco.arch.json", "--json"], { + io: { + cwd: "/workspace/app", + readFile: () => + JSON.stringify({ + schemaVersion: "croco.architecture-policy/v1", + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + packageGroups: { + framework: { packages: ["@croco/*-core"] }, + }, + rules: { + forbiddenImports: [ + { + id: "core-to-drizzle", + from: { groups: ["framework"] }, + to: { specifiers: ["drizzle-orm", "drizzle-orm/*"] }, + }, + ], + }, + }), + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + }, + }); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(JSON.parse(stdout[0] ?? "{}")).toMatchObject({ + status: "pass", + packageCount: 0, + diagnostics: [], + }); + }); + + it("prints text failures with evidence and recovery", async () => { + const repo = createTempRepo(); + const stdout: string[] = []; + writeFile( + repo, + "croco.arch.json", + `${JSON.stringify( + { + schemaVersion: "croco.architecture-policy/v1", + packageRoots: ["packages"], + include: ["packages/*/src/**/*.ts"], + packageGroups: { + framework: { packages: ["@croco/*-core"] }, + }, + rules: { + forbiddenImports: [ + { + id: "core-to-provider", + from: { groups: ["framework"] }, + to: { specifiers: ["drizzle-orm"] }, + recovery: "Move provider code out of core packages.", + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + writeFile( + repo, + "packages/repository-core/package.json", + `${JSON.stringify({ name: "@croco/repository-core" }, null, 2)}\n`, + ); + writeFile( + repo, + "packages/repository-core/src/index.ts", + 'import { drizzle } from "drizzle-orm";\nexport const value = drizzle;\n', + ); + + const exitCode = await runArchitecturePolicyCheck(["croco.arch.json"], { + io: { + cwd: repo, + stdout: (message) => stdout.push(message), + }, + }); + + expect(exitCode).toBe(1); + expect(stdout).toEqual([ + expect.stringContaining( + "ERROR architecture-policy/forbidden-import packages/repository-core/src/index.ts:1:26", + ), + " action: Move provider code out of core packages.", + ' evidence: import { drizzle } from "drizzle-orm";', + "Architecture policy check failed with 1 error(s).", + ]); + }); + + it("reports missing manifests as usage errors", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await runArchitecturePolicyCheck([], { + io: { + cwd: "/workspace/app", + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + }, + }); + + expect(exitCode).toBe(1); + expect(stderr).toEqual(["Missing architecture policy manifest. Pass --manifest ."]); + expect(stdout[0]).toContain("Usage: croco architecture-policy check"); + }); + + it("does not treat --root values as positional manifests", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await runArchitecturePolicyCheck(["--root", "/workspace/app"], { + io: { + cwd: "/workspace", + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + }, + }); + + expect(exitCode).toBe(1); + expect(stderr).toEqual(["Missing architecture policy manifest. Pass --manifest ."]); + expect(stdout[0]).toContain("Usage: croco architecture-policy check"); + }); + + it("does not treat duplicate --root values as positional manifests", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await runArchitecturePolicyCheck( + ["--root", "/workspace/first", "--root", "/workspace/second"], + { + io: { + cwd: "/workspace", + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + }, + }, + ); + + expect(exitCode).toBe(1); + expect(stderr).toEqual(["Missing architecture policy manifest. Pass --manifest ."]); + expect(stdout[0]).toContain("Usage: croco architecture-policy check"); + }); + + it("preserves Windows absolute paths", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const readPaths: string[] = []; + + const exitCode = await runArchitecturePolicyCheck( + [ + "--manifest", + "C:\\workspace\\app\\croco.arch.json", + "--root", + "C:\\workspace\\app", + "--json", + ], + { + io: { + cwd: "/workspace", + readFile: (path) => { + readPaths.push(path); + return JSON.stringify({ + schemaVersion: "croco.architecture-policy/v1", + packageRoots: [], + include: ["packages/*/src/**/*.ts"], + rules: {}, + }); + }, + stdout: (message) => stdout.push(message), + stderr: (message) => stderr.push(message), + }, + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(readPaths).toEqual(["C:\\workspace\\app\\croco.arch.json"]); + expect(JSON.parse(stdout[0] ?? "{}")).toMatchObject({ + packageCount: 0, + status: "pass", + }); + }); +}); + +function createTempRepo(): string { + const repo = mkdtempSync(join(tmpdir(), "croco-cli-architecture-policy-")); + tempRepos.push(repo); + return repo; +} + +function writeFile(repo: string, relativePath: string, content: string): void { + const filePath = join(repo, relativePath); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index b12d73fee..778858a2e 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -7,6 +7,7 @@ const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { alias: { + "@croco/architecture-policy": resolve(currentDir, "../architecture-policy/src/index.ts"), "@croco/execution-core": resolve(currentDir, "../execution-core/src/index.ts"), "@croco/migration-runner": resolve(currentDir, "../migration-runner/src/index.ts"), "@croco/openapi-spec": resolve(currentDir, "../openapi-spec/src/index.ts"), diff --git a/packages/create-croco-app/src/generator.ts b/packages/create-croco-app/src/generator.ts index 1eace3e69..0e1df86be 100644 --- a/packages/create-croco-app/src/generator.ts +++ b/packages/create-croco-app/src/generator.ts @@ -203,6 +203,10 @@ function writeSaasProviderProfileArtifacts(targetDir: string, options: Generator join(targetDir, "croco-runtime-policy.manifest.json"), `${JSON.stringify(createRuntimePolicyManifest(manifest), null, 2)}\n`, ); + writeFileSync( + join(targetDir, "croco.arch.json"), + `${JSON.stringify(createArchitecturePolicyManifest(options), null, 2)}\n`, + ); writeFileSync(join(targetDir, ".env.example"), renderSaasEnvExample(manifest)); writeFileSync(join(docsDir, "provider-profile.md"), renderSaasDeployNotes(manifest)); writeFileSync(join(docsDir, "secrets-checklist.md"), renderSaasSecretsChecklist(manifest)); @@ -231,6 +235,148 @@ function createRuntimePolicyManifest( }; } +function createArchitecturePolicyManifest(options: GeneratorOptions): Record { + return { + schemaVersion: "croco.architecture-policy/v1", + policyName: `${options.projectName}-generated-app`, + packageRoots: ["apps", "libs"], + include: [ + "apps/*/src/**/*.ts", + "apps/*/src/**/*.tsx", + "libs/shared/*/src/**/*.ts", + "libs/shared/*/src/**/*.tsx", + ], + ignore: [ + "apps/*/src/**/__tests__/**", + "apps/*/src/**/tests/**", + "apps/*/src/**/*.spec.ts", + "apps/*/src/**/*.spec.tsx", + "apps/*/src/**/*.test.ts", + "apps/*/src/**/*.test.tsx", + "libs/shared/*/src/**/__tests__/**", + "libs/shared/*/src/**/tests/**", + "libs/shared/*/src/**/*.spec.ts", + "libs/shared/*/src/**/*.spec.tsx", + "libs/shared/*/src/**/*.test.ts", + "libs/shared/*/src/**/*.test.tsx", + ], + packageGroups: { + app: { + description: "Generated application entrypoints.", + paths: ["apps/*"], + }, + "provider-contract": { + description: "Generated RPC provider contract package.", + packages: [`${options.scope}/provider-rpc`], + }, + provider: { + description: "Generated provider adapter packages.", + paths: ["libs/shared/provider-*"], + }, + framework: { + description: "Croco framework and domain contracts.", + packages: [ + "@croco/*-core", + "@croco/diagnostics-core", + "@croco/framework-*", + "@croco/llm-metering", + "@croco/problems-core", + "@croco/telemetry-api", + "@croco/tx-core", + ], + }, + protocols: { + description: "Croco protocol and generated contract tooling.", + packages: ["@croco/openapi-spec", "@croco/protocols-*", "@croco/rpc-codegen"], + }, + transports: { + description: "Croco runtime transports used by the generated app.", + packages: ["@croco/transports-*"], + }, + integrations: { + description: "Concrete provider/runtime integrations selected by the profile.", + packages: [ + "@croco/*-drizzle", + "@croco/*-qstash", + "@croco/*-upstash", + "@croco/auth-better-auth", + "@croco/auth-clerk", + "@croco/billing-polar", + "@croco/storage-*", + "@croco/telemetry-sdk-node", + "@croco/triggers-qstash", + "@croco/tx-drizzle", + ], + }, + tooling: { + description: "Build-time generated app tooling.", + packages: ["@croco/cli"], + }, + }, + rules: { + allowedGroupImports: [ + { + id: "generated-app-layer-edges", + description: + "Generated app packages can depend on Croco contracts, selected adapters, and the generated provider-rpc contract, but provider packages must not import app entrypoints.", + fromGroups: ["app"], + allowGroups: [ + "framework", + "protocols", + "transports", + "integrations", + "provider-contract", + "tooling", + ], + allowPackages: [`${options.scope}/provider-rpc`], + allowExternal: true, + message: + "Generated app entrypoints can import Croco contracts, selected adapters, and provider-rpc only.", + recovery: + "Move shared provider code into libs/shared and expose app-facing types through the provider-rpc package.", + }, + { + id: "generated-provider-layer-edges", + fromGroups: ["provider", "provider-contract"], + allowGroups: ["framework", "protocols"], + allowExternal: true, + message: "Generated provider packages cannot import app entrypoints.", + recovery: + "Keep provider packages reusable by depending only on Croco contracts, protocols, and external SDKs.", + }, + ], + publicEntrypoints: { + id: "generated-app-public-entrypoints", + description: + "Generated app packages import declared package entrypoints instead of source internals.", + includePackages: ["@croco/*", `${options.scope}/*`], + ignoreImports: [ + { + paths: [ + "apps/*/src/**/__tests__/**", + "apps/*/src/**/tests/**", + "apps/*/src/**/*.spec.ts", + "apps/*/src/**/*.spec.tsx", + "apps/*/src/**/*.test.ts", + "apps/*/src/**/*.test.tsx", + "libs/shared/*/src/**/__tests__/**", + "libs/shared/*/src/**/tests/**", + "libs/shared/*/src/**/*.spec.ts", + "libs/shared/*/src/**/*.spec.tsx", + "libs/shared/*/src/**/*.test.ts", + "libs/shared/*/src/**/*.test.tsx", + ], + specifiers: ["@croco/*/src/**", `${options.scope}/*/src/**`], + }, + ], + message: "Generated app code must import declared public package entrypoints.", + recovery: + "Export the required type or runtime surface from the package entrypoint before consuming it.", + }, + }, + }; +} + function writeSaasProviderPackageDependencies( targetDir: string, manifest: SaasProviderProfileManifest, diff --git a/packages/create-croco-app/src/tests/e2e-generation.spec.ts b/packages/create-croco-app/src/tests/e2e-generation.spec.ts index 2e597491e..9f5fefcb8 100644 --- a/packages/create-croco-app/src/tests/e2e-generation.spec.ts +++ b/packages/create-croco-app/src/tests/e2e-generation.spec.ts @@ -745,6 +745,8 @@ describe("E2E: generate()", () => { test: "turbo test", "demo:seed": "pnpm --filter @test/api-server demo:seed", "profile:check": "pnpm --filter @test/api-server profile:check", + "architecture-policy:check": + "NODE_PATH=./node_modules croco architecture-policy check --manifest croco.arch.json", "runtime-policy:check": "NODE_PATH=./node_modules croco runtime-policy check --manifest croco-runtime-policy.manifest.json", "project-map:write": @@ -753,7 +755,7 @@ describe("E2E: generate()", () => { "NODE_PATH=./node_modules croco project map --controllers 'apps/api-server/src/controllers/**/*.ts' --runtime-policy croco-runtime-policy.manifest.json --provider-profile croco-saas-profile.manifest.json --check --manifest croco.project-map.json", "profile:smoke:real": "pnpm --filter @test/api-server profile:smoke:real", "demo:smoke": - "pnpm profile:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter @test/api-server demo:smoke && pnpm --filter @test/api-server ops:smoke", + "pnpm profile:check && pnpm architecture-policy:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter @test/api-server demo:smoke && pnpm --filter @test/api-server ops:smoke", "ops:smoke": "pnpm --filter @test/api-server ops:smoke", }); expect(apiPackageJson.dependencies).toMatchObject({ @@ -779,7 +781,7 @@ describe("E2E: generate()", () => { "profile:smoke:real": "tsx src/provider-profile-check.ts --mode=real-provider", }); expect(apiPackageJson.devDependencies?.typedi).toBe("^0.10.0"); - expect(apiPackageJson.devDependencies?.["@croco/cli"]).toBe("^0.0.4"); + expect(apiPackageJson.devDependencies?.["@croco/cli"]).toMatch(/^\^[0-9]+\.[0-9]+\.[0-9]+$/); expect(apiPackageJson.scripts?.["ops:smoke"]).toBe("tsx src/demo/ops-smoke.ts"); expect(existsSync(join(testDir, "apps", "api-server", "src", "saasDemo.ts"))).toBe(true); expect(existsSync(join(testDir, "apps", "api-server", "src", "providerProfiles.ts"))).toBe( @@ -792,6 +794,7 @@ describe("E2E: generate()", () => { existsSync(join(testDir, "apps", "api-server", "src", "generatedSaasProviderProfile.ts")), ).toBe(true); expect(existsSync(join(testDir, "croco-saas-profile.manifest.json"))).toBe(true); + expect(existsSync(join(testDir, "croco.arch.json"))).toBe(true); expect(existsSync(join(testDir, "croco-runtime-policy.manifest.json"))).toBe(true); expect(existsSync(join(testDir, ".env.example"))).toBe(true); expect(existsSync(join(testDir, "docs", "provider-profile.md"))).toBe(true); @@ -802,6 +805,9 @@ describe("E2E: generate()", () => { const runtimePolicyManifest = JSON.parse( readFileSync(join(testDir, "croco-runtime-policy.manifest.json"), "utf8"), ); + const architecturePolicyManifest = JSON.parse( + readFileSync(join(testDir, "croco.arch.json"), "utf8"), + ); const envExample = readFileSync(join(testDir, ".env.example"), "utf8"); const providerProfileDocs = readFileSync(join(testDir, "docs", "provider-profile.md"), "utf8"); const generatedProfileSource = readFileSync( @@ -833,6 +839,22 @@ describe("E2E: generate()", () => { plans: [], }, }); + expect(architecturePolicyManifest).toMatchObject({ + schemaVersion: "croco.architecture-policy/v1", + policyName: "my-saas-generated-app", + packageRoots: ["apps", "libs"], + rules: { + allowedGroupImports: expect.arrayContaining([ + expect.objectContaining({ + id: "generated-app-layer-edges", + allowPackages: ["@test/provider-rpc"], + }), + ]), + publicEntrypoints: expect.objectContaining({ + id: "generated-app-public-entrypoints", + }), + }, + }); expect(profileManifest.packages).toEqual( expect.arrayContaining([ "@croco/transports-cloudflare-workers", @@ -941,7 +963,7 @@ describe("E2E: generate()", () => { "contract:verify": "pnpm contract:diff && pnpm contract:coverage && pnpm contract:openapi && pnpm contract:client && pnpm --filter @test/provider-rpc typecheck", "demo:smoke": - "pnpm profile:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter @test/api-server demo:smoke && pnpm --filter @test/api-server ops:smoke", + "pnpm profile:check && pnpm architecture-policy:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter @test/api-server demo:smoke && pnpm --filter @test/api-server ops:smoke", }); expect(manifest).toMatchObject({ schemaVersion: 1, diff --git a/packages/create-croco-app/src/tests/templates-build.spec.ts b/packages/create-croco-app/src/tests/templates-build.spec.ts index e30b21808..5cbe13690 100644 --- a/packages/create-croco-app/src/tests/templates-build.spec.ts +++ b/packages/create-croco-app/src/tests/templates-build.spec.ts @@ -418,11 +418,13 @@ function checkSaasStructure() { ), "demo:seed": expect.any(String), "profile:check": "pnpm --filter {{scope}}/api-server profile:check", + "architecture-policy:check": + "NODE_PATH=./node_modules croco architecture-policy check --manifest croco.arch.json", "runtime-policy:check": "NODE_PATH=./node_modules croco runtime-policy check --manifest croco-runtime-policy.manifest.json", "profile:smoke:real": "pnpm --filter {{scope}}/api-server profile:smoke:real", "demo:smoke": expect.stringMatching( - /profile:check[\s\S]*runtime-policy:check[\s\S]*contract:check[\s\S]*api-server demo:smoke/, + /profile:check[\s\S]*architecture-policy:check[\s\S]*runtime-policy:check[\s\S]*contract:check[\s\S]*api-server demo:smoke[\s\S]*api-server ops:smoke/, ), "ops:smoke": "pnpm --filter {{scope}}/api-server ops:smoke", typecheck: "turbo typecheck", diff --git a/packages/create-croco-app/templates/saas/package.json.hbs b/packages/create-croco-app/templates/saas/package.json.hbs index 400570086..585268301 100644 --- a/packages/create-croco-app/templates/saas/package.json.hbs +++ b/packages/create-croco-app/templates/saas/package.json.hbs @@ -16,10 +16,11 @@ "contract:client": "NODE_PATH=./node_modules node ./node_modules/@croco/rpc-codegen/dist/cli.js --controllers 'apps/api-server/src/controllers/**/*.ts' --out libs/shared/provider-rpc/src", "codegen": "pnpm contract:client", "profile:check": "pnpm --filter {{scope}}/api-server profile:check", + "architecture-policy:check": "NODE_PATH=./node_modules croco architecture-policy check --manifest croco.arch.json", "runtime-policy:check": "NODE_PATH=./node_modules croco runtime-policy check --manifest croco-runtime-policy.manifest.json", "profile:smoke:real": "pnpm --filter {{scope}}/api-server profile:smoke:real", "demo:seed": "pnpm --filter {{scope}}/api-server demo:seed", - "demo:smoke": "pnpm profile:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter {{scope}}/api-server demo:smoke && pnpm --filter {{scope}}/api-server ops:smoke", + "demo:smoke": "pnpm profile:check && pnpm architecture-policy:check && pnpm runtime-policy:check && pnpm contract:check && pnpm --filter {{scope}}/api-server demo:smoke && pnpm --filter {{scope}}/api-server ops:smoke", "ops:smoke": "pnpm --filter {{scope}}/api-server ops:smoke", "build": "turbo build", "test": "turbo test", diff --git a/packages/docs/src/content/docs/en/guides/getting-started.mdx b/packages/docs/src/content/docs/en/guides/getting-started.mdx index 4abd2a759..798d14ad2 100644 --- a/packages/docs/src/content/docs/en/guides/getting-started.mdx +++ b/packages/docs/src/content/docs/en/guides/getting-started.mdx @@ -221,4 +221,4 @@ Now that you have a running SaaS API, explore what Croco can do: - **[Events Core](/en/guides/events-core/)** — Build event-driven workflows with transactional consistency. - **[Retry Core](/en/guides/retry-core/)** — Add resilience with retries, backoff, and circuit breakers. - **[CLI & Generators](https://github.com/croco-dev/framework/tree/trunk/packages/create-croco-app)** — Scaffold new projects with presets for DDD API, fullstack, and more. -- **[Package Catalog](https://github.com/croco-dev/framework#readme)** — Browse all 108 packages by domain and maturity. +- **[Package Catalog](https://github.com/croco-dev/framework#readme)** — Browse all 109 packages by domain and maturity. diff --git a/packages/docs/src/content/docs/en/index.mdx b/packages/docs/src/content/docs/en/index.mdx index 1bed6e0bc..d5179c579 100644 --- a/packages/docs/src/content/docs/en/index.mdx +++ b/packages/docs/src/content/docs/en/index.mdx @@ -50,7 +50,7 @@ Every API exports as `app.lambdaHandler()`. Also runs on Docker, Cloudflare, and boundaries. [Deploy →](/en/guides/deployment-recipes/) - 108 packages organized by maturity: production, beta, alpha. [Browse + 109 packages organized by maturity: production, beta, alpha. [Browse →](https://github.com/croco-dev/framework#readme) diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 208705c91..17f8439e9 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 354 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. +This cookbook documents 358 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. ## Index @@ -30,6 +30,10 @@ This cookbook documents 354 public Croco Problem codes. The deterministic JSON r | [`API_KEY_EXPIRED`](#api-key-expired) | Unauthorized | 401 | not-retryable | safe-message | 1 | | [`API_KEY_NOT_FOUND`](#api-key-not-found) | NotFound | 404 | not-retryable | public | 1 | | [`API_KEY_REVOKED`](#api-key-revoked) | Unauthorized | 401 | not-retryable | safe-message | 1 | +| [`architecture-policy/manifest-json-parse`](#architecture-policy-manifest-json-parse) | ValidationError | 422 | not-retryable | public | 1 | +| [`architecture-policy/manifest-schema-version`](#architecture-policy-manifest-schema-version) | ValidationError | 422 | not-retryable | public | 1 | +| [`architecture-policy/manifest-shape`](#architecture-policy-manifest-shape) | ValidationError | 422 | not-retryable | public | 1 | +| [`architecture-policy/package-json-parse`](#architecture-policy-package-json-parse) | ValidationError | 422 | not-retryable | public | 1 | | [`audit-core/auditable-decorator-misuse`](#audit-core-auditable-decorator-misuse) | InternalServerError | 500 | conditional | operator-only | 1 | | [`audit/insert-failed`](#audit-insert-failed) | InternalServerError | 500 | conditional | operator-only | 1 | | [`auth-better-auth/invalid-session-payload`](#auth-better-auth-invalid-session-payload) | InternalServerError | 500 | conditional | operator-only | 1 | @@ -657,6 +661,74 @@ Sources: - `packages/auth-core/src/libs/problems/AuthProblems.ts:28:3` (problem-class) + + +## `architecture-policy/manifest-json-parse` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/architecture-policy/src/index.ts:19:5` (problem-constructor) + + + +## `architecture-policy/manifest-schema-version` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/architecture-policy/src/index.ts:30:5` (problem-constructor) + + + +## `architecture-policy/manifest-shape` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/architecture-policy/src/index.ts:9:5` (problem-constructor) + + + +## `architecture-policy/package-json-parse` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/architecture-policy/src/index.ts:46:5` (problem-constructor) + ## `audit-core/auditable-decorator-misuse` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86fa55ea4..2c9637e5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,6 +373,25 @@ importers: specifier: 4.0.16 version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + packages/architecture-policy: + dependencies: + '@croco/problems-core': + specifier: workspace:* + version: link:../problems-core + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.3 + tsup: + specifier: 8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.16 + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) + packages/audit-core: dependencies: '@croco/framework-context': @@ -680,6 +699,9 @@ importers: '@clack/prompts': specifier: ^0.9.1 version: 0.9.1 + '@croco/architecture-policy': + specifier: workspace:* + version: link:../architecture-policy '@croco/framework-context': specifier: workspace:* version: link:../framework-context diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 17a33368a..4fd8cbe56 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -1706,6 +1706,141 @@ ], "typeExports": [] }, + { + "packageName": "@croco/architecture-policy", + "relativeDir": "packages/architecture-policy", + "entrypoint": "packages/architecture-policy/src/index.ts", + "runtimeExports": [ + { + "name": "ARCHITECTURE_POLICY_SCHEMA_VERSION", + "exportKind": "declaration", + "source": null, + "declarationKind": "const" + }, + { + "name": "ArchitecturePolicyManifestJsonParseProblem", + "exportKind": "declaration", + "source": null, + "declarationKind": "class" + }, + { + "name": "ArchitecturePolicyManifestSchemaVersionProblem", + "exportKind": "declaration", + "source": null, + "declarationKind": "class" + }, + { + "name": "ArchitecturePolicyManifestShapeProblem", + "exportKind": "declaration", + "source": null, + "declarationKind": "class" + }, + { + "name": "ArchitecturePolicyPackageJsonParseProblem", + "exportKind": "declaration", + "source": null, + "declarationKind": "class" + }, + { + "name": "checkArchitecturePolicy", + "exportKind": "declaration", + "source": null, + "declarationKind": "function" + }, + { + "name": "formatArchitecturePolicyDiagnostic", + "exportKind": "declaration", + "source": null, + "declarationKind": "function" + }, + { + "name": "parseArchitecturePolicyManifest", + "exportKind": "declaration", + "source": null, + "declarationKind": "function" + }, + { + "name": "readArchitecturePolicyManifest", + "exportKind": "declaration", + "source": null, + "declarationKind": "function" + } + ], + "typeExports": [ + { + "name": "ArchitectureAllowedGroupImportRule", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitectureEntrypointIgnore", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitectureForbiddenImportRule", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitectureImportSourceKind", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyCheckOptions", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyDiagnostic", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyDiagnosticCode", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyManifest", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyMatcher", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyPackageGroup", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePolicyReport", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + }, + { + "name": "ArchitecturePublicEntrypointRule", + "exportKind": "declaration", + "source": null, + "declarationKind": "type" + } + ] + }, { "packageName": "@croco/audit-core", "relativeDir": "packages/audit-core", @@ -3283,6 +3418,18 @@ "relativeDir": "packages/cli", "entrypoint": "packages/cli/src/index.ts", "runtimeExports": [ + { + "name": "architecturePolicy", + "exportKind": "named", + "source": "./commands/architecturePolicy", + "declarationKind": "const" + }, + { + "name": "architecturePolicyCheck", + "exportKind": "named", + "source": "./commands/architecturePolicy", + "declarationKind": "const" + }, { "name": "codegen", "exportKind": "named", @@ -3517,6 +3664,12 @@ "source": "./commands/ops", "declarationKind": "const" }, + { + "name": "parseArchitecturePolicyCheckArgs", + "exportKind": "named", + "source": "./commands/architecturePolicy", + "declarationKind": "function" + }, { "name": "parseDiCheckArgs", "exportKind": "named", @@ -3535,6 +3688,12 @@ "source": "./libs/naming", "declarationKind": "function" }, + { + "name": "runArchitecturePolicyCheck", + "exportKind": "named", + "source": "./commands/architecturePolicy", + "declarationKind": "function" + }, { "name": "runContractsCheck", "exportKind": "named", @@ -3664,6 +3823,11 @@ } ], "typeExports": [ + { + "name": "ArchitecturePolicyCheckIo", + "exportKind": "named", + "source": "./commands/architecturePolicy" + }, { "name": "DiCheckDiagnostic", "exportKind": "named", diff --git a/scripts/architecture-policy-check.mts b/scripts/architecture-policy-check.mts new file mode 100644 index 000000000..932670f53 --- /dev/null +++ b/scripts/architecture-policy-check.mts @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +import { + checkArchitecturePolicy, + formatArchitecturePolicyDiagnostic, + readArchitecturePolicyManifest, +} from "../packages/architecture-policy/src/index.ts"; + +type Options = { + readonly manifest: string; + readonly rootDir: string; + readonly json: boolean; +}; + +function parseArgs(args: readonly string[]): Options { + let manifest = "croco.arch.json"; + let rootDir = process.cwd(); + let json = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === "--manifest") { + const value = readFlagValue(args, index, "--manifest"); + manifest = value; + index += 1; + continue; + } + + if (arg === "--root") { + const value = readFlagValue(args, index, "--root"); + rootDir = value; + index += 1; + continue; + } + + if (arg === "--json") { + json = true; + continue; + } + + throw new Error(`Unknown option: ${arg}`); + } + + return { + manifest, + rootDir, + json, + }; +} + +function readFlagValue(args: readonly string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a path`); + } + + return value; +} + +function main(): void { + const options = parseArgs(process.argv.slice(2)); + const manifest = readArchitecturePolicyManifest(options.manifest); + const report = checkArchitecturePolicy({ + rootDir: options.rootDir, + manifest, + }); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + for (const diagnostic of report.diagnostics) { + console.error(formatArchitecturePolicyDiagnostic(diagnostic)); + if (diagnostic.recovery) { + console.error(` action: ${diagnostic.recovery}`); + } + console.error(` evidence: ${diagnostic.excerpt}`); + } + } + + if (report.status === "fail") { + console.error(`architecture-policy: ${report.diagnostics.length} diagnostic(s)`); + process.exit(1); + } + + console.log( + `architecture-policy: passed for ${report.importCount} import(s) across ${report.packageCount} package(s)`, + ); +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`architecture-policy: failed: ${message}`); + process.exit(1); +} diff --git a/scripts/create-croco-app-generated-smoke.mts b/scripts/create-croco-app-generated-smoke.mts index c69b1e547..1d895856e 100644 --- a/scripts/create-croco-app-generated-smoke.mts +++ b/scripts/create-croco-app-generated-smoke.mts @@ -858,6 +858,7 @@ function getGeneratedSmokeRangeOverrides(): Record { "@croco/billing-core": `file:${packWorkspacePackage("@croco/billing-core", "billing-core", packDir)}`, "@croco/billing-polar": `file:${packWorkspacePackage("@croco/billing-polar", "billing-polar", packDir)}`, "@croco/cache-core": `file:${packWorkspacePackage("@croco/cache-core", "cache-core", packDir)}`, + "@croco/architecture-policy": `file:${packWorkspacePackage("@croco/architecture-policy", "architecture-policy", packDir)}`, "@croco/cli": `file:${packWorkspacePackage("@croco/cli", "cli", packDir)}`, "@croco/diagnostics-core": `file:${packWorkspacePackage("@croco/diagnostics-core", "diagnostics-core", packDir)}`, "@croco/execution-core": `file:${packWorkspacePackage("@croco/execution-core", "execution-core", packDir)}`, @@ -912,6 +913,7 @@ function getContractSmokeRangeOverrides(): Record { const packDir = join(smokeRoot, "contract-package-packs"); return { + "@croco/architecture-policy": `file:${packWorkspacePackage("@croco/architecture-policy", "architecture-policy", packDir)}`, "@croco/cli": `file:${packWorkspacePackage("@croco/cli", "cli", packDir)}`, "@croco/diagnostics-core": `file:${packWorkspacePackage("@croco/diagnostics-core", "diagnostics-core", packDir)}`, "@croco/events-core": `file:${packWorkspacePackage("@croco/events-core", "events-core", packDir)}`, diff --git a/scripts/package-entrypoint-smoke.mts b/scripts/package-entrypoint-smoke.mts index 72e6ea2e0..234c0e09f 100644 --- a/scripts/package-entrypoint-smoke.mts +++ b/scripts/package-entrypoint-smoke.mts @@ -422,12 +422,10 @@ function validateDeclaredTypeDependencies( const declaredDependencies = new Set(installDependencyNames(packageInfo.sourceManifest)); const declarationPath = join(packageInfo.packageDir, target.target); const declarationContent = stripComments(readFileSync(declarationPath, "utf-8")); - const importPattern = /\b(?:from|import)\s*\(?\s*["']([^"']+)["']/g; const packageName = packageInfo.packageName; const undeclaredDependencies = new Set(); - for (const match of declarationContent.matchAll(importPattern)) { - const specifier = match[1]; + for (const specifier of collectDeclarationImportSpecifiers(declarationContent)) { const dependencyName = packageNameFromSpecifier(specifier); if ( @@ -450,6 +448,25 @@ function validateDeclaredTypeDependencies( } } +function collectDeclarationImportSpecifiers(content: string): string[] { + const specifiers = new Set(); + const importDeclarationPattern = + /^\s*import(?:\s+type)?(?:\s+[^;]*?\s+from)?\s*["']([^"']+)["']/gm; + const exportDeclarationPattern = /^\s*export(?:\s+type)?\s+[^;]*?\s+from\s*["']([^"']+)["']/gm; + const importTypePattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; + + for (const pattern of [importDeclarationPattern, exportDeclarationPattern, importTypePattern]) { + for (const match of content.matchAll(pattern)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + } + + return Array.from(specifiers).sort(); +} + function stripComments(content: string): string { return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } diff --git a/scripts/package-quality-report.mts b/scripts/package-quality-report.mts index 7cc43e1c6..f7bc3a489 100644 --- a/scripts/package-quality-report.mts +++ b/scripts/package-quality-report.mts @@ -770,7 +770,7 @@ export function buildReportMarkdown(report: PackageQualityReport): string { "| Gate | Scope | CI mode | Current outcome | Evidence |", "| --- | --- | --- | --- | --- |", `| \`changeset-required:check\` | publishable package behavior changes | blocking on PR | ${report.gateOutcomes["changeset-required:check"]} | links public package changes to a required non-README changeset |`, - `| \`pnpm check\` | repository policy, lint, format, dependency boundaries, strict contract checks, static misuse checks, public API drift | blocking on PR/trunk | ${report.gateOutcomes["pnpm check"]} | includes \`dependency-boundaries:check\`, \`strict-contract-typecheck\`, \`static-misuse:check\`, and \`public-api:check\` |`, + `| \`pnpm check\` | repository policy, lint, format, architecture policy, dependency boundaries, strict contract checks, static misuse checks, public API drift | blocking on PR/trunk | ${report.gateOutcomes["pnpm check"]} | includes \`architecture-policy:check\`, \`dependency-boundaries:check\`, \`strict-contract-typecheck\`, \`static-misuse:check\`, and \`public-api:check\` |`, `| \`public-api:check\` | package public export surface drift | blocking through \`pnpm check\` | ${formatPublicApiStatus(report.publicApi)} | ${formatPublicApiEvidence(report.publicApi)} |`, `| \`build\` | package build tasks | blocking on PR/trunk | ${report.gateOutcomes.build} | Turbo \`build\` summary below |`, `| \`typecheck\` | package TypeScript tasks | blocking on PR/trunk | ${report.gateOutcomes.typecheck} | Turbo \`typecheck\` summary below |`, @@ -807,7 +807,7 @@ export function buildReportMarkdown(report: PackageQualityReport): string { ), "", "## Trunk gate rollout", - "- Current blocking gates: changeset-required, dependency-boundaries, static-misuse, lint/format/policy checks, build, typecheck, and test.", + "- Current blocking gates: changeset-required, architecture-policy, dependency-boundaries, static-misuse, lint/format/policy checks, build, typecheck, and test.", "- Current advisory gates: production audit on PRs, core coverage baseline warnings, benchmark warnings, and future bundle-size warnings.", "- Promote warning-only gates only after the dashboard shows stable package-level ownership, no unknown package rows, and documented baselines.", "- New packages should appear in this dashboard with explicit build/typecheck/test support or an intentional not-configured state.", diff --git a/scripts/tests/package-entrypoint-smoke.spec.ts b/scripts/tests/package-entrypoint-smoke.spec.ts index 47c72ae04..6b787772b 100644 --- a/scripts/tests/package-entrypoint-smoke.spec.ts +++ b/scripts/tests/package-entrypoint-smoke.spec.ts @@ -111,6 +111,19 @@ describe("package-entrypoint-smoke.mts", () => { expect(result.status).toBe(1); expect(`${result.stdout}\n${result.stderr}`).toContain("@croco-smoke/missing-types"); }); + + it("does not treat diagnostic code string literals as type dependency imports", () => { + const root = createTempRoot(); + writeImportablePackage(root, "diagnostic-codes", { + declarationContent: + 'type DiagnosticCode = "architecture-policy/forbidden-import" | "architecture-policy/private-entrypoint-import";\nexport type Value = { readonly code: DiagnosticCode };\n', + }); + + const result = runScript(root); + + expect(result.status).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("undeclared type dependency"); + }); }); function createTempRoot(): string {