From bec275822b679bf86b41afc7d869cf3a05eaa80e Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 20 Jun 2026 03:14:49 +0900 Subject: [PATCH] fix: validate presentation preset runtime profiles --- .../presentation-preset-runtime-profiles.md | 5 + README.md | 2 +- docs/package-docs-baseline.json | 1 - docs/package-docs-report.md | 3 +- .../reference/presentation-runtime-support.md | 22 + packages/presentation-preset/README.md | 37 ++ packages/presentation-preset/package.json | 3 +- .../presentation-preset/runtime-profiles.json | 134 ++++++ .../output-contract-validator.spec.ts | 282 ++++++++++- packages/presentation-preset/src/index.ts | 4 + .../src/output-contract-validator.ts | 437 ++++++++++++++++-- .../src/output-contract.ts | 26 +- packages/presentation-preset/tsup.config.ts | 8 + packages/presentation-preset/vitest.config.ts | 20 + public-api-surface.snapshot.json | 20 + scripts/package-docs-check.mts | 385 +++++++++++++++ scripts/package-entrypoint-smoke.mts | 32 +- scripts/tests/package-docs-check.spec.ts | 113 ++++- 18 files changed, 1491 insertions(+), 43 deletions(-) create mode 100644 .changeset/presentation-preset-runtime-profiles.md create mode 100644 packages/presentation-preset/README.md create mode 100644 packages/presentation-preset/runtime-profiles.json diff --git a/.changeset/presentation-preset-runtime-profiles.md b/.changeset/presentation-preset-runtime-profiles.md new file mode 100644 index 000000000..17390cc6d --- /dev/null +++ b/.changeset/presentation-preset-runtime-profiles.md @@ -0,0 +1,5 @@ +--- +"@croco/presentation-preset": patch +--- + +Presentation runtime claims are now backed by generated profile contracts and catalog validation. diff --git a/README.md b/README.md index 31c4a5e5c..0f200de1e 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/features-posthog` | Integration | `packages/features-posthog` | README, tests | | `@croco/integrations-posthog` | Integration | `packages/integrations-posthog` | tests | | `@croco/meta-vite` | Presentation | `packages/meta-vite` | README, tests | -| `@croco/presentation-preset` | Presentation | `packages/presentation-preset` | tests | +| `@croco/presentation-preset` | Presentation | `packages/presentation-preset` | README, tests | | `@croco/openapi-spec` | Protocol | `packages/openapi-spec` | tests | | `@croco/protocols-core` | Protocol | `packages/protocols-core` | tests | | `@croco/protocols-graphql` | Protocol | `packages/protocols-graphql` | tests | diff --git a/docs/package-docs-baseline.json b/docs/package-docs-baseline.json index 6a9625a3f..78e0ab872 100644 --- a/docs/package-docs-baseline.json +++ b/docs/package-docs-baseline.json @@ -13,7 +13,6 @@ "migration-runner", "notifications-core", "openapi-spec", - "presentation-preset", "preset-cloudflare", "preset-lambda", "preset-node", diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md index d8dfa6397..1fdb7fe5f 100644 --- a/docs/package-docs-report.md +++ b/docs/package-docs-report.md @@ -8,7 +8,7 @@ | ------------------------------ | ----: | | Public packages | 102 | | Private packages skipped | 2 | -| Missing package README | 22 | +| Missing package README | 21 | | Missing generated API docs | 75 | | Missing package test directory | 0 | | Extension matrix packages | 40 | @@ -28,7 +28,6 @@ New public packages must not add missing README, API docs, or test coverage unle - `@croco/migration-runner` (`packages/migration-runner`) — legacy baseline - `@croco/notifications-core` (`packages/notifications-core`) — legacy baseline - `@croco/openapi-spec` (`packages/openapi-spec`) — legacy baseline -- `@croco/presentation-preset` (`packages/presentation-preset`) — legacy baseline - `@croco/preset-cloudflare` (`packages/preset-cloudflare`) — legacy baseline - `@croco/preset-lambda` (`packages/preset-lambda`) — legacy baseline - `@croco/preset-node` (`packages/preset-node`) — legacy baseline diff --git a/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md b/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md index e796470a4..d41548466 100644 --- a/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md +++ b/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md @@ -47,3 +47,25 @@ Presentation packages move from alpha to beta only after all package-specific cr No presentation package should be promoted in `docs/package-catalog.json` unless its gate evidence is named in the relevant package README, package tests, generated-app smoke, and this page. + +## Presentation Preset Evidence + +`@croco/presentation-preset` keeps the current generated runtime profile contract in +`packages/presentation-preset/runtime-profiles.json`. The package test command validates each +profile's target metadata, output entries, artifacts, contract format, generated smoke case, and +the runtime claims currently listed in `docs/package-catalog.json`. + +| Profile | Runtime | Generated smoke case | +| ------------------- | -------------------- | ----------------------------- | +| `node-server` | `node` | `production-app-starter` | +| `lambda-function` | `lambda` | `graphql-lambda-api` | +| `cloudflare-worker` | `cloudflare-workers` | `meta-vite-fullstack-workers` | +| `browser-vite-spa` | `browser` | `meta-vite-web` | + +Verification commands: + +```bash +pnpm --filter @croco/presentation-preset test +pnpm create-croco-app:smoke +pnpm docs:catalog:check +``` diff --git a/packages/presentation-preset/README.md b/packages/presentation-preset/README.md new file mode 100644 index 000000000..f0f649b07 --- /dev/null +++ b/packages/presentation-preset/README.md @@ -0,0 +1,37 @@ +# @croco/presentation-preset + +`@croco/presentation-preset` records the generated output contracts that back the Presentation +runtime claims in `docs/package-catalog.json`. + +## Supported Runtime Profiles + +The source of truth is `runtime-profiles.json`. Each profile names the runtime claim, target +metadata, output artifacts, entry descriptors, package test evidence, and generated-app smoke +case that proves the claim. + +| Profile | Catalog runtime | Generated smoke evidence | +| ------------------- | -------------------- | ------------------------------------------------------------------------------------- | +| `node-server` | `node` | `CROCO_GENERATED_SMOKE_CASES=production-app-starter pnpm create-croco-app:smoke` | +| `lambda-function` | `lambda` | `CROCO_GENERATED_SMOKE_CASES=graphql-lambda-api pnpm create-croco-app:smoke` | +| `cloudflare-worker` | `cloudflare-workers` | `CROCO_GENERATED_SMOKE_CASES=meta-vite-fullstack-workers pnpm create-croco-app:smoke` | +| `browser-vite-spa` | `browser` | `CROCO_GENERATED_SMOKE_CASES=meta-vite-web pnpm create-croco-app:smoke` | + +## Verification + +Run the package-level contract check: + +```bash +pnpm --filter @croco/presentation-preset test +``` + +Run the generated-app smoke matrix when a profile, generated output shape, or runtime claim +changes: + +```bash +pnpm create-croco-app:smoke +pnpm docs:catalog:check +``` + +`pnpm docs:catalog:check` fails when `docs/package-catalog.json` claims a +`@croco/presentation-preset` runtime that lacks generated profile evidence in +`runtime-profiles.json`. diff --git a/packages/presentation-preset/package.json b/packages/presentation-preset/package.json index b679226a3..a8684e438 100644 --- a/packages/presentation-preset/package.json +++ b/packages/presentation-preset/package.json @@ -20,7 +20,8 @@ "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" - } + }, + "./runtime-profiles.json": "./dist/runtime-profiles.json" }, "access": "public" }, diff --git a/packages/presentation-preset/runtime-profiles.json b/packages/presentation-preset/runtime-profiles.json new file mode 100644 index 000000000..1fb5627f6 --- /dev/null +++ b/packages/presentation-preset/runtime-profiles.json @@ -0,0 +1,134 @@ +{ + "schemaVersion": 1, + "validationCommand": "pnpm --filter @croco/presentation-preset test", + "profiles": [ + { + "name": "node-server", + "runtime": "node", + "packageTestName": "validates the node-server generated runtime profile", + "generatedAppSmokeCase": "production-app-starter", + "generatedAppSmokeCommand": "CROCO_GENERATED_SMOKE_CASES=production-app-starter pnpm create-croco-app:smoke", + "target": { + "target": "node", + "requiredEnvVars": [], + "runtime": { + "nodeVersion": ">=20" + }, + "output": { + "presetName": "presentation-preset/node-server", + "buildTime": "2026-01-01T00:00:00.000Z", + "format": "dual", + "artifacts": [ + { "path": "apps/api-server/dist/index.mjs", "format": "esm", "type": "code" }, + { "path": "apps/api-server/dist/index.cjs", "format": "cjs", "type": "code" }, + { "path": "apps/api-server/dist/index.d.ts", "format": "neutral", "type": "types" }, + { "path": "contract-graph.snapshot.json", "format": "neutral", "type": "asset" }, + { "path": "openapi.json", "format": "neutral", "type": "asset" } + ], + "entries": [ + { + "exportName": ".", + "main": "apps/api-server/dist/index.mjs", + "cjs": "apps/api-server/dist/index.cjs", + "types": "apps/api-server/dist/index.d.ts" + } + ] + } + } + }, + { + "name": "lambda-function", + "runtime": "lambda", + "packageTestName": "validates the lambda-function generated runtime profile", + "generatedAppSmokeCase": "graphql-lambda-api", + "generatedAppSmokeCommand": "CROCO_GENERATED_SMOKE_CASES=graphql-lambda-api pnpm create-croco-app:smoke", + "target": { + "target": "lambda", + "requiredEnvVars": [], + "runtime": { + "nodeVersion": ">=20", + "memory": 512, + "timeout": 30 + }, + "output": { + "presetName": "presentation-preset/lambda-function", + "buildTime": "2026-01-01T00:00:00.000Z", + "format": "dual", + "artifacts": [ + { "path": "apps/api-server/dist/lambda.mjs", "format": "esm", "type": "code" }, + { "path": "apps/api-server/dist/lambda.cjs", "format": "cjs", "type": "code" }, + { "path": "apps/api-server/dist/lambda.d.ts", "format": "neutral", "type": "types" }, + { "path": "apps/api-server/template.yaml", "format": "neutral", "type": "config" }, + { "path": "contract-graph.snapshot.json", "format": "neutral", "type": "asset" } + ], + "entries": [ + { + "exportName": "./lambda", + "main": "apps/api-server/dist/lambda.mjs", + "cjs": "apps/api-server/dist/lambda.cjs", + "types": "apps/api-server/dist/lambda.d.ts" + } + ] + } + } + }, + { + "name": "cloudflare-worker", + "runtime": "cloudflare-workers", + "packageTestName": "validates the cloudflare-worker generated runtime profile", + "generatedAppSmokeCase": "meta-vite-fullstack-workers", + "generatedAppSmokeCommand": "CROCO_GENERATED_SMOKE_CASES=meta-vite-fullstack-workers pnpm create-croco-app:smoke", + "target": { + "target": "cloudflare-workers", + "requiredEnvVars": [], + "output": { + "presetName": "presentation-preset/cloudflare-worker", + "buildTime": "2026-01-01T00:00:00.000Z", + "format": "esm", + "artifacts": [ + { "path": "ssr-worker/dist/index.mjs", "format": "esm", "type": "code" }, + { "path": "ssr-worker/dist/index.d.ts", "format": "neutral", "type": "types" }, + { "path": "ssr-worker/wrangler.toml", "format": "neutral", "type": "config" }, + { "path": "ssr-worker/dist/client/manifest.json", "format": "neutral", "type": "asset" } + ], + "entries": [ + { + "exportName": "./worker", + "main": "ssr-worker/dist/index.mjs", + "types": "ssr-worker/dist/index.d.ts" + } + ] + } + } + }, + { + "name": "browser-vite-spa", + "runtime": "browser", + "packageTestName": "validates the browser-vite-spa generated runtime profile", + "generatedAppSmokeCase": "meta-vite-web", + "generatedAppSmokeCommand": "CROCO_GENERATED_SMOKE_CASES=meta-vite-web pnpm create-croco-app:smoke", + "target": { + "target": "browser", + "requiredEnvVars": [], + "output": { + "presetName": "presentation-preset/browser-vite-spa", + "buildTime": "2026-01-01T00:00:00.000Z", + "format": "esm", + "artifacts": [ + { "path": "apps/web/dist/index.html", "format": "neutral", "type": "asset" }, + { "path": "apps/web/dist/assets/main.js", "format": "esm", "type": "code" }, + { "path": "apps/web/dist/assets/main.css", "format": "neutral", "type": "asset" }, + { "path": "apps/web/src/vite-env.d.ts", "format": "neutral", "type": "types" } + ], + "entries": [ + { + "exportName": "./browser", + "main": "apps/web/dist/assets/main.js", + "types": "apps/web/src/vite-env.d.ts" + } + ] + } + } + } + ] +} diff --git a/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts b/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts index e0e7a54e5..185a28d87 100644 --- a/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts +++ b/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts @@ -1,6 +1,11 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import type { OutputContract } from "../output-contract"; +import type { + GeneratedRuntimeProfile, + GeneratedRuntimeProfileCatalog, + OutputContract, +} from "../output-contract"; import { OutputContractValidator } from "../output-contract-validator"; function createValidContract(overrides?: Partial): OutputContract { @@ -110,4 +115,279 @@ describe("OutputContractValidator", () => { expect(report.passed).toBe(false); expect(report.results.length).toBeGreaterThan(0); }); + + it("reports error when contract format is unsupported", () => { + const contract = createValidContract({ format: "iife" as unknown as OutputContract["format"] }); + const report = validator.validate(contract); + + expect(report.passed).toBe(false); + expect(report.results.some((result) => result.message.includes("not supported"))).toBe(true); + }); }); + +describe("Generated runtime profile catalog", () => { + const validator = new OutputContractValidator(); + const profileCatalog = readRuntimeProfileCatalog(); + + it.each(profileCatalog.profiles)("validates the $name generated runtime profile", (profile) => { + const report = validator.validateGeneratedRuntimeProfile(profile); + + expect(report.passed).toBe(true); + expect(report.results.filter((result) => result.severity === "error")).toHaveLength(0); + }); + + it("validates every runtime profile and catalog claim together", () => { + const report = validator.validateGeneratedRuntimeProfileCatalog(profileCatalog, { + claimedRuntimes: readPresentationPresetRuntimeClaims(), + }); + + expect(report.passed).toBe(true); + expect(report.results.filter((result) => result.severity === "error")).toHaveLength(0); + }); + + it("names generated app smoke cases that exist in the smoke matrix", () => { + const smokeScript = readFileSync( + new URL("../../../../scripts/create-croco-app-generated-smoke.mts", import.meta.url), + "utf-8", + ); + + for (const profile of profileCatalog.profiles) { + expect(smokeScript).toContain(`name: "${profile.generatedAppSmokeCase}"`); + } + }); + + it("fails when a catalog runtime claim has no generated profile evidence", () => { + const report = validator.validateGeneratedRuntimeProfileCatalog( + { + ...profileCatalog, + profiles: profileCatalog.profiles.filter((profile) => profile.runtime !== "browser"), + }, + { claimedRuntimes: ["browser"] }, + ); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === + "Catalog runtime claim 'browser' has no generated runtime profile evidence", + ), + ).toBe(true); + }); + + it("fails when a catalog runtime claim is unsupported", () => { + const report = validator.validateGeneratedRuntimeProfileCatalog(profileCatalog, { + claimedRuntimes: ["deno"], + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Catalog runtime claim 'deno' is not a supported presentation runtime", + ), + ).toBe(true); + }); + + it("fails without throwing when catalog profiles contain non-object values", () => { + const report = validator.validateGeneratedRuntimeProfileCatalog({ + ...profileCatalog, + profiles: [null, ...profileCatalog.profiles] as unknown as readonly GeneratedRuntimeProfile[], + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Generated runtime profile must be an object", + ), + ).toBe(true); + }); + + it("fails when runtime target metadata does not match the profile runtime", () => { + const [profile] = profileCatalog.profiles; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + target: "lambda", + }, + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && result.message.includes("does not match runtime"), + ), + ).toBe(true); + }); + + it("fails when runtime target env metadata is not a string array", () => { + const [profile] = profileCatalog.profiles; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + requiredEnvVars: "TOKEN" as unknown as readonly string[], + }, + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Deploy target requiredEnvVars must contain non-empty strings", + ), + ).toBe(true); + }); + + it("fails when runtime target constraints use invalid value types", () => { + const [profile] = profileCatalog.profiles; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + runtime: { + nodeVersion: 20, + memory: "512", + timeout: 0, + } as unknown as typeof profile.target.runtime, + }, + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Deploy target runtime.nodeVersion must be non-empty when provided", + ), + ).toBe(true); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Deploy target runtime.memory must be greater than 0 when provided", + ), + ).toBe(true); + expect( + report.results.some( + (result) => + result.severity === "error" && + result.message === "Deploy target runtime.timeout must be greater than 0 when provided", + ), + ).toBe(true); + }); + + it("fails without throwing when generated profile artifacts or entries contain non-object values", () => { + const [profile] = profileCatalog.profiles; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + output: { + ...profile.target.output, + artifacts: [null, ...profile.target.output.artifacts], + entries: [null, ...profile.target.output.entries], + }, + }, + } as unknown as GeneratedRuntimeProfile); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => result.severity === "error" && result.message === "Artifact must be an object", + ), + ).toBe(true); + expect( + report.results.some( + (result) => result.severity === "error" && result.message === "Entry must be an object", + ), + ).toBe(true); + }); + + it("fails when a generated profile artifact format is unsupported", () => { + const [profile] = profileCatalog.profiles; + const [artifact, ...artifacts] = profile.target.output.artifacts; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + output: { + ...profile.target.output, + artifacts: [ + { + ...artifact, + format: "iife" as typeof artifact.format, + }, + ...artifacts, + ], + }, + }, + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && result.message.includes("has unsupported format"), + ), + ).toBe(true); + }); + + it("fails when a generated profile entry references a missing artifact", () => { + const [profile] = profileCatalog.profiles; + const [entry, ...entries] = profile.target.output.entries; + const report = validator.validateGeneratedRuntimeProfile({ + ...profile, + target: { + ...profile.target, + output: { + ...profile.target.output, + entries: [ + { + ...entry, + main: "missing-entry.js", + }, + ...entries, + ], + }, + }, + }); + + expect(report.passed).toBe(false); + expect( + report.results.some( + (result) => + result.severity === "error" && result.message.includes("but no matching artifact exists"), + ), + ).toBe(true); + }); +}); + +function readRuntimeProfileCatalog(): GeneratedRuntimeProfileCatalog { + return JSON.parse( + readFileSync(new URL("../../runtime-profiles.json", import.meta.url), "utf-8"), + ) as GeneratedRuntimeProfileCatalog; +} + +function readPresentationPresetRuntimeClaims(): readonly string[] { + const catalog = JSON.parse( + readFileSync(new URL("../../../../docs/package-catalog.json", import.meta.url), "utf-8"), + ) as { + readonly extensionMatrix?: { + readonly packages?: { + readonly "presentation-preset"?: { + readonly runtimes?: readonly string[]; + }; + }; + }; + }; + + return catalog.extensionMatrix?.packages?.["presentation-preset"]?.runtimes ?? []; +} diff --git a/packages/presentation-preset/src/index.ts b/packages/presentation-preset/src/index.ts index ae4f1fecc..96938116d 100644 --- a/packages/presentation-preset/src/index.ts +++ b/packages/presentation-preset/src/index.ts @@ -4,9 +4,13 @@ export type { BuildArtifact, DeployTarget, EntryDescriptor, + GeneratedRuntimeProfile, + GeneratedRuntimeProfileCatalog, OutputContract, + PresentationRuntime, } from "./output-contract"; export type { + RuntimeClaimValidationOptions, ValidationReport, ValidationResult, ValidationSeverity, diff --git a/packages/presentation-preset/src/output-contract-validator.ts b/packages/presentation-preset/src/output-contract-validator.ts index 1e14aa6ed..dd7ee6013 100644 --- a/packages/presentation-preset/src/output-contract-validator.ts +++ b/packages/presentation-preset/src/output-contract-validator.ts @@ -1,4 +1,12 @@ -import type { BuildArtifact, EntryDescriptor, OutputContract } from "./output-contract"; +import type { + BuildArtifact, + DeployTarget, + EntryDescriptor, + GeneratedRuntimeProfile, + GeneratedRuntimeProfileCatalog, + OutputContract, + PresentationRuntime, +} from "./output-contract"; export type ValidationSeverity = "error" | "warning"; @@ -14,86 +22,261 @@ export type ValidationReport = { readonly results: readonly ValidationResult[]; }; +export type RuntimeClaimValidationOptions = { + readonly claimedRuntimes?: readonly string[]; +}; + +const ARTIFACT_FORMATS = new Set(["esm", "cjs", "dual", "neutral"]); +const ARTIFACT_TYPES = new Set(["code", "types", "config", "asset"]); +const PRESENTATION_RUNTIMES = new Set(["node", "lambda", "cloudflare-workers", "browser"]); + export class OutputContractValidator { validate(contract: OutputContract): ValidationReport { const results: ValidationResult[] = []; - const path = `contract:${contract.presetName}`; + this.validateOutputContract(contract, results); + + return { + contractName: contract.presetName, + passed: hasNoErrors(results), + results, + }; + } + + validateDeployTarget(target: DeployTarget): ValidationReport { + const results: ValidationResult[] = []; + this.validateDeployTargetShape(target, results); + + return { + contractName: target.target, + passed: hasNoErrors(results), + results, + }; + } + + validateGeneratedRuntimeProfile(profile: GeneratedRuntimeProfile): ValidationReport { + const results: ValidationResult[] = []; + this.validateGeneratedRuntimeProfileShape(profile, results); + + return { + contractName: + isRecord(profile) && isNonEmptyString(profile.name) ? profile.name : "profile:", + passed: hasNoErrors(results), + results, + }; + } + + validateGeneratedRuntimeProfileCatalog( + catalog: GeneratedRuntimeProfileCatalog, + options: RuntimeClaimValidationOptions = {}, + ): ValidationReport { + const results: ValidationResult[] = []; + const profileNames = new Set(); + const profileRuntimes = new Set(); + + if (catalog.schemaVersion !== 1) { + results.push({ + path: "profile-catalog:schemaVersion", + severity: "error", + message: "Generated runtime profile catalog schemaVersion must be 1", + }); + } + if (typeof catalog.validationCommand !== "string" || catalog.validationCommand.length === 0) { + results.push({ + path: "profile-catalog:validationCommand", + severity: "error", + message: "Generated runtime profile catalog validationCommand is required", + }); + } + const profiles = Array.isArray(catalog.profiles) ? catalog.profiles : []; + if (profiles.length === 0) { + results.push({ + path: "profile-catalog:profiles", + severity: "error", + message: "Generated runtime profile catalog must define at least one profile", + }); + } + + for (const profile of profiles) { + this.validateGeneratedRuntimeProfileShape(profile, results); + + if (!isRecord(profile)) { + continue; + } + + if (isNonEmptyString(profile.name)) { + if (profileNames.has(profile.name)) { + results.push({ + path: `profile:${profile.name}`, + severity: "error", + message: `Generated runtime profile '${profile.name}' is duplicated`, + }); + } + profileNames.add(profile.name); + } + + if (isPresentationRuntime(profile.runtime)) { + profileRuntimes.add(profile.runtime); + } + } + + for (const runtime of options.claimedRuntimes ?? []) { + if (!isPresentationRuntime(runtime)) { + results.push({ + path: `profile-catalog:claimed-runtime:${runtime}`, + severity: "error", + message: `Catalog runtime claim '${runtime}' is not a supported presentation runtime`, + }); + continue; + } + + if (!profileRuntimes.has(runtime)) { + results.push({ + path: `profile-catalog:claimed-runtime:${runtime}`, + severity: "error", + message: `Catalog runtime claim '${runtime}' has no generated runtime profile evidence`, + }); + } + } + + return { + contractName: "presentation-runtime-profiles", + passed: hasNoErrors(results), + results, + }; + } + + private validateOutputContract(contract: OutputContract, results: ValidationResult[]): void { + if (!isRecord(contract)) { + results.push({ + path: "contract:", + severity: "error", + message: "Output contract must be an object", + }); + return; + } + + const path = isNonEmptyString(contract.presetName) + ? `contract:${contract.presetName}` + : "contract:"; - if (!contract.presetName) { + if (!isNonEmptyString(contract.presetName)) { results.push({ path, severity: "error", message: "presetName is required" }); } - if (!contract.buildTime) { + if (!isNonEmptyString(contract.buildTime)) { results.push({ path, severity: "error", message: "buildTime is required" }); } - if (!contract.format) { + if (!isNonEmptyString(contract.format)) { results.push({ path, severity: "error", message: "format is required" }); + } else if (!isArtifactFormat(contract.format)) { + results.push({ + path, + severity: "error", + message: `format '${contract.format}' is not supported`, + }); } - if (!contract.artifacts || contract.artifacts.length === 0) { + + const artifacts = Array.isArray(contract.artifacts) ? contract.artifacts : []; + if (!Array.isArray(contract.artifacts)) { + results.push({ path, severity: "error", message: "artifacts must be an array" }); + } else if (artifacts.length === 0) { results.push({ path, severity: "warning", - message: "No artifacts defined — contract is empty", + message: "No artifacts defined - contract is empty", }); } - if (!contract.entries || contract.entries.length === 0) { + const entries = Array.isArray(contract.entries) ? contract.entries : []; + if (!Array.isArray(contract.entries)) { + results.push({ path, severity: "error", message: "entries must be an array" }); + } else if (entries.length === 0) { results.push({ path, severity: "error", message: "At least one entry point is required" }); } - if (contract.artifacts) { - for (const artifact of contract.artifacts) { + if (Array.isArray(contract.artifacts)) { + for (const artifact of artifacts) { this.validateArtifact(artifact, results); } } - if (contract.entries) { - for (const entry of contract.entries) { + if (Array.isArray(contract.entries)) { + for (const entry of entries) { this.validateEntry(entry, results); } } - this.crossCheckEntriesVsArtifacts(contract, results); - - return { - contractName: contract.presetName, - passed: results.filter((result) => result.severity === "error").length === 0, - results, - }; + if (Array.isArray(contract.artifacts) && Array.isArray(contract.entries)) { + this.crossCheckEntriesVsArtifacts(contract, results); + } } private validateArtifact(artifact: BuildArtifact, results: ValidationResult[]): void { - const path = `artifact:${artifact.path}`; - if (!artifact.path) { + if (!isRecord(artifact)) { + results.push({ + path: "artifact:", + severity: "error", + message: "Artifact must be an object", + }); + return; + } + + const path = isNonEmptyString(artifact.path) + ? `artifact:${artifact.path}` + : "artifact:"; + if (!isNonEmptyString(artifact.path)) { results.push({ path, severity: "error", message: "Artifact path is required" }); } - if (!artifact.format) { + if (!isNonEmptyString(artifact.format)) { results.push({ path, severity: "error", message: `Artifact '${artifact.path}' missing format`, }); + } else if (!isArtifactFormat(artifact.format)) { + results.push({ + path, + severity: "error", + message: `Artifact '${artifact.path}' has unsupported format '${artifact.format}'`, + }); } - if (!artifact.type) { + if (!isNonEmptyString(artifact.type)) { results.push({ path, severity: "error", message: `Artifact '${artifact.path}' missing type`, }); + } else if (!isArtifactType(artifact.type)) { + results.push({ + path, + severity: "error", + message: `Artifact '${artifact.path}' has unsupported type '${artifact.type}'`, + }); } } private validateEntry(entry: EntryDescriptor, results: ValidationResult[]): void { - const path = `entry:${entry.exportName}`; - if (!entry.exportName) { + if (!isRecord(entry)) { + results.push({ + path: "entry:", + severity: "error", + message: "Entry must be an object", + }); + return; + } + + const path = isNonEmptyString(entry.exportName) + ? `entry:${entry.exportName}` + : "entry:"; + if (!isNonEmptyString(entry.exportName)) { results.push({ path, severity: "error", message: "Entry export name is required" }); } - if (!entry.main) { + if (!isNonEmptyString(entry.main)) { results.push({ path, severity: "error", message: `Entry '${entry.exportName}' missing main file`, }); } - if (!entry.types) { + if (!isNonEmptyString(entry.types)) { results.push({ path, severity: "error", @@ -106,17 +289,26 @@ export class OutputContractValidator { contract: OutputContract, results: ValidationResult[], ): void { - const artifactPaths = new Set(contract.artifacts.map((artifact) => artifact.path)); + const artifactPaths = new Set(); + for (const artifact of contract.artifacts) { + if (isRecord(artifact) && isNonEmptyString(artifact.path)) { + artifactPaths.add(artifact.path); + } + } const referencedPaths = new Set(); for (const entry of contract.entries) { - if (entry.main) { + if (!isRecord(entry)) { + continue; + } + + if (isNonEmptyString(entry.main)) { referencedPaths.add(entry.main); } - if (entry.cjs) { + if (isNonEmptyString(entry.cjs)) { referencedPaths.add(entry.cjs); } - if (entry.types) { + if (isNonEmptyString(entry.types)) { referencedPaths.add(entry.types); } } @@ -131,4 +323,187 @@ export class OutputContractValidator { } } } + + private validateDeployTargetShape(target: DeployTarget, results: ValidationResult[]): void { + if (!isRecord(target)) { + results.push({ + path: "target:", + severity: "error", + message: "Deploy target must be an object", + }); + return; + } + + const path = isNonEmptyString(target.target) ? `target:${target.target}` : "target:"; + if (!isNonEmptyString(target.target)) { + results.push({ path, severity: "error", message: "Deploy target is required" }); + } + + if ( + target.requiredEnvVars !== undefined && + (!Array.isArray(target.requiredEnvVars) || + !target.requiredEnvVars.every((envVar) => isNonEmptyString(envVar))) + ) { + results.push({ + path, + severity: "error", + message: "Deploy target requiredEnvVars must contain non-empty strings", + }); + } + + if (target.runtime !== undefined) { + if (!isRecord(target.runtime)) { + results.push({ + path, + severity: "error", + message: "Deploy target runtime metadata must be an object when provided", + }); + } else { + if ( + target.runtime.nodeVersion !== undefined && + !isNonEmptyString(target.runtime.nodeVersion) + ) { + results.push({ + path, + severity: "error", + message: "Deploy target runtime.nodeVersion must be non-empty when provided", + }); + } + if (target.runtime.memory !== undefined && !isPositiveNumber(target.runtime.memory)) { + results.push({ + path, + severity: "error", + message: "Deploy target runtime.memory must be greater than 0 when provided", + }); + } + if (target.runtime.timeout !== undefined && !isPositiveNumber(target.runtime.timeout)) { + results.push({ + path, + severity: "error", + message: "Deploy target runtime.timeout must be greater than 0 when provided", + }); + } + } + } + + if (!isRecord(target.output)) { + results.push({ path, severity: "error", message: "Deploy target output is required" }); + return; + } + + this.validateOutputContract(target.output, results); + } + + private validateGeneratedRuntimeProfileShape( + profile: GeneratedRuntimeProfile, + results: ValidationResult[], + ): void { + if (!isRecord(profile)) { + results.push({ + path: "profile:", + severity: "error", + message: "Generated runtime profile must be an object", + }); + return; + } + + const path = isNonEmptyString(profile.name) ? `profile:${profile.name}` : "profile:"; + + if (!isNonEmptyString(profile.name)) { + results.push({ + path, + severity: "error", + message: "Generated runtime profile name is required", + }); + } + if (!isNonEmptyString(profile.runtime)) { + results.push({ + path, + severity: "error", + message: "Generated runtime profile runtime is required", + }); + } else if (!isPresentationRuntime(profile.runtime)) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' has unsupported runtime '${profile.runtime}'`, + }); + } + if (!isNonEmptyString(profile.packageTestName)) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' must name its package test evidence`, + }); + } + if (!isNonEmptyString(profile.generatedAppSmokeCase)) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' must name its generated app smoke case`, + }); + } + if (!isNonEmptyString(profile.generatedAppSmokeCommand)) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' must name its generated app smoke command`, + }); + } else if ( + profile.generatedAppSmokeCase && + !profile.generatedAppSmokeCommand.includes(profile.generatedAppSmokeCase) + ) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' smoke command must include case '${profile.generatedAppSmokeCase}'`, + }); + } + + if (!isRecord(profile.target)) { + results.push({ + path, + severity: "error", + message: `Generated runtime profile '${profile.name}' must include deploy target metadata`, + }); + return; + } + + if (profile.runtime && profile.target.target && profile.target.target !== profile.runtime) { + results.push({ + path: `target:${profile.target.target}`, + severity: "error", + message: `Generated runtime profile '${profile.name}' target '${profile.target.target}' does not match runtime '${profile.runtime}'`, + }); + } + this.validateDeployTargetShape(profile.target, results); + } +} + +function isArtifactFormat(value: string): boolean { + return ARTIFACT_FORMATS.has(value); +} + +function isArtifactType(value: string): boolean { + return ARTIFACT_TYPES.has(value); +} + +function isPresentationRuntime(value: unknown): value is PresentationRuntime { + return typeof value === "string" && PRESENTATION_RUNTIMES.has(value); +} + +function hasNoErrors(results: readonly ValidationResult[]): boolean { + return results.filter((result) => result.severity === "error").length === 0; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isPositiveNumber(value: unknown): value is number { + return typeof value === "number" && value > 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/packages/presentation-preset/src/output-contract.ts b/packages/presentation-preset/src/output-contract.ts index a547a3836..083568d14 100644 --- a/packages/presentation-preset/src/output-contract.ts +++ b/packages/presentation-preset/src/output-contract.ts @@ -3,8 +3,9 @@ * - 'esm': ECMAScript Module (.mjs or .js with "type": "module") * - 'cjs': CommonJS (.cjs or .js with "type": "commonjs") * - 'dual': Both ESM and CJS formats + * - 'neutral': Runtime-independent config, type, or static asset */ -export type ArtifactFormat = "esm" | "cjs" | "dual"; +export type ArtifactFormat = "esm" | "cjs" | "dual" | "neutral"; /** * Type of build artifact @@ -80,3 +81,26 @@ export type DeployTarget = { /** Output contract this target uses */ readonly output: OutputContract; }; + +export type PresentationRuntime = "node" | "lambda" | "cloudflare-workers" | "browser"; + +export type GeneratedRuntimeProfile = { + /** Stable generated profile name used in tests and docs */ + readonly name: string; + /** Runtime claim this generated profile proves for the package catalog */ + readonly runtime: PresentationRuntime; + /** Named package test that validates this profile contract */ + readonly packageTestName: string; + /** create-croco-app generated smoke case that exercises this profile */ + readonly generatedAppSmokeCase: string; + /** Focused command for re-running the generated smoke evidence */ + readonly generatedAppSmokeCommand: string; + /** Runtime target metadata and expected output contract for the profile */ + readonly target: DeployTarget; +}; + +export type GeneratedRuntimeProfileCatalog = { + readonly schemaVersion: 1; + readonly validationCommand: string; + readonly profiles: readonly GeneratedRuntimeProfile[]; +}; diff --git a/packages/presentation-preset/tsup.config.ts b/packages/presentation-preset/tsup.config.ts index 5dd304ffd..54675a069 100644 --- a/packages/presentation-preset/tsup.config.ts +++ b/packages/presentation-preset/tsup.config.ts @@ -1,5 +1,9 @@ +import { copyFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; import { defineConfig } from "tsup"; +const RUNTIME_PROFILES_OUTPUT_PATH = "dist/runtime-profiles.json"; + export default defineConfig({ entry: ["src/index.ts"], format: ["esm", "cjs"], @@ -7,4 +11,8 @@ export default defineConfig({ clean: true, minify: false, sourcemap: false, + onSuccess: async () => { + mkdirSync(dirname(RUNTIME_PROFILES_OUTPUT_PATH), { recursive: true }); + copyFileSync("runtime-profiles.json", RUNTIME_PROFILES_OUTPUT_PATH); + }, }); diff --git a/packages/presentation-preset/vitest.config.ts b/packages/presentation-preset/vitest.config.ts index 0fe16ca70..1481f9361 100644 --- a/packages/presentation-preset/vitest.config.ts +++ b/packages/presentation-preset/vitest.config.ts @@ -1,6 +1,26 @@ +import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "@croco/diagnostics-core": resolve(__dirname, "../diagnostics-core/src/index.ts"), + "@croco/events-core": resolve(__dirname, "../events-core/src/index.ts"), + "@croco/framework-context": resolve(__dirname, "../framework-context/src/index.ts"), + "@croco/framework-logger": resolve(__dirname, "../framework-logger/src/index.ts"), + "@croco/framework-preset": resolve(__dirname, "../framework-preset/src/index.ts"), + "@croco/health-core": resolve(__dirname, "../health-core/src/index.ts"), + "@croco/preset-cloudflare": resolve(__dirname, "../preset-cloudflare/src/index.ts"), + "@croco/preset-cloudflare/src/fetch": resolve(__dirname, "../preset-cloudflare/src/fetch.ts"), + "@croco/preset-lambda": resolve(__dirname, "../preset-lambda/src/index.ts"), + "@croco/preset-node": resolve(__dirname, "../preset-node/src/index.ts"), + "@croco/problems-core": resolve(__dirname, "../problems-core/src/index.ts"), + "@croco/protocols-core": resolve(__dirname, "../protocols-core/src/index.ts"), + "@croco/protocols-rest": resolve(__dirname, "../protocols-rest/src/index.ts"), + "@croco/ratelimit-core": resolve(__dirname, "../ratelimit-core/src/index.ts"), + "@croco/transports-http": resolve(__dirname, "../transports-http/src/index.ts"), + }, + }, test: { include: ["src/**/*.spec.ts"], }, diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index 63dc210e2..3064bf9fe 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -8402,11 +8402,31 @@ "exportKind": "named", "source": "./output-contract" }, + { + "name": "GeneratedRuntimeProfile", + "exportKind": "named", + "source": "./output-contract" + }, + { + "name": "GeneratedRuntimeProfileCatalog", + "exportKind": "named", + "source": "./output-contract" + }, { "name": "OutputContract", "exportKind": "named", "source": "./output-contract" }, + { + "name": "PresentationRuntime", + "exportKind": "named", + "source": "./output-contract" + }, + { + "name": "RuntimeClaimValidationOptions", + "exportKind": "named", + "source": "./output-contract-validator" + }, { "name": "ValidationReport", "exportKind": "named", diff --git a/scripts/package-docs-check.mts b/scripts/package-docs-check.mts index f20962380..da167177d 100644 --- a/scripts/package-docs-check.mts +++ b/scripts/package-docs-check.mts @@ -76,6 +76,12 @@ type ExtensionMatrixState = { readonly packages: readonly ExtensionRecord[]; }; +type RuntimeProfileCatalog = { + readonly schemaVersion?: unknown; + readonly validationCommand?: unknown; + readonly profiles?: unknown; +}; + type DocsBaseline = { readonly schemaVersion?: unknown; readonly allowedMissingApiDocs?: unknown; @@ -113,6 +119,11 @@ 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 presentationRuntimeProfilesPath = join( + "packages", + "presentation-preset", + "runtime-profiles.json", +); const publicDocsRootPath = join("packages", "docs", "src", "content", "docs", "en"); const architectureGuidePath = join(publicDocsRootPath, "guides", "architecture.mdx"); const extensionMatrixDocsPath = join( @@ -128,6 +139,8 @@ const extensionMatrixDocsPath = join( const readmePath = "README.md"; const maturityOrder = ["production", "beta", "alpha", "deprecated"] as const; const runtimeOrder = ["node", "lambda", "cloudflare-workers", "browser"] as const; +const artifactFormatOrder = ["esm", "cjs", "dual", "neutral"] as const; +const artifactTypeOrder = ["code", "types", "config", "asset"] as const; const scriptRootDir = dirname(dirname(fileURLToPath(import.meta.url))); type MaturityKey = (typeof maturityOrder)[number]; @@ -162,6 +175,7 @@ function run(options: Options): string[] { const baseline = loadDocsBaseline(options.rootDir, state.packages, violations); const coverage = getCoverageSet(state.packages); validateCoverageBaseline(coverage, baseline, violations); + validatePresentationPresetRuntimeEvidence(options.rootDir, state, violations); const generatedCatalog = formatMarkdown(readmePath, generateReadmeCatalog(state)); const generatedExtensionMatrixDocs = formatMarkdown( @@ -755,6 +769,373 @@ function validateCoverageBaseline( ); } +function validatePresentationPresetRuntimeEvidence( + rootDir: string, + state: CatalogState, + violations: string[], +): void { + const presentationPreset = state.extensionMatrix.packages.find( + (pkg) => pkg.shortName === "presentation-preset", + ); + if (!presentationPreset) { + return; + } + + const profileCatalogPath = join(rootDir, presentationRuntimeProfilesPath); + if (!existsSync(profileCatalogPath)) { + violations.push( + `${presentationRuntimeProfilesPath}: must exist to prove @croco/presentation-preset runtime claims`, + ); + return; + } + + const profileCatalog = readJsonFile(profileCatalogPath); + validateRuntimeProfileCatalog(profileCatalog, presentationPreset.extension.runtimes, violations); +} + +function validateRuntimeProfileCatalog( + profileCatalog: RuntimeProfileCatalog, + claimedRuntimes: readonly RuntimeKey[], + violations: string[], +): void { + if (profileCatalog.schemaVersion !== 1) { + addRuntimeProfileViolation( + violations, + "Generated runtime profile catalog schemaVersion must be 1", + ); + } + if ( + typeof profileCatalog.validationCommand !== "string" || + profileCatalog.validationCommand.length === 0 + ) { + addRuntimeProfileViolation( + violations, + "Generated runtime profile catalog validationCommand is required", + ); + } + + const profiles = Array.isArray(profileCatalog.profiles) ? profileCatalog.profiles : []; + if (profiles.length === 0) { + addRuntimeProfileViolation( + violations, + "Generated runtime profile catalog must define at least one profile", + ); + } + + const profileNames = new Set(); + const profileRuntimes = new Set(); + for (const [index, profileValue] of profiles.entries()) { + if (!isRecord(profileValue)) { + addRuntimeProfileViolation(violations, `profiles[${index}] must be an object`); + continue; + } + + validateRuntimeProfile(index, profileValue, profileNames, profileRuntimes, violations); + } + + for (const runtime of claimedRuntimes) { + if (!profileRuntimes.has(runtime)) { + addRuntimeProfileViolation( + violations, + `Catalog runtime claim '${runtime}' has no generated runtime profile evidence`, + ); + } + } +} + +function validateRuntimeProfile( + index: number, + profile: Readonly>, + profileNames: Set, + profileRuntimes: Set, + violations: string[], +): void { + const name = readRuntimeProfileString(profile, "name", index, violations); + const runtime = readRuntimeProfileString(profile, "runtime", index, violations); + readRuntimeProfileString(profile, "packageTestName", index, violations); + const smokeCase = readRuntimeProfileString(profile, "generatedAppSmokeCase", index, violations); + const smokeCommand = readRuntimeProfileString( + profile, + "generatedAppSmokeCommand", + index, + violations, + ); + + if (name) { + if (profileNames.has(name)) { + addRuntimeProfileViolation(violations, `Generated runtime profile '${name}' is duplicated`); + } + profileNames.add(name); + } + if (runtime) { + if (!runtimeOrder.includes(runtime as RuntimeKey)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${name}' has unsupported runtime '${runtime}'`, + ); + } else { + profileRuntimes.add(runtime); + } + } + if (smokeCase && smokeCommand && !smokeCommand.includes(smokeCase)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${name}' smoke command must include case '${smokeCase}'`, + ); + } + + const target = profile.target; + if (!isRecord(target)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${name || index}' must include deploy target metadata`, + ); + return; + } + + validateRuntimeProfileTarget(name || String(index), runtime, target, violations); +} + +function validateRuntimeProfileTarget( + profileName: string, + runtime: string, + target: Readonly>, + violations: string[], +): void { + const targetName = readRequiredRuntimeProfileString( + target.target, + `Generated runtime profile '${profileName}' deploy target is required`, + violations, + ); + if (targetName && runtime && targetName !== runtime) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' target '${targetName}' does not match runtime '${runtime}'`, + ); + } + + if (target.requiredEnvVars !== undefined && !isStringArray(target.requiredEnvVars)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' requiredEnvVars must be a string array`, + ); + } + + if (target.runtime !== undefined && !isRecord(target.runtime)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' runtime must be an object when provided`, + ); + } + + if (isRecord(target.runtime)) { + const nodeVersion = target.runtime.nodeVersion; + const memory = target.runtime.memory; + const timeout = target.runtime.timeout; + if ( + nodeVersion !== undefined && + (typeof nodeVersion !== "string" || nodeVersion.length === 0) + ) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' runtime.nodeVersion must be non-empty when provided`, + ); + } + if (memory !== undefined && (typeof memory !== "number" || memory <= 0)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' runtime.memory must be greater than 0 when provided`, + ); + } + if (timeout !== undefined && (typeof timeout !== "number" || timeout <= 0)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' runtime.timeout must be greater than 0 when provided`, + ); + } + } + + if (!isRecord(target.output)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' deploy target output is required`, + ); + return; + } + + validateRuntimeProfileOutput(profileName, target.output, violations); +} + +function validateRuntimeProfileOutput( + profileName: string, + output: Readonly>, + violations: string[], +): void { + readRequiredRuntimeProfileString( + output.presetName, + `Generated runtime profile '${profileName}' output presetName is required`, + violations, + ); + readRequiredRuntimeProfileString( + output.buildTime, + `Generated runtime profile '${profileName}' output buildTime is required`, + violations, + ); + + const format = readRequiredRuntimeProfileString( + output.format, + `Generated runtime profile '${profileName}' output format is required`, + violations, + ); + if (format && !artifactFormatOrder.includes(format as (typeof artifactFormatOrder)[number])) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' output format '${format}' is not supported`, + ); + } + + const artifactPaths = new Set(); + const artifacts = Array.isArray(output.artifacts) ? output.artifacts : []; + if (artifacts.length === 0) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' output must define artifacts`, + ); + } + for (const [index, artifact] of artifacts.entries()) { + if (!isRecord(artifact)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' artifact[${index}] must be an object`, + ); + continue; + } + validateRuntimeProfileArtifact(profileName, index, artifact, artifactPaths, violations); + } + + const entries = Array.isArray(output.entries) ? output.entries : []; + if (entries.length === 0) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' output must define entries`, + ); + } + for (const [index, entry] of entries.entries()) { + if (!isRecord(entry)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' entry[${index}] must be an object`, + ); + continue; + } + validateRuntimeProfileEntry(profileName, index, entry, artifactPaths, violations); + } +} + +function validateRuntimeProfileArtifact( + profileName: string, + index: number, + artifact: Readonly>, + artifactPaths: Set, + violations: string[], +): void { + const path = readRequiredRuntimeProfileString( + artifact.path, + `Generated runtime profile '${profileName}' artifact[${index}].path is required`, + violations, + ); + const format = readRequiredRuntimeProfileString( + artifact.format, + `Generated runtime profile '${profileName}' artifact[${index}].format is required`, + violations, + ); + const type = readRequiredRuntimeProfileString( + artifact.type, + `Generated runtime profile '${profileName}' artifact[${index}].type is required`, + violations, + ); + + if (path) { + artifactPaths.add(path); + } + if (format && !artifactFormatOrder.includes(format as (typeof artifactFormatOrder)[number])) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' artifact '${path}' has unsupported format '${format}'`, + ); + } + if (type && !artifactTypeOrder.includes(type as (typeof artifactTypeOrder)[number])) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' artifact '${path}' has unsupported type '${type}'`, + ); + } +} + +function validateRuntimeProfileEntry( + profileName: string, + index: number, + entry: Readonly>, + artifactPaths: ReadonlySet, + violations: string[], +): void { + readRequiredRuntimeProfileString( + entry.exportName, + `Generated runtime profile '${profileName}' entry[${index}].exportName is required`, + violations, + ); + const main = readRequiredRuntimeProfileString( + entry.main, + `Generated runtime profile '${profileName}' entry[${index}].main is required`, + violations, + ); + const types = readRequiredRuntimeProfileString( + entry.types, + `Generated runtime profile '${profileName}' entry[${index}].types is required`, + violations, + ); + const cjs = typeof entry.cjs === "string" ? entry.cjs : ""; + + for (const referencedPath of [main, cjs, types].filter(Boolean)) { + if (!artifactPaths.has(referencedPath)) { + addRuntimeProfileViolation( + violations, + `Generated runtime profile '${profileName}' entry references '${referencedPath}' but no matching artifact exists`, + ); + } + } +} + +function readRuntimeProfileString( + profile: Readonly>, + key: string, + index: number, + violations: string[], +): string { + return readRequiredRuntimeProfileString( + profile[key], + `Generated runtime profile profiles[${index}].${key} is required`, + violations, + ); +} + +function readRequiredRuntimeProfileString( + value: unknown, + message: string, + violations: string[], +): string { + if (typeof value !== "string" || value.length === 0) { + addRuntimeProfileViolation(violations, message); + return ""; + } + + return value; +} + +function addRuntimeProfileViolation(violations: string[], message: string): void { + violations.push(`${presentationRuntimeProfilesPath}: ${message}`); +} + function validateProductionApiDocsBaseline( missingApiDocs: readonly PackageRecord[], baseline: Baseline, @@ -1319,3 +1700,7 @@ function readRequiredFile(filePath: string): string { function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string"); } + +function isRecord(value: unknown): value is Readonly> { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/package-entrypoint-smoke.mts b/scripts/package-entrypoint-smoke.mts index 72d82dc5c..72e6ea2e0 100644 --- a/scripts/package-entrypoint-smoke.mts +++ b/scripts/package-entrypoint-smoke.mts @@ -46,6 +46,7 @@ type PackageInfo = { type SmokeTarget = { readonly fieldName: string; + readonly kind: "json" | "module"; readonly specifier: string; readonly target: string; }; @@ -536,6 +537,10 @@ function pushConditionalTarget( targets: SmokeTarget[], ): void { if (typeof value === "string") { + if (condition === "types" && isJsonTargetPath(value)) { + return; + } + pushStringTarget(specifier, value, fieldName, packageInfo, diagnostics, targets); return; } @@ -551,6 +556,9 @@ function pushConditionalTarget( if (target === undefined && condition === "require") { return; } + if (condition === "types" && typeof target === "string" && isJsonTargetPath(target)) { + return; + } pushStringTarget(specifier, target, fieldName, packageInfo, diagnostics, targets); } @@ -581,7 +589,16 @@ function pushStringTarget( return; } - targets.push({ fieldName, specifier, target }); + targets.push({ + fieldName, + kind: isJsonTargetPath(target) ? "json" : "module", + specifier, + target, + }); +} + +function isJsonTargetPath(target: string): boolean { + return target.endsWith(".json"); } function writeEsmConsumer(smokeRoot: string, targets: readonly SmokeTarget[]): void { @@ -590,11 +607,18 @@ function writeEsmConsumer(smokeRoot: string, targets: readonly SmokeTarget[]): v [ 'process.env.SKIP_ENV_VALIDATION = "true";', "const targets = [", - ...targets.map((target) => ` ${JSON.stringify(target.specifier)},`), + ...targets.map( + (target) => + ` ${JSON.stringify({ json: target.kind === "json", specifier: target.specifier })},`, + ), "];", "for (const target of targets) {", - " await import(target);", - " console.log(`esm ok ${target}`);", + " if (target.json) {", + ' await import(target.specifier, { with: { type: "json" } });', + " } else {", + " await import(target.specifier);", + " }", + " console.log(`esm ok ${target.specifier}`);", "}", "", ].join("\n"), diff --git a/scripts/tests/package-docs-check.spec.ts b/scripts/tests/package-docs-check.spec.ts index 1feba6cde..03ac62062 100644 --- a/scripts/tests/package-docs-check.spec.ts +++ b/scripts/tests/package-docs-check.spec.ts @@ -217,6 +217,77 @@ describe("package-docs-check.mts", () => { ); }); + it("fails when presentation-preset claims a runtime without generated profile evidence", () => { + const root = createTempRoot(); + writePackage(root, "presentation-preset", { name: "@croco/presentation-preset" }); + writePresentationRuntimeProfileCatalog(root, ["node"]); + writeCatalogMetadata(root, ["presentation-preset"], { + extensionRuntimesByPackage: { + "presentation-preset": ["node", "browser"], + }, + }); + writeDocsBaseline(root, { + allowedMissingApiDocs: ["presentation-preset"], + allowedMissingReadme: [], + allowedMissingTests: [], + }); + + const result = runScript(root, "--write"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "packages/presentation-preset/runtime-profiles.json: Catalog runtime claim 'browser' has no generated runtime profile evidence", + ); + }); + + it("accepts presentation-preset runtime claims with generated profile evidence", () => { + const root = createTempRoot(); + writePackage(root, "presentation-preset", { name: "@croco/presentation-preset" }); + writePresentationRuntimeProfileCatalog(root, ["node", "browser"]); + writeCatalogMetadata(root, ["presentation-preset"], { + extensionRuntimesByPackage: { + "presentation-preset": ["node", "browser"], + }, + }); + writeDocsBaseline(root, { + allowedMissingApiDocs: ["presentation-preset"], + allowedMissingReadme: [], + allowedMissingTests: [], + }); + + const result = runScript(root, "--write"); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "package-docs-check: package catalog and documentation report are in sync.", + ); + }); + + it("fails when presentation-preset runtime metadata is defined as a non-object value", () => { + const root = createTempRoot(); + writePackage(root, "presentation-preset", { name: "@croco/presentation-preset" }); + writePresentationRuntimeProfileCatalog(root, ["node"], { + runtimeMetadata: "node20", + }); + writeCatalogMetadata(root, ["presentation-preset"], { + extensionRuntimesByPackage: { + "presentation-preset": ["node"], + }, + }); + writeDocsBaseline(root, { + allowedMissingApiDocs: ["presentation-preset"], + allowedMissingReadme: [], + allowedMissingTests: [], + }); + + const result = runScript(root, "--write"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "packages/presentation-preset/runtime-profiles.json: Generated runtime profile 'node-fixture' runtime must be an object when provided", + ); + }); + it("fails when public architecture docs use stale layer text or missing package names", () => { const root = createTempRoot(); writePackage(root, "alpha", { name: "@croco/alpha" }); @@ -345,6 +416,7 @@ function writeCatalogMetadata( options: { readonly extensionGroups?: readonly string[]; readonly extensionPackages?: readonly string[]; + readonly extensionRuntimesByPackage?: Record; readonly groupName?: string; readonly productionPackages?: readonly string[]; } = {}, @@ -392,7 +464,7 @@ function writeCatalogMetadata( domain: "Fixture", features: ["Fixture feature"], requiredEnv: ["none"], - runtimes: ["node"], + runtimes: options.extensionRuntimesByPackage?.[packageName] ?? ["node"], }, ]), ), @@ -400,6 +472,45 @@ function writeCatalogMetadata( }); } +function writePresentationRuntimeProfileCatalog( + root: string, + runtimes: readonly string[], + options: { readonly runtimeMetadata?: unknown } = {}, +): void { + writeJson(join(root, "packages", "presentation-preset", "runtime-profiles.json"), { + schemaVersion: 1, + validationCommand: "pnpm --filter @croco/presentation-preset test", + profiles: runtimes.map((runtime) => ({ + name: `${runtime}-fixture`, + runtime, + packageTestName: `validates ${runtime}`, + generatedAppSmokeCase: `${runtime}-smoke`, + generatedAppSmokeCommand: `CROCO_GENERATED_SMOKE_CASES=${runtime}-smoke pnpm create-croco-app:smoke`, + target: { + target: runtime, + requiredEnvVars: [], + ...(options.runtimeMetadata !== undefined ? { runtime: options.runtimeMetadata } : {}), + output: { + presetName: `presentation-preset/${runtime}-fixture`, + buildTime: "2026-01-01T00:00:00.000Z", + format: "esm", + artifacts: [ + { path: `${runtime}/index.js`, format: "esm", type: "code" }, + { path: `${runtime}/index.d.ts`, format: "neutral", type: "types" }, + ], + entries: [ + { + exportName: ".", + main: `${runtime}/index.js`, + types: `${runtime}/index.d.ts`, + }, + ], + }, + }, + })), + }); +} + function writeDocsBaseline( root: string, baseline: {