diff --git a/.changeset/52b493b7.md b/.changeset/52b493b7.md
new file mode 100644
index 000000000..3d6673719
--- /dev/null
+++ b/.changeset/52b493b7.md
@@ -0,0 +1,5 @@
+---
+"@croco/docs": patch
+---
+
+- fix: publish extension compatibility matrix
diff --git a/README.md b/README.md
index 046532b58..2846089cc 100644
--- a/README.md
+++ b/README.md
@@ -359,6 +359,70 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기
| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 31 |
| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 |
+### Extension & Adapter Matrix
+
+> 이 섹션은 `docs/package-catalog.json`의 `extensionMatrix` metadata에서 생성됩니다. 성숙도와 package test 존재 여부는 별도 열로 표시합니다.
+
+Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수, Workers는 Cloudflare Workers, Frontend는 browser/SSR frontend integration을 의미합니다.
+
+#### Provider
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| -------------------------------- | --------------- | ------------------------------------ | ---- | ------ | ------- | -------- | ----------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------- | ------------ | ----------------- |
+| `@croco/access-drizzle` | Access control | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | permission checks
policy storage | 🔴 alpha/WIP | has package tests |
+| `@croco/audit-drizzle` | Audit | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | audit event persistence
tenant audit lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-better-auth` | Auth | Better Auth + Drizzle provider | yes | yes | - | - | BETTER_AUTH_URL
BETTER_AUTH_SECRET
BETTER_AUTH_WEBHOOK_SECRET optional | - | session auth
webhooks
Drizzle schema | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-clerk` | Auth | Clerk Backend provider | yes | yes | - | - | CLERK_SECRET_KEY
CLERK_PUBLISHABLE_KEY optional
CLERK_WEBHOOK_SECRET optional | - | token auth
session management
organizations
webhooks | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-drizzle` | Auth | Drizzle store | yes | yes | - | - | database connection supplied by app | drizzle-orm | API key store
role registry | 🔴 alpha/WIP | has package tests |
+| `@croco/batch-qstash` | Batch | QStash chunk executor | yes | yes | yes | - | QSTASH_TOKEN
public webhook URL | - | chunk scheduling
checkpoint resume
idempotent publish | 🔴 alpha/WIP | has package tests |
+| `@croco/billing-polar` | Billing | Polar billing gateway | yes | yes | - | - | POLAR_ACCESS_TOKEN
POLAR_WEBHOOK_SECRET
POLAR_ORGANIZATION_ID optional | - | checkout
webhooks
subscription lifecycle
customer portal | 🟡 beta | has package tests |
+| `@croco/customer-health-drizzle` | Customer health | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | health score persistence
customer health lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/entitlements-drizzle` | Entitlements | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | entitlement persistence
billing entitlement lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/execution-drizzle` | Execution | Drizzle execution store | yes | yes | - | - | database connection supplied by app | drizzle-orm | execution state persistence
retryable failure records | 🔴 alpha/WIP | has package tests |
+| `@croco/invitation-drizzle` | Invitation | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | invitation persistence
transaction-aware repository | 🔴 alpha/WIP | has package tests |
+| `@croco/membership-drizzle` | Membership | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | membership persistence
transaction-aware repository | 🔴 alpha/WIP | has package tests |
+| `@croco/metering-drizzle` | Metering | Drizzle usage store | yes | yes | - | - | database connection supplied by app | - | usage persistence
quota lookup
migration scripts | 🔴 alpha/WIP | has package tests |
+| `@croco/metering-upstash` | Metering | Upstash Redis client adapter | yes | yes | yes | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | - | Redis command adapter
serverless usage storage | 🔴 alpha/WIP | has package tests |
+| `@croco/metrics-billing` | Metrics | Billing metrics bridge | yes | yes | yes | - | none | - | billing event metrics
usage aggregation bridge | 🔴 alpha/WIP | has package tests |
+| `@croco/notifications-resend` | Notifications | Resend email provider | yes | yes | - | - | RESEND_API_KEY
default from address | - | email send
template send
retry
idempotency key | 🔴 alpha/WIP | has package tests |
+| `@croco/onboarding-drizzle` | Onboarding | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | onboarding state persistence
step completion storage | 🔴 alpha/WIP | has package tests |
+| `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | yes | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity | 🔴 alpha/WIP | has package tests |
+| `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry | 🟡 beta | has package tests |
+| `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | yes | - | QSTASH_TOKEN
destination webhook URL | - | task publish
delay override
custom headers | 🔴 alpha/WIP | has package tests |
+| `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | yes | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch | 🔴 alpha/WIP | has package tests |
+
+#### Integration
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| ----------------------------- | ------------- | ------------------------------ | ---- | ------ | ------- | -------- | ----------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------ | ------------------- | ----------------- |
+| `@croco/analytics-posthog` | Analytics | PostHog analytics provider | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | event capture
user/group analytics | 🔴 alpha/WIP | has package tests |
+| `@croco/features-posthog` | Feature flags | PostHog feature provider | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | feature flag lookup
PostHog client reuse | 🟡 beta | has package tests |
+| `@croco/integrations-posthog` | PostHog | Shared PostHog client | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | client lifecycle
capture flush
diagnostics | 🟡 beta | has package tests |
+| `@croco/telemetry-api` | Telemetry | OpenTelemetry application API | yes | yes | yes | - | none | - | Trace decorator
withSpan
recordError
trace context lookup | 🟢 production-ready | has package tests |
+| `@croco/telemetry-sdk-node` | Telemetry | OpenTelemetry Node SDK runtime | yes | yes | - | - | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT
TELEMETRY_ENABLED optional | - | SDK init
Lambda preset
OTLP export
forceFlush | 🟢 production-ready | has package tests |
+
+#### Transport
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| -------------------------------------- | ----------------- | -------------------------- | ---- | ------ | ------- | -------- | --------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------ | ------------------- | ----------------- |
+| `@croco/transports-graphql` | GraphQL transport | GraphQL Yoga transport | yes | yes | - | - | none | - | GraphQL server
resolver execution
Problem mapping | 🟡 beta | has package tests |
+| `@croco/transports-cloudflare-workers` | HTTP transport | Cloudflare Workers adapter | - | - | yes | - | Cloudflare Worker env object supplied by platform | - | Worker fetch adapter
request context bridge | 🔴 alpha/WIP | has package tests |
+| `@croco/transports-http` | HTTP transport | Hono HTTP/Lambda transport | yes | yes | - | - | CROCO_DIAGNOSTICS_TOKEN optional
CROCO_HTTP_SECURITY_VALIDATION optional | - | REST route execution
Lambda adapter
operational endpoints
diagnostics | 🟢 production-ready | has package tests |
+
+#### Presentation
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| ---------------------------- | ------------------- | ----------------------------------- | ---- | ------ | ------- | -------- | ------------------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------- | ------------ | ----------------- |
+| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration | 🔴 alpha/WIP | has package tests |
+| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR adapters | 🟡 beta | has package tests |
+| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | SSR fetch handler
service binding routing
asset fallback | 🔴 alpha/WIP | has package tests |
+| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility | 🔴 alpha/WIP | has package tests |
+| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support | 🟡 beta | has package tests |
+
### 🟢 production-ready
| 패키지 | 그룹 | 디렉터리 | 문서 |
@@ -473,7 +537,7 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기
### Documentation Gate
-- `pnpm docs:catalog:check`는 README 카탈로그와 문서 커버리지 리포트 drift를 검증합니다.
+- `pnpm docs:catalog:check`는 README 카탈로그, extension matrix reference 문서, 문서 커버리지 리포트 drift를 검증합니다.
- 신규 public package는 `docs/package-catalog.json`에 그룹/성숙도 metadata가 있어야 합니다.
- 신규 public package의 README, API docs, tests 누락은 `docs/package-docs-baseline.json`에 없는 한 실패합니다.
diff --git a/docs/package-catalog.json b/docs/package-catalog.json
index 2e1052ccd..384fbd7aa 100644
--- a/docs/package-catalog.json
+++ b/docs/package-catalog.json
@@ -252,5 +252,299 @@
"label": "⚠️ deprecated",
"packages": []
}
+ },
+ "extensionMatrix": {
+ "groups": ["Provider", "Integration", "Transport", "Presentation"],
+ "packages": {
+ "access-drizzle": {
+ "domain": "Access control",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["permission checks", "policy storage"]
+ },
+ "analytics-posthog": {
+ "domain": "Analytics",
+ "adapter": "PostHog analytics provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["POSTHOG_API_KEY", "POSTHOG_HOST optional"],
+ "features": ["event capture", "user/group analytics"]
+ },
+ "audit-drizzle": {
+ "domain": "Audit",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["audit event persistence", "tenant audit lookup"]
+ },
+ "auth-better-auth": {
+ "domain": "Auth",
+ "adapter": "Better Auth + Drizzle provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": [
+ "BETTER_AUTH_URL",
+ "BETTER_AUTH_SECRET",
+ "BETTER_AUTH_WEBHOOK_SECRET optional"
+ ],
+ "features": ["session auth", "webhooks", "Drizzle schema"]
+ },
+ "auth-clerk": {
+ "domain": "Auth",
+ "adapter": "Clerk Backend provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": [
+ "CLERK_SECRET_KEY",
+ "CLERK_PUBLISHABLE_KEY optional",
+ "CLERK_WEBHOOK_SECRET optional"
+ ],
+ "features": ["token auth", "session management", "organizations", "webhooks"]
+ },
+ "auth-drizzle": {
+ "domain": "Auth",
+ "adapter": "Drizzle store",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["API key store", "role registry"]
+ },
+ "batch-qstash": {
+ "domain": "Batch",
+ "adapter": "QStash chunk executor",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["QSTASH_TOKEN", "public webhook URL"],
+ "features": ["chunk scheduling", "checkpoint resume", "idempotent publish"]
+ },
+ "billing-polar": {
+ "domain": "Billing",
+ "adapter": "Polar billing gateway",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": [
+ "POLAR_ACCESS_TOKEN",
+ "POLAR_WEBHOOK_SECRET",
+ "POLAR_ORGANIZATION_ID optional"
+ ],
+ "features": ["checkout", "webhooks", "subscription lifecycle", "customer portal"]
+ },
+ "customer-health-drizzle": {
+ "domain": "Customer health",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["health score persistence", "customer health lookup"]
+ },
+ "entitlements-drizzle": {
+ "domain": "Entitlements",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["entitlement persistence", "billing entitlement lookup"]
+ },
+ "execution-drizzle": {
+ "domain": "Execution",
+ "adapter": "Drizzle execution store",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["execution state persistence", "retryable failure records"]
+ },
+ "features-posthog": {
+ "domain": "Feature flags",
+ "adapter": "PostHog feature provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["POSTHOG_API_KEY", "POSTHOG_HOST optional"],
+ "features": ["feature flag lookup", "PostHog client reuse"]
+ },
+ "frontend-cloudflare": {
+ "domain": "Frontend SSR",
+ "adapter": "Cloudflare SSR handler",
+ "runtimes": ["cloudflare-workers"],
+ "requiredEnv": ["API_WORKER binding optional", "ASSETS binding optional"],
+ "features": ["SSR fetch handler", "service binding routing", "asset fallback"]
+ },
+ "frontend-react": {
+ "domain": "Frontend React",
+ "adapter": "React integration helpers",
+ "runtimes": ["browser", "node"],
+ "requiredEnv": ["none"],
+ "features": ["React bindings", "meta-vite integration"]
+ },
+ "frontend-vite": {
+ "domain": "Frontend Vite",
+ "adapter": "Vite integration helpers",
+ "runtimes": ["node", "browser", "cloudflare-workers"],
+ "requiredEnv": ["none"],
+ "features": ["Vite config helpers", "Cloudflare Vite compatibility"]
+ },
+ "integrations-posthog": {
+ "domain": "PostHog",
+ "adapter": "Shared PostHog client",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["POSTHOG_API_KEY", "POSTHOG_HOST optional"],
+ "features": ["client lifecycle", "capture flush", "diagnostics"]
+ },
+ "invitation-drizzle": {
+ "domain": "Invitation",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["invitation persistence", "transaction-aware repository"]
+ },
+ "membership-drizzle": {
+ "domain": "Membership",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["membership persistence", "transaction-aware repository"]
+ },
+ "meta-vite": {
+ "domain": "Frontend routing",
+ "adapter": "Meta Vite runtime",
+ "runtimes": ["node", "lambda", "cloudflare-workers", "browser"],
+ "requiredEnv": ["optional Redis-compatible ISR adapter config"],
+ "features": ["route registry", "server actions", "SSR/RSC streaming", "ISR adapters"]
+ },
+ "metering-drizzle": {
+ "domain": "Metering",
+ "adapter": "Drizzle usage store",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["usage persistence", "quota lookup", "migration scripts"]
+ },
+ "metering-upstash": {
+ "domain": "Metering",
+ "adapter": "Upstash Redis client adapter",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
+ "features": ["Redis command adapter", "serverless usage storage"]
+ },
+ "metrics-billing": {
+ "domain": "Metrics",
+ "adapter": "Billing metrics bridge",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["none"],
+ "features": ["billing event metrics", "usage aggregation bridge"]
+ },
+ "notifications-resend": {
+ "domain": "Notifications",
+ "adapter": "Resend email provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["RESEND_API_KEY", "default from address"],
+ "features": ["email send", "template send", "retry", "idempotency key"]
+ },
+ "onboarding-drizzle": {
+ "domain": "Onboarding",
+ "adapter": "Drizzle repository",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["onboarding state persistence", "step completion storage"]
+ },
+ "presentation-preset": {
+ "domain": "Presentation preset",
+ "adapter": "Backend/frontend preset composition",
+ "runtimes": ["node", "lambda", "cloudflare-workers", "browser"],
+ "requiredEnv": ["none"],
+ "features": ["preset composition", "contract wiring", "generated app support"]
+ },
+ "ratelimit-upstash": {
+ "domain": "Rate limiting",
+ "adapter": "Upstash Redis rate-limit store",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
+ "features": ["sliding window", "token bucket", "fixed window", "Lua atomicity"]
+ },
+ "search-drizzle": {
+ "domain": "Search",
+ "adapter": "Drizzle search index",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["database connection supplied by app"],
+ "features": ["search document persistence", "tenant-aware lookup"]
+ },
+ "search-meilisearch": {
+ "domain": "Search",
+ "adapter": "Meilisearch engine",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["MEILISEARCH_HOST", "MEILISEARCH_API_KEY"],
+ "features": ["indexing", "search", "tenant tokens"]
+ },
+ "storage-cloudflare": {
+ "domain": "Storage",
+ "adapter": "Cloudflare Images provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_HASH"],
+ "features": ["image upload", "transform URLs", "upload intents", "signed URLs"]
+ },
+ "storage-cloudinary": {
+ "domain": "Storage",
+ "adapter": "Cloudinary provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET"],
+ "features": ["file upload", "transform URLs", "upload intents", "retry"]
+ },
+ "storage-r2": {
+ "domain": "Storage",
+ "adapter": "Cloudflare R2 S3-compatible provider",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_BUCKET"],
+ "features": ["put/get/delete", "signed URLs", "stream reads", "retry"]
+ },
+ "tasks-qstash": {
+ "domain": "Tasks",
+ "adapter": "QStash task runner",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["QSTASH_TOKEN", "destination webhook URL"],
+ "features": ["task publish", "delay override", "custom headers"]
+ },
+ "telemetry-api": {
+ "domain": "Telemetry",
+ "adapter": "OpenTelemetry application API",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["none"],
+ "features": ["Trace decorator", "withSpan", "recordError", "trace context lookup"]
+ },
+ "telemetry-sdk-node": {
+ "domain": "Telemetry",
+ "adapter": "OpenTelemetry Node SDK runtime",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": [
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT",
+ "TELEMETRY_ENABLED optional"
+ ],
+ "features": ["SDK init", "Lambda preset", "OTLP export", "forceFlush"]
+ },
+ "transports-cloudflare-workers": {
+ "domain": "HTTP transport",
+ "adapter": "Cloudflare Workers adapter",
+ "runtimes": ["cloudflare-workers"],
+ "requiredEnv": ["Cloudflare Worker env object supplied by platform"],
+ "features": ["Worker fetch adapter", "request context bridge"]
+ },
+ "transports-graphql": {
+ "domain": "GraphQL transport",
+ "adapter": "GraphQL Yoga transport",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": ["none"],
+ "features": ["GraphQL server", "resolver execution", "Problem mapping"]
+ },
+ "transports-http": {
+ "domain": "HTTP transport",
+ "adapter": "Hono HTTP/Lambda transport",
+ "runtimes": ["node", "lambda"],
+ "requiredEnv": [
+ "CROCO_DIAGNOSTICS_TOKEN optional",
+ "CROCO_HTTP_SECURITY_VALIDATION optional"
+ ],
+ "features": [
+ "REST route execution",
+ "Lambda adapter",
+ "operational endpoints",
+ "diagnostics"
+ ]
+ },
+ "triggers-qstash": {
+ "domain": "Triggers",
+ "adapter": "QStash scheduler and webhook handler",
+ "runtimes": ["node", "lambda", "cloudflare-workers"],
+ "requiredEnv": ["QSTASH_TOKEN", "public webhook URL"],
+ "features": ["schedule publish", "webhook verification", "trigger dispatch"]
+ }
+ }
}
}
diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md
index 946a6d1b4..c8cc5bfc5 100644
--- a/docs/package-docs-report.md
+++ b/docs/package-docs-report.md
@@ -11,6 +11,7 @@
| Missing package README | 23 |
| Missing generated API docs | 84 |
| Missing package test directory | 0 |
+| Extension matrix packages | 38 |
New public packages must not add missing README, API docs, or test coverage unless the gap is explicitly listed in `docs/package-docs-baseline.json`.
@@ -150,3 +151,14 @@ None.
| 🟡 beta | 43 |
| 🔴 alpha/WIP | 31 |
| ⚠️ deprecated | 0 |
+
+## Extension Matrix
+
+Extension matrix metadata is maintained in `docs/package-catalog.json` and rendered to the root README plus the docs reference page.
+
+| Group | Packages | Without package tests |
+| ------------ | -------: | --------------------: |
+| Provider | 25 | 0 |
+| Integration | 5 | 0 |
+| Transport | 3 | 0 |
+| Presentation | 5 | 0 |
diff --git a/packages/docs/src/content/docs/en/reference/extension-matrix.md b/packages/docs/src/content/docs/en/reference/extension-matrix.md
new file mode 100644
index 000000000..96ea119dd
--- /dev/null
+++ b/packages/docs/src/content/docs/en/reference/extension-matrix.md
@@ -0,0 +1,70 @@
+---
+title: Extension Matrix
+description: Official Croco provider and adapter compatibility matrix.
+---
+
+# Extension Matrix
+
+> Generated by `pnpm docs:catalog:write`. Do not edit this file by hand.
+
+This page lists Croco provider, integration, transport, and presentation adapter compatibility from `docs/package-catalog.json`. Required configuration, runtime support, package peer dependencies, maturity, and package test presence are intentionally separate so users can evaluate production readiness without treating a passing unit test as a maturity claim.
+
+Runtime columns: Node covers long-running server and CLI use, Lambda covers serverless functions, Workers covers Cloudflare Workers, and Frontend covers browser or SSR frontend integration.
+
+## Provider
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| -------------------------------- | --------------- | ------------------------------------ | ---- | ------ | ------- | -------- | ----------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------- | ------------ | ----------------- |
+| `@croco/access-drizzle` | Access control | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | permission checks
policy storage | 🔴 alpha/WIP | has package tests |
+| `@croco/audit-drizzle` | Audit | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | audit event persistence
tenant audit lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-better-auth` | Auth | Better Auth + Drizzle provider | yes | yes | - | - | BETTER_AUTH_URL
BETTER_AUTH_SECRET
BETTER_AUTH_WEBHOOK_SECRET optional | - | session auth
webhooks
Drizzle schema | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-clerk` | Auth | Clerk Backend provider | yes | yes | - | - | CLERK_SECRET_KEY
CLERK_PUBLISHABLE_KEY optional
CLERK_WEBHOOK_SECRET optional | - | token auth
session management
organizations
webhooks | 🔴 alpha/WIP | has package tests |
+| `@croco/auth-drizzle` | Auth | Drizzle store | yes | yes | - | - | database connection supplied by app | drizzle-orm | API key store
role registry | 🔴 alpha/WIP | has package tests |
+| `@croco/batch-qstash` | Batch | QStash chunk executor | yes | yes | yes | - | QSTASH_TOKEN
public webhook URL | - | chunk scheduling
checkpoint resume
idempotent publish | 🔴 alpha/WIP | has package tests |
+| `@croco/billing-polar` | Billing | Polar billing gateway | yes | yes | - | - | POLAR_ACCESS_TOKEN
POLAR_WEBHOOK_SECRET
POLAR_ORGANIZATION_ID optional | - | checkout
webhooks
subscription lifecycle
customer portal | 🟡 beta | has package tests |
+| `@croco/customer-health-drizzle` | Customer health | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | health score persistence
customer health lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/entitlements-drizzle` | Entitlements | Drizzle repository | yes | yes | - | - | database connection supplied by app | drizzle-orm | entitlement persistence
billing entitlement lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/execution-drizzle` | Execution | Drizzle execution store | yes | yes | - | - | database connection supplied by app | drizzle-orm | execution state persistence
retryable failure records | 🔴 alpha/WIP | has package tests |
+| `@croco/invitation-drizzle` | Invitation | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | invitation persistence
transaction-aware repository | 🔴 alpha/WIP | has package tests |
+| `@croco/membership-drizzle` | Membership | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | membership persistence
transaction-aware repository | 🔴 alpha/WIP | has package tests |
+| `@croco/metering-drizzle` | Metering | Drizzle usage store | yes | yes | - | - | database connection supplied by app | - | usage persistence
quota lookup
migration scripts | 🔴 alpha/WIP | has package tests |
+| `@croco/metering-upstash` | Metering | Upstash Redis client adapter | yes | yes | yes | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | - | Redis command adapter
serverless usage storage | 🔴 alpha/WIP | has package tests |
+| `@croco/metrics-billing` | Metrics | Billing metrics bridge | yes | yes | yes | - | none | - | billing event metrics
usage aggregation bridge | 🔴 alpha/WIP | has package tests |
+| `@croco/notifications-resend` | Notifications | Resend email provider | yes | yes | - | - | RESEND_API_KEY
default from address | - | email send
template send
retry
idempotency key | 🔴 alpha/WIP | has package tests |
+| `@croco/onboarding-drizzle` | Onboarding | Drizzle repository | yes | yes | - | - | database connection supplied by app | - | onboarding state persistence
step completion storage | 🔴 alpha/WIP | has package tests |
+| `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | yes | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity | 🔴 alpha/WIP | has package tests |
+| `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests |
+| `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests |
+| `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry | 🟡 beta | has package tests |
+| `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | yes | - | QSTASH_TOKEN
destination webhook URL | - | task publish
delay override
custom headers | 🔴 alpha/WIP | has package tests |
+| `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | yes | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch | 🔴 alpha/WIP | has package tests |
+
+## Integration
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| ----------------------------- | ------------- | ------------------------------ | ---- | ------ | ------- | -------- | ----------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------ | ------------------- | ----------------- |
+| `@croco/analytics-posthog` | Analytics | PostHog analytics provider | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | event capture
user/group analytics | 🔴 alpha/WIP | has package tests |
+| `@croco/features-posthog` | Feature flags | PostHog feature provider | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | feature flag lookup
PostHog client reuse | 🟡 beta | has package tests |
+| `@croco/integrations-posthog` | PostHog | Shared PostHog client | yes | yes | - | - | POSTHOG_API_KEY
POSTHOG_HOST optional | - | client lifecycle
capture flush
diagnostics | 🟡 beta | has package tests |
+| `@croco/telemetry-api` | Telemetry | OpenTelemetry application API | yes | yes | yes | - | none | - | Trace decorator
withSpan
recordError
trace context lookup | 🟢 production-ready | has package tests |
+| `@croco/telemetry-sdk-node` | Telemetry | OpenTelemetry Node SDK runtime | yes | yes | - | - | OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT
TELEMETRY_ENABLED optional | - | SDK init
Lambda preset
OTLP export
forceFlush | 🟢 production-ready | has package tests |
+
+## Transport
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| -------------------------------------- | ----------------- | -------------------------- | ---- | ------ | ------- | -------- | --------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------ | ------------------- | ----------------- |
+| `@croco/transports-graphql` | GraphQL transport | GraphQL Yoga transport | yes | yes | - | - | none | - | GraphQL server
resolver execution
Problem mapping | 🟡 beta | has package tests |
+| `@croco/transports-cloudflare-workers` | HTTP transport | Cloudflare Workers adapter | - | - | yes | - | Cloudflare Worker env object supplied by platform | - | Worker fetch adapter
request context bridge | 🔴 alpha/WIP | has package tests |
+| `@croco/transports-http` | HTTP transport | Hono HTTP/Lambda transport | yes | yes | - | - | CROCO_DIAGNOSTICS_TOKEN optional
CROCO_HTTP_SECURITY_VALIDATION optional | - | REST route execution
Lambda adapter
operational endpoints
diagnostics | 🟢 production-ready | has package tests |
+
+## Presentation
+
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |
+| ---------------------------- | ------------------- | ----------------------------------- | ---- | ------ | ------- | -------- | ------------------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------- | ------------ | ----------------- |
+| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration | 🔴 alpha/WIP | has package tests |
+| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR adapters | 🟡 beta | has package tests |
+| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | SSR fetch handler
service binding routing
asset fallback | 🔴 alpha/WIP | has package tests |
+| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility | 🔴 alpha/WIP | has package tests |
+| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support | 🟡 beta | has package tests |
diff --git a/packages/testing/vitest.config.ts b/packages/testing/vitest.config.ts
new file mode 100644
index 000000000..b1c5922dd
--- /dev/null
+++ b/packages/testing/vitest.config.ts
@@ -0,0 +1,19 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+
+const currentDir = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"),
+ },
+ },
+ test: {
+ globals: true,
+ environment: "node",
+ include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
+ exclude: ["**/node_modules/**", "**/dist/**"],
+ },
+});
diff --git a/scripts/package-docs-check.mts b/scripts/package-docs-check.mts
index f2b520d1d..e77317884 100644
--- a/scripts/package-docs-check.mts
+++ b/scripts/package-docs-check.mts
@@ -19,6 +19,7 @@ type Options = {
};
type PackageJson = {
+ readonly peerDependencies?: unknown;
readonly name?: unknown;
readonly private?: unknown;
};
@@ -29,6 +30,7 @@ type PackageInfo = {
readonly hasReadme: boolean;
readonly hasTests: boolean;
readonly name: string;
+ readonly peerDependencies: readonly string[];
readonly private: boolean;
readonly shortName: string;
};
@@ -39,6 +41,7 @@ type PackageRecord = PackageInfo & {
};
type CatalogMetadata = {
+ readonly extensionMatrix?: unknown;
readonly schemaVersion?: unknown;
readonly groups?: unknown;
readonly maturity?: unknown;
@@ -54,6 +57,25 @@ type MaturityConfig = {
readonly packages: readonly string[];
};
+type RuntimeKey = (typeof runtimeOrder)[number];
+
+type ExtensionMetadata = {
+ readonly adapter: string;
+ readonly domain: string;
+ readonly features: readonly string[];
+ readonly requiredEnv: readonly string[];
+ readonly runtimes: readonly RuntimeKey[];
+};
+
+type ExtensionRecord = PackageRecord & {
+ readonly extension: ExtensionMetadata;
+};
+
+type ExtensionMatrixState = {
+ readonly groups: readonly string[];
+ readonly packages: readonly ExtensionRecord[];
+};
+
type DocsBaseline = {
readonly schemaVersion?: unknown;
readonly allowedMissingApiDocs?: unknown;
@@ -74,6 +96,7 @@ type CoverageSet = {
};
type CatalogState = {
+ readonly extensionMatrix: ExtensionMatrixState;
readonly groups: ReadonlyMap;
readonly maturity: ReadonlyMap;
readonly packages: readonly PackageRecord[];
@@ -88,8 +111,19 @@ const docsDirName = "docs";
const catalogMetadataPath = join(docsDirName, "package-catalog.json");
const docsBaselinePath = join(docsDirName, "package-docs-baseline.json");
const docsReportPath = join(docsDirName, "package-docs-report.md");
+const extensionMatrixDocsPath = join(
+ "packages",
+ "docs",
+ "src",
+ "content",
+ "docs",
+ "en",
+ "reference",
+ "extension-matrix.md",
+);
const readmePath = "README.md";
const maturityOrder = ["production", "beta", "alpha", "deprecated"] as const;
+const runtimeOrder = ["node", "lambda", "cloudflare-workers", "browser"] as const;
const scriptRootDir = dirname(dirname(fileURLToPath(import.meta.url)));
type MaturityKey = (typeof maturityOrder)[number];
@@ -125,6 +159,10 @@ function run(options: Options): string[] {
validateCoverageBaseline(coverage, baseline, violations);
const generatedCatalog = formatMarkdown(readmePath, generateReadmeCatalog(state));
+ const generatedExtensionMatrixDocs = formatMarkdown(
+ extensionMatrixDocsPath,
+ generateExtensionMatrixDocs(state),
+ );
const generatedReport = formatMarkdown(
docsReportPath,
generateDocsReport(state, coverage, baseline),
@@ -132,6 +170,10 @@ function run(options: Options): string[] {
if (options.mode === "write") {
writeReadmeCatalog(options.rootDir, generatedCatalog);
+ writeGeneratedFile(
+ join(options.rootDir, extensionMatrixDocsPath),
+ generatedExtensionMatrixDocs,
+ );
writeGeneratedFile(join(options.rootDir, docsReportPath), generatedReport);
return violations;
}
@@ -142,6 +184,14 @@ function run(options: Options): string[] {
violations.push(`README.md package catalog drift detected; run pnpm docs:catalog:write`);
}
+ const extensionMatrixPath = join(options.rootDir, extensionMatrixDocsPath);
+ const currentExtensionMatrix = existsSync(extensionMatrixPath)
+ ? readFileSync(extensionMatrixPath, "utf-8")
+ : "";
+ if (currentExtensionMatrix !== generatedExtensionMatrixDocs) {
+ violations.push(`${extensionMatrixDocsPath} drift detected; run pnpm docs:catalog:write`);
+ }
+
const reportPath = join(options.rootDir, docsReportPath);
const currentReport = existsSync(reportPath) ? readFileSync(reportPath, "utf-8") : "";
if (currentReport !== generatedReport) {
@@ -205,8 +255,15 @@ function loadCatalogState(rootDir: string, violations: string[]): CatalogState {
maturity: maturityKey,
};
});
+ const extensionMatrix = parseExtensionMatrix(
+ metadata.extensionMatrix,
+ groups,
+ records,
+ violations,
+ );
return {
+ extensionMatrix,
groups,
maturity,
packages: records,
@@ -245,6 +302,7 @@ function readPackages(rootDir: string): PackageInfo[] {
existsSync(join(packageDir, "src", "tests")) ||
existsSync(join(packageDir, "src", "__tests__")),
name: pkg.name,
+ peerDependencies: readDependencyKeys(pkg.peerDependencies),
private: pkg.private === true,
shortName: toShortPackageName(pkg.name),
});
@@ -324,6 +382,192 @@ function parseMaturity(
return maturity;
}
+function parseExtensionMatrix(
+ value: unknown,
+ groups: ReadonlyMap,
+ packages: readonly PackageRecord[],
+ violations: string[],
+): ExtensionMatrixState {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ violations.push(`${catalogMetadataPath}: extensionMatrix must be an object`);
+ return {
+ groups: [],
+ packages: [],
+ };
+ }
+
+ const groupValue = (value as { readonly groups?: unknown }).groups;
+ const packageValue = (value as { readonly packages?: unknown }).packages;
+ if (!isStringArray(groupValue)) {
+ violations.push(`${catalogMetadataPath}: extensionMatrix.groups must be a string array`);
+ }
+ if (!packageValue || typeof packageValue !== "object" || Array.isArray(packageValue)) {
+ violations.push(`${catalogMetadataPath}: extensionMatrix.packages must be an object`);
+ return {
+ groups: isStringArray(groupValue) ? groupValue : [],
+ packages: [],
+ };
+ }
+
+ const extensionGroups = isStringArray(groupValue) ? groupValue : [];
+ for (const group of extensionGroups) {
+ if (!groups.has(group)) {
+ violations.push(
+ `${catalogMetadataPath}: extensionMatrix.groups references missing group ${group}`,
+ );
+ }
+ }
+
+ const packageByName = new Map(packages.map((pkg) => [pkg.shortName, pkg]));
+ const extensionGroupSet = new Set(extensionGroups);
+ const targetPackages = packages.filter((pkg) => extensionGroupSet.has(pkg.group));
+ const records: ExtensionRecord[] = [];
+
+ for (const [packageName, metadataValue] of Object.entries(packageValue)) {
+ const pkg = packageByName.get(packageName);
+ if (!pkg) {
+ violations.push(
+ `${catalogMetadataPath}: extensionMatrix.packages references missing package ${packageName}`,
+ );
+ continue;
+ }
+ if (!extensionGroupSet.has(pkg.group)) {
+ violations.push(
+ `${catalogMetadataPath}: extensionMatrix.packages.${packageName} is not in an extension group`,
+ );
+ continue;
+ }
+
+ const metadata = parseExtensionMetadata(packageName, metadataValue, violations);
+ if (!metadata) {
+ continue;
+ }
+
+ records.push({
+ ...pkg,
+ extension: metadata,
+ });
+ }
+
+ const metadataPackageNames = new Set(records.map((pkg) => pkg.shortName));
+ for (const pkg of targetPackages) {
+ if (!metadataPackageNames.has(pkg.shortName)) {
+ violations.push(
+ `${catalogMetadataPath}: extensionMatrix is missing metadata for ${pkg.group} package ${pkg.shortName}`,
+ );
+ }
+ }
+
+ return {
+ groups: extensionGroups,
+ packages: records.sort(
+ (left, right) =>
+ extensionGroups.indexOf(left.group) - extensionGroups.indexOf(right.group) ||
+ left.extension.domain.localeCompare(right.extension.domain) ||
+ left.shortName.localeCompare(right.shortName),
+ ),
+ };
+}
+
+function parseExtensionMetadata(
+ packageName: string,
+ value: unknown,
+ violations: string[],
+): ExtensionMetadata | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ violations.push(
+ `${catalogMetadataPath}: extensionMatrix.packages.${packageName} must be an object`,
+ );
+ return null;
+ }
+
+ const metadata = value as Record;
+ const adapter = readRequiredString(
+ metadata.adapter,
+ `extensionMatrix.packages.${packageName}.adapter`,
+ violations,
+ );
+ const domain = readRequiredString(
+ metadata.domain,
+ `extensionMatrix.packages.${packageName}.domain`,
+ violations,
+ );
+ const features = readRequiredStringArray(
+ metadata.features,
+ `extensionMatrix.packages.${packageName}.features`,
+ violations,
+ );
+ const requiredEnv = readRequiredStringArray(
+ metadata.requiredEnv,
+ `extensionMatrix.packages.${packageName}.requiredEnv`,
+ violations,
+ );
+ const runtimes = readRuntimeArray(
+ metadata.runtimes,
+ `extensionMatrix.packages.${packageName}.runtimes`,
+ violations,
+ );
+
+ if (
+ !adapter ||
+ !domain ||
+ features.length === 0 ||
+ requiredEnv.length === 0 ||
+ runtimes.length === 0
+ ) {
+ return null;
+ }
+
+ return {
+ adapter,
+ domain,
+ features,
+ requiredEnv,
+ runtimes,
+ };
+}
+
+function readRequiredString(value: unknown, key: string, violations: string[]): string {
+ if (typeof value !== "string" || value.length === 0) {
+ violations.push(`${catalogMetadataPath}: ${key} must be a non-empty string`);
+ return "";
+ }
+
+ return value;
+}
+
+function readRequiredStringArray(
+ value: unknown,
+ key: string,
+ violations: string[],
+): readonly string[] {
+ if (!isStringArray(value) || value.length === 0) {
+ violations.push(`${catalogMetadataPath}: ${key} must be a non-empty string array`);
+ return [];
+ }
+
+ return value;
+}
+
+function readRuntimeArray(
+ value: unknown,
+ key: string,
+ violations: string[],
+): readonly RuntimeKey[] {
+ const runtimes = readRequiredStringArray(value, key, violations);
+ const validRuntimes = new Set(runtimeOrder);
+
+ for (const runtime of runtimes) {
+ if (!validRuntimes.has(runtime)) {
+ violations.push(
+ `${catalogMetadataPath}: ${key} contains unsupported runtime ${runtime}; expected one of ${runtimeOrder.join(", ")}`,
+ );
+ }
+ }
+
+ return runtimes.filter((runtime): runtime is RuntimeKey => validRuntimes.has(runtime));
+}
+
function validateAssignments(
label: string,
assignments: ReadonlyMap,
@@ -506,6 +750,16 @@ function generateReadmeCatalog(state: CatalogState): string {
lines.push(`| ${config.label} | ${maturityDescription(maturity)} | ${count} |`);
}
+ lines.push(
+ "",
+ "### Extension & Adapter Matrix",
+ "",
+ "> 이 섹션은 `docs/package-catalog.json`의 `extensionMatrix` metadata에서 생성됩니다. 성숙도와 package test 존재 여부는 별도 열로 표시합니다.",
+ "",
+ "Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수, Workers는 Cloudflare Workers, Frontend는 browser/SSR frontend integration을 의미합니다.",
+ );
+ appendExtensionMatrixTables(lines, state, "####");
+
for (const maturity of maturityOrder) {
const config = state.maturity.get(maturity);
if (!config) {
@@ -540,7 +794,7 @@ function generateReadmeCatalog(state: CatalogState): string {
"",
"### Documentation Gate",
"",
- "- `pnpm docs:catalog:check`는 README 카탈로그와 문서 커버리지 리포트 drift를 검증합니다.",
+ "- `pnpm docs:catalog:check`는 README 카탈로그, extension matrix reference 문서, 문서 커버리지 리포트 drift를 검증합니다.",
"- 신규 public package는 `docs/package-catalog.json`에 그룹/성숙도 metadata가 있어야 합니다.",
"- 신규 public package의 README, API docs, tests 누락은 `docs/package-docs-baseline.json`에 없는 한 실패합니다.",
"",
@@ -570,6 +824,7 @@ function generateDocsReport(
`| Missing package README | ${coverage.missingReadme.length} |`,
`| Missing generated API docs | ${coverage.missingApiDocs.length} |`,
`| Missing package test directory | ${coverage.missingTests.length} |`,
+ `| Extension matrix packages | ${state.extensionMatrix.packages.length} |`,
"",
"New public packages must not add missing README, API docs, or test coverage unless the gap is explicitly listed in `docs/package-docs-baseline.json`.",
"",
@@ -606,10 +861,76 @@ function generateDocsReport(
);
}
+ lines.push(
+ "",
+ "## Extension Matrix",
+ "",
+ "Extension matrix metadata is maintained in `docs/package-catalog.json` and rendered to the root README plus the docs reference page.",
+ "",
+ "| Group | Packages | Without package tests |",
+ "| --- | ---: | ---: |",
+ );
+ for (const group of state.extensionMatrix.groups) {
+ const packages = state.extensionMatrix.packages.filter((pkg) => pkg.group === group);
+ lines.push(
+ `| ${group} | ${packages.length} | ${packages.filter((pkg) => !pkg.hasTests).length} |`,
+ );
+ }
+
+ lines.push("");
+ return lines.join("\n");
+}
+
+function generateExtensionMatrixDocs(state: CatalogState): string {
+ const lines: string[] = [
+ "---",
+ "title: Extension Matrix",
+ "description: Official Croco provider and adapter compatibility matrix.",
+ "---",
+ "",
+ "# Extension Matrix",
+ "",
+ "> Generated by `pnpm docs:catalog:write`. Do not edit this file by hand.",
+ "",
+ "This page lists Croco provider, integration, transport, and presentation adapter compatibility from `docs/package-catalog.json`. Required configuration, runtime support, package peer dependencies, maturity, and package test presence are intentionally separate so users can evaluate production readiness without treating a passing unit test as a maturity claim.",
+ "",
+ "Runtime columns: Node covers long-running server and CLI use, Lambda covers serverless functions, Workers covers Cloudflare Workers, and Frontend covers browser or SSR frontend integration.",
+ ];
+
+ appendExtensionMatrixTables(lines, state, "##");
+
lines.push("");
return lines.join("\n");
}
+function appendExtensionMatrixTables(
+ lines: string[],
+ state: CatalogState,
+ headingPrefix: "##" | "####",
+): void {
+ for (const group of state.extensionMatrix.groups) {
+ const packages = state.extensionMatrix.packages.filter((pkg) => pkg.group === group);
+ if (packages.length === 0) {
+ continue;
+ }
+
+ lines.push(
+ "",
+ `${headingPrefix} ${group}`,
+ "",
+ "| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests |",
+ "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+ );
+
+ for (const pkg of packages) {
+ const maturity = state.maturity.get(pkg.maturity)?.label ?? pkg.maturity;
+ lines.push(
+ `| \`${pkg.name}\` | ${pkg.extension.domain} | ${pkg.extension.adapter} | ${formatRuntimeSupport(pkg, "node")} | ${formatRuntimeSupport(pkg, "lambda")} | ${formatRuntimeSupport(pkg, "cloudflare-workers")} | ${formatRuntimeSupport(pkg, "browser")} | ${formatList(pkg.extension.requiredEnv)} | ${formatList(pkg.peerDependencies)} | ${formatList(pkg.extension.features)} | ${maturity} | ${formatPackageTestStatus(pkg)} |`,
+ );
+ }
+ }
+}
+
function formatMissingPackages(
packages: readonly PackageRecord[],
allowedMissingPackages: ReadonlySet,
@@ -624,6 +945,18 @@ function formatMissingPackages(
});
}
+function formatRuntimeSupport(pkg: ExtensionRecord, runtime: RuntimeKey): string {
+ return pkg.extension.runtimes.includes(runtime) ? "yes" : "-";
+}
+
+function formatList(values: readonly string[]): string {
+ return values.length > 0 ? values.join("
") : "-";
+}
+
+function formatPackageTestStatus(pkg: PackageRecord): string {
+ return pkg.hasTests ? "has package tests" : "no package tests";
+}
+
function writeReadmeCatalog(rootDir: string, generatedCatalog: string): void {
const readmeFilePath = join(rootDir, readmePath);
const readme = readRequiredFile(readmeFilePath);
@@ -702,6 +1035,14 @@ function toShortPackageName(packageName: string): string {
return packageName.replace(/^@croco\//, "");
}
+function readDependencyKeys(value: unknown): readonly string[] {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ return [];
+ }
+
+ return Object.keys(value).sort((left, right) => left.localeCompare(right));
+}
+
function readJsonFile(filePath: string): T {
return JSON.parse(readRequiredFile(filePath)) as T;
}
diff --git a/scripts/tests/package-docs-check.spec.ts b/scripts/tests/package-docs-check.spec.ts
index 875e0b1db..43d592782 100644
--- a/scripts/tests/package-docs-check.spec.ts
+++ b/scripts/tests/package-docs-check.spec.ts
@@ -34,12 +34,30 @@ describe("package-docs-check.mts", () => {
const result = runScript(root, "--write");
const readme = readFileSync(join(root, "README.md"), "utf-8");
const report = readFileSync(join(root, "docs", "package-docs-report.md"), "utf-8");
+ const matrix = readFileSync(
+ join(
+ root,
+ "packages",
+ "docs",
+ "src",
+ "content",
+ "docs",
+ "en",
+ "reference",
+ "extension-matrix.md",
+ ),
+ "utf-8",
+ );
expect(result.status).toBe(0);
expect(readme).toContain("");
expect(readme).toContain("현재 카탈로그는 **2개 public package**");
+ expect(readme).toContain("Extension & Adapter Matrix");
expect(readme).toContain("`@croco/alpha`");
expect(report).toContain("Missing generated API docs");
+ expect(report).toContain("Extension Matrix");
+ expect(matrix).toContain("title: Extension Matrix");
+ expect(matrix).toContain("`@croco/alpha`");
});
it("fails check mode when the README catalog was not regenerated", () => {
@@ -56,6 +74,7 @@ describe("package-docs-check.mts", () => {
expect(result.status).toBe(1);
expect(result.stdout).toContain("README.md package catalog drift detected");
+ expect(result.stdout).toContain("reference/extension-matrix.md drift detected");
expect(result.stdout).toContain("docs/package-docs-report.md drift detected");
});
@@ -91,6 +110,28 @@ describe("package-docs-check.mts", () => {
expect(result.stdout).toContain("new public packages missing README");
expect(result.stdout).toContain("new public packages missing API docs");
});
+
+ it("fails when an extension group package is missing matrix metadata", () => {
+ const root = createTempRoot();
+ writePackage(root, "provider", { name: "@croco/provider" });
+ writeCatalogMetadata(root, ["provider"], {
+ extensionGroups: ["Provider"],
+ extensionPackages: [],
+ groupName: "Provider",
+ });
+ writeDocsBaseline(root, {
+ allowedMissingApiDocs: ["provider"],
+ allowedMissingReadme: [],
+ allowedMissingTests: [],
+ });
+
+ const result = runScript(root, "--write");
+
+ expect(result.status).toBe(1);
+ expect(result.stdout).toContain(
+ "extensionMatrix is missing metadata for Provider package provider",
+ );
+ });
});
function createTempRoot(): string {
@@ -140,11 +181,21 @@ function writePackage(
writeFileSync(join(packageDir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
}
-function writeCatalogMetadata(root: string, packageNames: readonly string[]): void {
+function writeCatalogMetadata(
+ root: string,
+ packageNames: readonly string[],
+ options: {
+ readonly extensionGroups?: readonly string[];
+ readonly extensionPackages?: readonly string[];
+ readonly groupName?: string;
+ } = {},
+): void {
+ const groupName = options.groupName ?? "Core";
+ const extensionPackages = options.extensionPackages ?? packageNames;
writeJson(join(root, "docs", "package-catalog.json"), {
schemaVersion: 1,
groups: {
- Core: {
+ [groupName]: {
description: "Fixture core packages",
packages: packageNames,
},
@@ -167,6 +218,21 @@ function writeCatalogMetadata(root: string, packageNames: readonly string[]): vo
packages: [],
},
},
+ extensionMatrix: {
+ groups: options.extensionGroups ?? [groupName],
+ packages: Object.fromEntries(
+ extensionPackages.map((packageName) => [
+ packageName,
+ {
+ adapter: "Fixture adapter",
+ domain: "Fixture",
+ features: ["Fixture feature"],
+ requiredEnv: ["none"],
+ runtimes: ["node"],
+ },
+ ]),
+ ),
+ },
});
}