diff --git a/.github/workflows/binding-publish-npm.yml b/.github/workflows/binding-publish-npm.yml new file mode 100644 index 0000000..cdb1cae --- /dev/null +++ b/.github/workflows/binding-publish-npm.yml @@ -0,0 +1,48 @@ +name: Publish TypeScript bindings (placeholder, publishes nothing) + +on: + workflow_dispatch: + inputs: + targets: + description: 'Comma-separated package-key:typescript targets, or all' + type: string + default: '' + dry_run: + description: 'Must stay true: the workflow is a placeholder and verifies and publishes nothing' + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: binding-publish-npm + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: Reject real publication + if: ${{ !inputs.dry_run }} + run: | + echo "::error::npm publication is not implemented. Run with dry_run enabled; nothing was published." + exit 1 + - name: Record that nothing was verified or published + env: + TARGETS: ${{ inputs.targets }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + { + echo "## Placeholder: nothing was verified or published" + echo + echo "npm publication is not implemented. This run did not verify, build, or publish any package." + echo + echo "- targets: \`${TARGETS:-}\`" + echo "- dry_run: \`${DRY_RUN}\`" + } >> "$GITHUB_STEP_SUMMARY" + echo "::notice::Placeholder only. No package was verified or published." diff --git a/.github/workflows/extension-binding-normalizer.yml b/.github/workflows/extension-binding-normalizer.yml index d978455..ec2228d 100644 --- a/.github/workflows/extension-binding-normalizer.yml +++ b/.github/workflows/extension-binding-normalizer.yml @@ -8,6 +8,7 @@ on: - "tooling/extension-bindings/IMPLEMENTATION.md" - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" + - "tooling/extension-bindings/emitters/typescript/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" push: @@ -18,6 +19,7 @@ on: - "tooling/extension-bindings/IMPLEMENTATION.md" - "tooling/extension-bindings/README.md" - "tooling/extension-bindings/emitters/go/**" + - "tooling/extension-bindings/emitters/typescript/**" - "tooling/extension-bindings/model/**" - "tooling/extension-bindings/normalizer/**" workflow_dispatch: @@ -81,3 +83,31 @@ jobs: - name: Vet Go emitter run: go vet ./... + + verify-typescript-emitter: + name: Verify TypeScript emitter + runs-on: ubuntu-latest + timeout-minutes: 15 + + defaults: + run: + working-directory: tooling/extension-bindings/emitters/typescript + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 20.19.6 + cache: npm + cache-dependency-path: tooling/extension-bindings/emitters/typescript/package-lock.json + + - name: Install the pinned emitter dependencies + run: npm ci --ignore-scripts + + - name: Test the TypeScript emitter and generated conformance packages + run: npm test diff --git a/tooling/extension-bindings/README.md b/tooling/extension-bindings/README.md index faf15a2..e977c4c 100644 --- a/tooling/extension-bindings/README.md +++ b/tooling/extension-bindings/README.md @@ -2,7 +2,8 @@ `IMPLEMENTATION.md` is historical design guidance; the repository owner's standing overrides govern generation and publication. -The shared Go resolver and normalizer feed native Go and Python emitters. +The shared Go resolver and normalizer feed native Go and Python emitters. A native TypeScript +emitter lives under `emitters/typescript/`. The Go orchestrator exposes eight commands through Go's standard `flag` package. GitHub Actions generates and commits Go and Python bindings to `main`, then a separate manual workflow publishes verified Go module tags and release assets. @@ -142,6 +143,7 @@ For local checks: ```sh python3 -m unittest discover -s tooling/common -p 'test_*.py' python3 -m unittest discover -s tooling/extension-bindings/ci -p 'test_*.py' +(cd tooling/extension-bindings/emitters/typescript && npm ci --ignore-scripts && npm test) python3 tooling/common/build_extensions_site.py --output /tmp/extension-site python3 tooling/extension-bindings/ci/publish.py configure --targets env-configuration:go ``` diff --git a/tooling/extension-bindings/emitters/typescript/.gitignore b/tooling/extension-bindings/emitters/typescript/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tooling/extension-bindings/emitters/typescript/.prettierrc.json b/tooling/extension-bindings/emitters/typescript/.prettierrc.json new file mode 100644 index 0000000..ec39f60 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/.prettierrc.json @@ -0,0 +1 @@ +{ "printWidth": 100, "semi": true, "singleQuote": false, "trailingComma": "all" } diff --git a/tooling/extension-bindings/emitters/typescript/package-lock.json b/tooling/extension-bindings/emitters/typescript/package-lock.json new file mode 100644 index 0000000..efb4164 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/package-lock.json @@ -0,0 +1,158 @@ +{ + "name": "@runtimeconditions/binding-emitter-typescript", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@runtimeconditions/binding-emitter-typescript", + "version": "0.1.0", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "prettier": "3.9.10", + "typescript": "5.9.3", + "yaml": "2.9.1" + }, + "bin": { + "runtimeconditions-binding-emitter-typescript": "dist/src/main.js" + }, + "devDependencies": { + "@types/node": "20.19.43" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/tooling/extension-bindings/emitters/typescript/package-target.schema.yaml b/tooling/extension-bindings/emitters/typescript/package-target.schema.yaml new file mode 100644 index 0000000..c5a9e53 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/package-target.schema.yaml @@ -0,0 +1,86 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://runtimeconditions.io/schemas/typescript-package-target/v1alpha1 +title: Runtime Conditions TypeScript package target +type: object +additionalProperties: false +required: + - apiVersion + - kind + - packageKey + - rootExtension + - packageName + - version + - sourceDirectory + - minimumNodeVersion + - publicationMode + - repositoryUrl +properties: + apiVersion: + const: runtimeconditions.io/typescript-package-target/v1alpha1 + kind: + const: RuntimeConditionsTypeScriptPackageTarget + packageKey: + type: string + pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' + rootExtension: + type: string + minLength: 1 + packageName: + description: The npm package name, which is also the module specifier consumers import. + type: string + maxLength: 214 + pattern: '^(?:@[a-z0-9][a-z0-9-]*/)?[a-z0-9][a-z0-9-]*$' + version: + type: string + pattern: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + sourceDirectory: + type: string + pattern: '^bindings/[a-z0-9]+(?:-[a-z0-9]+)*/typescript$' + minimumNodeVersion: + type: string + pattern: '^(?:2[0-9]|[3-9][0-9]|[1-9][0-9]{2,})\.(?:0|[1-9][0-9]*)$' + publicationMode: + enum: [github-tag, registry] + registryId: + type: string + minLength: 1 + repositoryUrl: + type: string + pattern: '^https://[^\s]+$' + emitterSha256: + description: Required when emitting package resources; supplied by the tooling caller. + type: string + pattern: '^[0-9a-f]{64}$' + dependencies: + type: array + items: + type: object + additionalProperties: false + required: [extension, packageName, version] + properties: + extension: + type: string + minLength: 1 + packageName: + type: string + maxLength: 214 + pattern: '^(?:@[a-z0-9][a-z0-9-]*/)?[a-z0-9][a-z0-9-]*$' + version: + type: string + pattern: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:alpha|beta|rc)\.(0|[1-9][0-9]*))?$' + versionRange: + description: >- + An npm version range for this dependency. When absent, the generated + package depends on `^`. + type: string + minLength: 1 +allOf: + - if: + properties: + publicationMode: + const: registry + then: + required: [registryId] + else: + not: + required: [registryId] diff --git a/tooling/extension-bindings/emitters/typescript/package.json b/tooling/extension-bindings/emitters/typescript/package.json new file mode 100644 index 0000000..64c9480 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/package.json @@ -0,0 +1,27 @@ +{ + "name": "@runtimeconditions/binding-emitter-typescript", + "version": "0.1.0", + "private": true, + "description": "Native TypeScript emitter for Runtime Conditions extension bindings", + "type": "module", + "engines": { + "node": ">=20.19.0" + }, + "bin": { + "runtimeconditions-binding-emitter-typescript": "dist/src/main.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "npm run build && node --test dist/test/" + }, + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "prettier": "3.9.10", + "typescript": "5.9.3", + "yaml": "2.9.1" + }, + "devDependencies": { + "@types/node": "20.19.43" + } +} diff --git a/tooling/extension-bindings/emitters/typescript/src/cli.ts b/tooling/extension-bindings/emitters/typescript/src/cli.ts new file mode 100644 index 0000000..a72fec1 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/cli.ts @@ -0,0 +1,62 @@ +import { parseArgs } from "node:util"; + +import { DiagnosticError } from "./diagnostics.js"; +import { loadModel } from "./model.js"; +import { buildPlan } from "./plan.js"; +import { emitPackage } from "./source.js"; +import { apiSurface, verifyPackage } from "./verification.js"; +import { loadTarget } from "./target.js"; +import { EMITTER_VERSION } from "./metadata.js"; + +const USAGE = + "usage: runtimeconditions-binding-emitter-typescript emit|verify|api --model FILE --package-config FILE (--output DIR | --input DIR)"; + +export async function main(argv: readonly string[]): Promise { + const [command, ...rest] = argv; + if (command === "--version") { + process.stdout.write(`${EMITTER_VERSION}\n`); + return 0; + } + try { + if (command !== "emit" && command !== "verify" && command !== "api") { + throw new Error(USAGE); + } + const { values } = parseArgs({ + args: rest, + options: { + model: { type: "string" }, + "package-config": { type: "string" }, + output: { type: "string" }, + input: { type: "string" }, + assembled: { type: "boolean", default: false }, + }, + strict: true, + }); + let result: object; + if (command === "api") { + if (values.input === undefined) throw new Error(USAGE); + result = { api: apiSurface(values.input) }; + } else { + if (values.model === undefined || values["package-config"] === undefined) + throw new Error(USAGE); + const model = loadModel(values.model); + const plan = buildPlan(model, loadTarget(values["package-config"])); + if (command === "emit") { + if (values.output === undefined) throw new Error(USAGE); + await emitPackage(plan, model, values.output); + result = { output: values.output }; + } else { + if (values.input === undefined) throw new Error(USAGE); + result = verifyPackage(plan, values.input, model); + } + } + process.stdout.write(`${JSON.stringify(result)}\n`); + return 0; + } catch (error) { + if (error instanceof DiagnosticError || error instanceof Error) { + process.stderr.write(`${error.message}\n`); + return 1; + } + throw error; + } +} diff --git a/tooling/extension-bindings/emitters/typescript/src/conformance.ts b/tooling/extension-bindings/emitters/typescript/src/conformance.ts new file mode 100644 index 0000000..0cc4919 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/conformance.ts @@ -0,0 +1,209 @@ +import { DiagnosticError, fail } from "./diagnostics.js"; +import type { Model } from "./model.js"; +import { header } from "./metadata.js"; +import type { EmissionPlan, TypeExpr, TypePlan } from "./plan.js"; + +function attempt(build: () => string): string | undefined { + try { + return build(); + } catch (error) { + if (error instanceof DiagnosticError) return undefined; + throw error; + } +} + +function sample( + expression: TypeExpr, + plan: EmissionPlan, + types: ReadonlyMap, + active: readonly string[] = [], + selectedOptional: string | null = null, +): string { + switch (expression.kind) { + case "string": + case "any": + return '"sample"'; + case "boolean": + return "true"; + case "integer": + case "number": + return "1"; + case "null": + return "null"; + case "array": + return attempt(() => `[${sample(expression.arguments[0]!, plan, types, active)}]`) ?? "[]"; + case "map": + return ( + attempt(() => `{ sample: ${sample(expression.arguments[0]!, plan, types, active)} }`) ?? + "{}" + ); + case "union": { + let last: unknown; + for (const variant of expression.arguments) { + try { + return sample(variant, plan, types, active); + } catch (error) { + if (!(error instanceof DiagnosticError)) throw error; + last = error; + } + } + throw last; + } + default: + } + const name = plan.names.get(expression.symbolKey)!; + const type = types.get(name)!; + if (type.kind === "enum") return `b.${name}.${type.members[0]![0]}`; + if (type.kind === "alias") { + if (active.includes(name)) { + fail("model", "RCT1012", type.coordinate, "recursive alias has no finite conformance sample"); + } + return sample(type.expression!, plan, types, [...active, name]); + } + const repeated = active.includes(name); + const optional = type.fields.filter((field) => !field.required).map((field) => field.name); + const selected = selectedOptional ?? optional[0] ?? null; + const entries: string[] = []; + for (const field of type.fields) { + if (!field.required) { + if (!repeated && field.name === selected) { + entries.push(`${field.name}: ${sample(field.typeExpr, plan, types, [...active, name])}`); + } + continue; + } + if (repeated && ["object", "ref", "alias"].includes(field.typeExpr.kind)) { + fail( + "model", + "RCT1012", + field.coordinate, + "required recursive field has no finite conformance sample", + ); + } + entries.push(`${field.name}: ${sample(field.typeExpr, plan, types, [...active, name])}`); + } + return `new b.${name}(${type.fields.length > 0 ? `{ ${entries.join(", ")} }` : ""})`; +} + +function fieldGroups(fields: readonly TypePlan[]): TypePlan[][] { + const groups = new Map(); + for (const field of fields) + groups.set(field.schemaCoordinate, [...(groups.get(field.schemaCoordinate) ?? []), field]); + const result = [...groups.keys()] + .sort() + .map((key) => [...groups.get(key)!].sort((a, b) => (a.name < b.name ? -1 : 1))); + return result.length > 0 ? result : [[]]; +} + +export function conformanceSource(plan: EmissionPlan, model: Model): string { + const lines = [ + `// ${header(model)}`, + "", + 'import * as b from "./bindings.js";', + "", + "export function exercise(): void {", + ]; + if (plan.usesJsonValue) { + lines.push( + ' const _jsonScalar: b.JSONValue = "sample";', + ' const _jsonArray: b.JSONValue = ["sample"];', + ' const _jsonMap: b.JSONValue = { sample: "sample" };', + ); + } + const types = new Map(plan.types.map((item) => [item.name, item])); + const objectVariables = new Map(); + const alternates = new Map(); + const ordered = [...plan.types].sort((a, b) => (a.name < b.name ? -1 : 1)); + ordered.forEach((type, index) => { + if (type.kind === "enum") { + type.members.forEach(([member], memberIndex) => { + lines.push( + ` const _enum${index}_${memberIndex}: b.${type.name} = b.${type.name}.${member};`, + ); + }); + } else if (type.kind === "alias") { + lines.push( + ` const _alias${index}: b.${type.name} = ${sample(type.expression!, plan, types)};`, + ); + if (type.expression!.kind === "union") { + type.expression!.arguments.forEach((variant, variantIndex) => { + lines.push( + ` const _variant${index}_${variantIndex}: b.${type.name} = ${sample(variant, plan, types)};`, + ); + }); + } + } else { + const key = [...plan.names.entries()].find( + ([k, v]) => v === type.name && k.startsWith("type:"), + )![0]; + const objectExpression: TypeExpr = { kind: "object", arguments: [], symbolKey: key }; + const variable = `_object${index}`; + objectVariables.set(type.name, variable); + lines.push(` const ${variable}: b.${type.name} = ${sample(objectExpression, plan, types)};`); + const optional = type.fields.filter((field) => !field.required).map((field) => field.name); + optional.slice(1).forEach((name, offset) => { + const alternate = `_objectOptional${index}_${offset + 1}`; + alternates.set(type.name, [...(alternates.get(type.name) ?? []), alternate]); + lines.push( + ` const ${alternate}: b.${type.name} = ${sample(objectExpression, plan, types, [], name)};`, + ); + }); + } + }); + let declarationIndex = 0; + for (const declaration of [...plan.declarations].sort((a, b) => + a.function < b.function ? -1 : 1, + )) { + const fields = plan.types.filter( + (item) => + item.isDeclarationField && + item.marker !== null && + item.marker.owner === plan.target.rootExtension && + item.marker.protocol === declaration.protocol, + ); + if (fields.length === 0) { + lines.push(` const _deferred_${declaration.function} = b.${declaration.function};`); + continue; + } + for (const group of fieldGroups(fields)) { + lines.push( + ` const _declaration${declarationIndex}: b.Declaration = b.${declaration.function}(${group.map((item) => objectVariables.get(item.name)).join(", ")});`, + ); + declarationIndex += 1; + for (const item of group) { + for (const alternate of alternates.get(item.name) ?? []) { + const args = group.map((field) => + field.name === item.name ? alternate : objectVariables.get(field.name), + ); + lines.push( + ` const _declaration${declarationIndex}: b.Declaration = b.${declaration.function}(${args.join(", ")});`, + ); + declarationIndex += 1; + } + } + } + } + let importedIndex = 0; + for (const declaration of [...plan.importedDeclarations].sort((a, b) => + a.function < b.function ? -1 : 1, + )) { + const fields = plan.types.filter( + (item) => + item.isDeclarationField && + item.marker !== null && + item.marker.protocol === declaration.protocol, + ); + if (fields.length === 0) { + lines.push(` const _deferredImported${importedIndex} = b.${declaration.function};`); + importedIndex += 1; + continue; + } + for (const group of fieldGroups(fields)) { + lines.push( + ` const _imported${importedIndex} = b.${declaration.function}(${group.map((item) => objectVariables.get(item.name)).join(", ")});`, + ); + importedIndex += 1; + } + } + lines.push("}", ""); + return lines.join("\n"); +} diff --git a/tooling/extension-bindings/emitters/typescript/src/diagnostics.ts b/tooling/extension-bindings/emitters/typescript/src/diagnostics.ts new file mode 100644 index 0000000..d9f1467 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/diagnostics.ts @@ -0,0 +1,30 @@ +export interface Diagnostic { + readonly category: string; + readonly code: string; + readonly coordinate: string; + readonly message: string; + readonly jsonPointer: string; +} + +export class DiagnosticError extends Error { + readonly diagnostic: Diagnostic; + + constructor(diagnostic: Diagnostic) { + const location = diagnostic.jsonPointer + ? `${diagnostic.coordinate} ${diagnostic.jsonPointer}` + : diagnostic.coordinate; + super(`${diagnostic.code} ${location}: ${diagnostic.message}`); + this.name = "DiagnosticError"; + this.diagnostic = diagnostic; + } +} + +export function fail( + category: string, + code: string, + coordinate: string, + message: string, + jsonPointer = "", +): never { + throw new DiagnosticError({ category, code, coordinate, message, jsonPointer }); +} diff --git a/tooling/extension-bindings/emitters/typescript/src/index.ts b/tooling/extension-bindings/emitters/typescript/src/index.ts new file mode 100644 index 0000000..33691fd --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/index.ts @@ -0,0 +1,5 @@ +export { DiagnosticError, fail, type Diagnostic } from "./diagnostics.js"; +export { loadModel, type Model } from "./model.js"; +export { loadTarget, type PackageTarget } from "./target.js"; +export { buildPlan, type EmissionPlan } from "./plan.js"; +export { emitPackage, renderPackage, renderSources } from "./source.js"; diff --git a/tooling/extension-bindings/emitters/typescript/src/main.ts b/tooling/extension-bindings/emitters/typescript/src/main.ts new file mode 100644 index 0000000..fa487f4 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/main.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { main } from "./cli.js"; + +process.exitCode = await main(process.argv.slice(2)); diff --git a/tooling/extension-bindings/emitters/typescript/src/metadata.ts b/tooling/extension-bindings/emitters/typescript/src/metadata.ts new file mode 100644 index 0000000..b88fb10 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/metadata.ts @@ -0,0 +1,349 @@ +import { readFileSync } from "node:fs"; +import * as prettier from "prettier"; +import ts from "typescript"; +import { stringify } from "yaml"; + +import { fail } from "./diagnostics.js"; +import type { Model } from "./model.js"; +import type { EmissionPlan, TypeExpr } from "./plan.js"; +import type { PackageDependency } from "./target.js"; +import { fillJson, fillText, readTemplate } from "./templates.js"; + +const EMITTER_PACKAGE = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf-8"), +) as { version: string; bin: Record }; + +export const EMITTER_NAME: string = Object.keys(EMITTER_PACKAGE.bin)[0]!; +export const EMITTER_VERSION: string = EMITTER_PACKAGE.version; +export const TYPESCRIPT_VERSION: string = ts.version; +export const PRETTIER_VERSION: string = prettier.version; + +export function header(model: Model): string { + return `Code generated by ${EMITTER_NAME} ${EMITTER_VERSION} from model ${model["metadata"].semanticSha256}; DO NOT EDIT.`; +} + +function dependencyRange(dependency: PackageDependency): string { + return dependency.versionRange ?? `^${dependency.version}`; +} + +function packageJson(plan: EmissionPlan, model: Model): string { + const { target } = plan; + const names = target.dependencies.map((item) => item.packageName); + if (new Set(names).size !== names.length) { + fail( + "package-config", + "RCT1006", + target.packageKey, + "multiple direct extensions use the same npm package name", + "/dependencies", + ); + } + const dependencies = Object.fromEntries( + [...target.dependencies] + .sort((a, b) => (a.packageName < b.packageName ? -1 : 1)) + .map((item) => [item.packageName, dependencyRange(item)]), + ); + const repository = target.repositoryUrl.endsWith(".git") + ? target.repositoryUrl + : `${target.repositoryUrl}.git`; + const template = "package.json.tmpl"; + const filled = fillJson(template, JSON.parse(readTemplate(template)), { + header: header(model), + name: target.packageName, + version: target.version, + minimumNodeVersion: target.minimumNodeVersion, + repositoryUrl: repository, + sourceDirectory: target.sourceDirectory, + dependencies, + typescriptVersion: TYPESCRIPT_VERSION, + }); + return JSON.stringify(filled, null, 2) + "\n"; +} + +function tsconfig(model: Model): string { + const template = "tsconfig.json.tmpl"; + return fillText(template, readTemplate(template), { header: header(model) }); +} + +function modelLocation(item: any): string { + return item.provenance.coordinate + (item.provenance.jsonPointer ?? ""); +} + +function modelRef(provenance: any): Record { + const result: Record = { coordinate: provenance.coordinate }; + if (provenance.jsonPointer) result["jsonPointer"] = provenance.jsonPointer; + return result; +} + +function modelLocations(model: Model): [Map, Map] { + const provenances = new Map(); + const shapes = new Map(); + const add = (item: any): void => { + const provenance = item.provenance; + const place = modelLocation(item); + const previous = provenances.get(place); + if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(provenance)) { + fail( + "model", + "RCT1012", + place, + "ambiguous legacy plan coordinate; separate model coordinate and JSON Pointer are required", + ); + } + provenances.set(place, provenance); + }; + const walk = (shape: any): void => { + add(shape); + if (!shapes.has(modelLocation(shape))) shapes.set(modelLocation(shape), shape); + for (const property of shape.properties ?? []) { + add(property); + walk(property.shape); + } + for (const child of [shape.items, shape.mapValues]) if (child !== undefined) walk(child); + for (const child of shape.variants ?? []) walk(child); + }; + const vocabulary = model["vocabulary"]; + for (const group of [ + "ownedDeclarations", + "importedDeclarations", + "interfaces", + "conditionFields", + "valueDomains", + ]) { + for (const item of vocabulary[group] ?? []) add(item); + } + for (const scope of model["scopes"] ?? []) if (scope.projection) walk(scope.projection); + for (const schema of model["schemas"] ?? []) { + add(schema); + walk(schema.projection); + for (const definition of schema.definitions ?? []) { + add(definition); + walk(definition.shape); + } + } + return [provenances, shapes]; +} + +const BUILTINS: Record = { + string: "string", + boolean: "boolean", + integer: "number", + number: "number", + null: "null", + any: "JSONValue", +}; + +function reference(expression: TypeExpr, plan: EmissionPlan): Record { + if (["object", "enum", "ref", "alias"].includes(expression.kind)) { + return { type: plan.names.get(expression.symbolKey)! }; + } + return { builtin: BUILTINS[expression.kind]! }; +} + +function structuralTypes( + plan: EmissionPlan, + provenances: Map, + shapes: Map, +): any[] { + const contracts = new Map(); + for (const item of [...plan.declarations, ...plan.importedDeclarations]) + contracts.set(item.method, item.coordinate); + const result: any[] = []; + for (const item of plan.types) { + const shape = shapes.get(item.coordinate); + const aliasKind = item.expression?.kind ?? ""; + const construct = + item.kind === "object" && shape.kind !== "object" + ? "wrapper" + : item.kind === "object" + ? "object" + : item.kind === "enum" + ? "scalar" + : ({ array: "collection", map: "map", union: "union" } as Record)[ + aliasKind + ]!; + const entry: Record = { + modelRef: modelRef(provenances.get(item.coordinate)), + sourceName: item.sourceName, + nativeName: item.name, + construct, + file: "bindings.ts", + }; + if (item.kind === "enum") { + entry["underlying"] = "string"; + entry["members"] = item.members.map(([native, value]) => ({ + modelRef: modelRef(provenances.get(item.coordinate)), + nativeName: native, + value, + })); + } + if (item.kind === "object") { + entry["fields"] = item.fields.map((field) => ({ + modelRef: modelRef(provenances.get(field.coordinate)), + sourceName: construct === "wrapper" ? item.sourceName : field.sourceName, + nativeName: field.name, + required: field.required, + value: reference(field.typeExpr, plan), + ...(construct === "wrapper" ? { synthetic: true } : {}), + })); + if (item.marker !== null) { + entry["implements"] = [ + { + declarationCoordinate: contracts.get(item.marker.method)!, + markerMethod: item.marker.method, + }, + ]; + } + } + if (item.kind === "alias") { + if (construct === "collection" || construct === "map") { + const child = shape[construct === "collection" ? "items" : "mapValues"]; + entry["element"] = { + modelRef: modelRef(child.provenance), + value: reference(item.expression!.arguments[0]!, plan), + }; + } else { + entry["variants"] = shape.variants.map((child: any, index: number) => ({ + modelRef: modelRef(child.provenance), + value: reference(item.expression!.arguments[index]!, plan), + })); + } + } + result.push(entry); + } + return result.sort((a, b) => + a.nativeName < b.nativeName ? -1 : a.nativeName > b.nativeName ? 1 : 0, + ); +} + +function manifest(plan: EmissionPlan, model: Model): string { + const [provenances, shapes] = modelLocations(model); + const rootId: string = model["rootExtension"].id; + const declarations = new Map(); + for (const group of ["ownedDeclarations", "importedDeclarations"]) { + for (const item of model["vocabulary"][group] ?? []) declarations.set(item.kind, item); + } + const fields = new Map(); + for (const item of model["vocabulary"].conditionFields ?? []) { + if (item.owner === rootId) + fields.set(JSON.stringify([item.kind, item.interfaceType ?? "", item.path]), item); + } + const rootBindings: any[] = []; + for (const schema of model["schemas"] ?? []) { + if (schema.owner !== rootId) continue; + const kind: string = schema.kind ?? schema.id; + if (!declarations.has(kind)) continue; + const interfaceType: string = schema.interfaceType ?? ""; + for (const property of schema.projection.properties ?? []) { + const name: string = property.name; + const sourceName = name === "interface" && interfaceType ? interfaceType : name; + const nativeName = plan.names.get(`type:field:${schema.coordinate}:${name}`); + if (nativeName === undefined) { + fail( + "model", + "RCT1012", + schema.coordinate, + `root property '${name}' has no declaration field type`, + ); + } + const vocabularyField = fields.get(JSON.stringify([kind, interfaceType, name])); + const role = + name === "interface" && interfaceType + ? "interface" + : vocabularyField !== undefined + ? "condition-field" + : "schema-field"; + const binding: Record = { + role, + modelRef: modelRef(property.provenance), + declarationCoordinate: declarations.get(kind).coordinate, + scope: { kind, ...(interfaceType ? { interfaceType } : {}) }, + sourceName, + path: [{ name }], + value: { type: nativeName }, + schemaCoordinate: schema.coordinate, + }; + if (role === "interface") binding["fixedInterfaceType"] = interfaceType; + if (vocabularyField !== undefined) + binding["vocabularyCoordinate"] = vocabularyField.coordinate; + rootBindings.push(binding); + } + } + const text = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + const document = { + apiVersion: "runtimeconditions.io/bindings/v1alpha2", + kind: "RuntimeConditionsBindingManifest", + generated: { + nonEditable: true, + emitter: `${EMITTER_NAME}@sha256:${plan.target.emitterSha256}`, + version: EMITTER_VERSION, + }, + model: { apiVersion: model["apiVersion"], semanticSha256: plan.modelDigest }, + extension: { id: rootId, semanticSha256: model["rootExtension"].semanticSha256 }, + package: { + language: "typescript", + coordinate: plan.target.packageName, + name: plan.target.packageName, + version: plan.target.version, + minimumNodeVersion: plan.target.minimumNodeVersion, + }, + declarations: plan.declarations.map((item) => ({ + modelRef: modelRef(provenances.get(item.coordinate)), + owner: rootId, + sourceName: item.kind, + function: item.function, + markerInterface: item.protocol, + markerMethod: item.method, + file: "bindings.ts", + })), + importedMarkerContracts: plan.importedDeclarations.map((item) => ({ + modelRef: modelRef(provenances.get(item.coordinate)), + owner: item.owner, + sourceName: item.kind, + markerInterface: item.protocol, + markerMethod: item.method, + providerPackage: item.providerPackage, + })), + rootBindings: rootBindings.sort( + (a, b) => + text(a.declarationCoordinate, b.declarationCoordinate) || + text(a.modelRef.coordinate, b.modelRef.coordinate) || + text(a.modelRef.jsonPointer ?? "", b.modelRef.jsonPointer ?? ""), + ), + types: structuralTypes(plan, provenances, shapes), + }; + return `# ${header(model)}\n${stringify(document, { lineWidth: 0, indent: 2 })}`; +} + +export function renderResources(plan: EmissionPlan, model: Model): Record { + if (plan.target.emitterSha256 === "") { + fail( + "package-config", + "RCT1006", + plan.target.packageKey, + "emitterSha256 is required when emitting package resources", + "/emitterSha256", + ); + } + if ( + model["metadata"].semanticSha256 !== plan.modelDigest || + model["rootExtension"].id !== plan.target.rootExtension + ) { + fail("model", "RCT1003", "model", "emission plan does not match supplied model"); + } + const root = model["rootExtension"]; + const roots = model["extensions"].filter((item: any) => item.id === root.id); + if (roots.length !== 1 || roots[0].semanticSha256 !== root.semanticSha256) { + fail( + "model", + "RCT1003", + root.id, + "root extension digest does not match model extension closure", + ); + } + return { + "package.json": packageJson(plan, model), + "tsconfig.json": tsconfig(model), + "runtimeconditions/runtimeconditions.bindings.yaml": manifest(plan, model), + }; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/model.ts b/tooling/extension-bindings/emitters/typescript/src/model.ts new file mode 100644 index 0000000..4ee691e --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/model.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parse } from "yaml"; + +import { fail } from "./diagnostics.js"; +import { loadYaml, type JsonObject } from "./yamlio.js"; + +export const MODEL_API = "runtimeconditions.io/binding-model/v1alpha1"; +export const MODEL_KIND = "RuntimeConditionsBindingModel"; + +export type Model = Record; + +export function modelSchemaPath(): string { + return ( + process.env["RC_BINDING_MODEL_SCHEMA"] ?? + fileURLToPath( + new URL("../../../../model/runtimeconditions.binding-model.schema.yaml", import.meta.url), + ) + ); +} + +export function createValidator(schemaPath: string): Ajv2020 { + const ajv = new Ajv2020({ strict: true, allowUnionTypes: true, allErrors: false }); + addFormats.default(ajv); + ajv.addKeyword("x-runtimeconditions-ordering"); + ajv.addSchema(parse(readFileSync(schemaPath, "utf-8")) as object, "model"); + return ajv; +} + +let validator: Ajv2020 | undefined; + +export function loadModel(path: string): Model { + const model: JsonObject = loadYaml(path, true); + if (model["apiVersion"] !== MODEL_API) { + fail("model", "RCT1004", "model", "unsupported binding-model apiVersion", "/apiVersion"); + } + if (model["kind"] !== MODEL_KIND) { + fail("model", "RCT1005", "model", "unsupported binding-model kind", "/kind"); + } + validator ??= createValidator(modelSchemaPath()); + const validate = validator.getSchema("model"); + if (validate === undefined) fail("model", "RCT1003", "model", "model schema is unavailable"); + if (!validate(model)) { + const error = validate.errors?.[0]; + fail( + "model", + "RCT1003", + "model", + `${error?.message ?? "schema validation failed"}`, + error?.instancePath ?? "", + ); + } + return model as Model; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/naming.ts b/tooling/extension-bindings/emitters/typescript/src/naming.ts new file mode 100644 index 0000000..e79c18b --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/naming.ts @@ -0,0 +1,274 @@ +import { fail } from "./diagnostics.js"; + +export type Style = "pascal" | "camel" | "exact"; + +export interface Symbol { + readonly key: string; + readonly coordinate: string; + readonly source: string; + readonly style: Style; + readonly namespace?: string; + readonly parents?: readonly string[]; + readonly fixed?: boolean; +} + +const KEYWORDS = new Set([ + "abstract", + "arguments", + "await", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "constructor", + "continue", + "debugger", + "default", + "delete", + "do", + "double", + "else", + "enum", + "eval", + "export", + "extends", + "false", + "final", + "finally", + "float", + "for", + "function", + "goto", + "if", + "implements", + "import", + "in", + "instanceof", + "int", + "interface", + "let", + "long", + "native", + "new", + "null", + "package", + "private", + "protected", + "prototype", + "public", + "return", + "short", + "static", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "true", + "try", + "typeof", + "var", + "void", + "volatile", + "while", + "with", + "yield", +]); + +const HELPERS = [ + "JSONValue", + "Readonly", + "ReadonlyArray", + "Record", + "Array", + "Object", + "String", + "Number", + "Boolean", + "Map", + "Set", + "Date", + "Error", + "Function", + "Promise", + "Symbol", + "JSON", + "Math", + "Partial", + "Required", + "Pick", + "Omit", + "Exclude", + "Extract", + "NonNullable", + "Iterable", + "Awaited", + "BigInt", + "RegExp", + "Infinity", + "NaN", + "undefined", + "globalThis", +]; + +const RESERVED = new Map( + HELPERS.map((name) => [name, `typescript-global:${name}`]), +); + +const IDENTIFIER = /^[\p{ID_Start}$_][\p{ID_Continue}$‌‍]*$/u; +const ALNUM = /^[\p{L}\p{N}]$/u; +const UPPER = /^\p{Lu}$/u; +const LOWER = /^\p{Ll}$/u; +const DIGIT = /^\p{Nd}$/u; + +function tokens(input: string): string[] { + const characters = Array.from(input.normalize("NFKC")); + const chunks: string[] = []; + let current = ""; + characters.forEach((character, index) => { + if (!ALNUM.test(character)) { + if (current) { + chunks.push(current); + current = ""; + } + return; + } + const previous = index > 0 ? characters[index - 1]! : ""; + const following = index + 1 < characters.length ? characters[index + 1]! : ""; + const boundary = + current !== "" && + ((UPPER.test(character) && (LOWER.test(previous) || DIGIT.test(previous))) || + (UPPER.test(character) && UPPER.test(previous) && LOWER.test(following))); + if (boundary) { + chunks.push(current); + current = ""; + } + current += character; + }); + if (current) chunks.push(current); + return chunks; +} + +function capitalize(part: string): string { + const [first = "", ...rest] = Array.from(part); + return first.toUpperCase() + rest.join("").toLowerCase(); +} + +export function typescriptName(source: string, style: Style, coordinate: string): string { + if (style === "exact") { + if (!IDENTIFIER.test(source)) { + fail( + "typescript-naming", + "RCT2000", + coordinate, + `invalid TypeScript identifier ${JSON.stringify(source)}`, + ); + } + return source; + } + const parts = tokens(source); + if (parts.length === 0) { + fail( + "typescript-naming", + "RCT2000", + coordinate, + `cannot derive TypeScript identifier from '${source}'`, + ); + } + let name = + style === "pascal" + ? parts.map(capitalize).join("") + : parts.map((part, index) => (index === 0 ? part.toLowerCase() : capitalize(part))).join(""); + if (DIGIT.test(Array.from(name)[0]!)) name = (style === "pascal" ? "X" : "x") + name; + if (KEYWORDS.has(name)) name += "_"; + if (!IDENTIFIER.test(name)) { + fail( + "typescript-naming", + "RCT2000", + coordinate, + `invalid TypeScript identifier ${JSON.stringify(name)}`, + ); + } + return name; +} + +function compare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function allocateSymbols( + packageCoordinate: string, + symbols: readonly Symbol[], +): Map { + const ordered = [...symbols].sort( + (a, b) => + compare(a.namespace ?? "module", b.namespace ?? "module") || + compare(a.coordinate, b.coordinate) || + compare(a.key, b.key), + ); + if (new Set(ordered.map((item) => item.key)).size !== ordered.length) { + fail("typescript-naming", "RCT2001", packageCoordinate, "duplicate symbol request key"); + } + const levels = new Map(ordered.map((item) => [item.key, 0])); + const candidate = (item: Symbol): string => { + const parents = [...(item.parents ?? []).slice(0, levels.get(item.key))].reverse(); + return typescriptName([...parents, item.source].join("_"), item.style, item.coordinate); + }; + const maxDepth = Math.max(0, ...ordered.map((item) => item.parents?.length ?? 0)); + for (let round = 0; round < ordered.length * (maxDepth + 1) + 1; round += 1) { + const groups = new Map(); + for (const item of ordered) { + const groupKey = JSON.stringify([item.namespace ?? "module", candidate(item)]); + groups.set(groupKey, [...(groups.get(groupKey) ?? []), item]); + } + const collisions: [string, Symbol[]][] = []; + for (const [groupKey, group] of [...groups.entries()].sort(([a], [b]) => compare(a, b))) { + const [namespace, name] = JSON.parse(groupKey) as [string, string]; + const reserved = namespace === "module" ? RESERVED.get(name) : undefined; + if (group.length > 1 || reserved !== undefined) { + const pair = [...group].sort((a, b) => compare(a.coordinate, b.coordinate)).slice(0, 2); + if (reserved !== undefined) { + fail( + "typescript-naming", + "RCT2001", + packageCoordinate, + `symbol '${name}' conflicts: ${group[0]!.coordinate} and ${reserved}`, + ); + } + if (group.some((item) => item.fixed)) { + fail( + "typescript-naming", + "RCT2001", + packageCoordinate, + `fixed symbol '${name}' conflicts: ${pair[0]!.coordinate} and ${pair[1]!.coordinate}`, + ); + } + collisions.push([name, group]); + } + } + if (collisions.length === 0) { + return new Map(ordered.map((item) => [item.key, candidate(item)])); + } + for (const [name, group] of collisions) { + for (const item of group) { + if (levels.get(item.key) === (item.parents?.length ?? 0)) { + const pair = [...group].sort((a, b) => compare(a.coordinate, b.coordinate)).slice(0, 2); + fail( + "typescript-naming", + "RCT2001", + packageCoordinate, + `symbol '${name}' conflicts after full path: ${pair[0]!.coordinate} and ${pair[1]!.coordinate}`, + ); + } + levels.set(item.key, levels.get(item.key)! + 1); + } + } + } + throw new Error("symbol allocation did not converge"); +} diff --git a/tooling/extension-bindings/emitters/typescript/src/plan.ts b/tooling/extension-bindings/emitters/typescript/src/plan.ts new file mode 100644 index 0000000..4e14d94 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/plan.ts @@ -0,0 +1,642 @@ +import { createHash } from "node:crypto"; + +import { fail } from "./diagnostics.js"; +import type { Model } from "./model.js"; +import { allocateSymbols, type Symbol } from "./naming.js"; +import type { PackageTarget } from "./target.js"; + +export interface TypeExpr { + readonly kind: string; + readonly arguments: readonly TypeExpr[]; + readonly symbolKey: string; +} + +export interface FieldPlan { + readonly coordinate: string; + readonly sourceName: string; + readonly name: string; + readonly typeExpr: TypeExpr; + readonly annotation: string; + readonly required: boolean; +} + +export interface MarkerReference { + readonly owner: string; + readonly protocol: string; + readonly method: string; + readonly providerPackage: string | null; +} + +export interface TypePlan { + readonly coordinate: string; + readonly name: string; + readonly kind: "object" | "alias" | "enum"; + readonly fields: readonly FieldPlan[]; + readonly expression: TypeExpr | null; + readonly annotation: string; + readonly members: readonly (readonly [string, string])[]; + readonly marker: MarkerReference | null; + readonly isDeclarationField: boolean; + readonly schemaCoordinate: string; + readonly sourceName: string; +} + +export interface DeclarationPlan { + readonly coordinate: string; + readonly kind: string; + readonly function: string; + readonly protocol: string; + readonly method: string; +} + +export interface ImportedDeclarationPlan extends DeclarationPlan { + readonly owner: string; + readonly providerPackage: string | null; +} + +export interface EmissionPlan { + readonly target: PackageTarget; + readonly names: ReadonlyMap; + readonly declarations: readonly DeclarationPlan[]; + readonly importedDeclarations: readonly ImportedDeclarationPlan[]; + readonly types: readonly TypePlan[]; + readonly usesJsonValue: boolean; + readonly modelDigest: string; +} + +export const JSON_VALUE_ALIAS_RHS = + "string | number | boolean | null | ReadonlyArray | { readonly [key: string]: JSONValue }"; + +const SCALAR_ANNOTATIONS: Record = { + string: "string", + boolean: "boolean", + integer: "number", + number: "number", + null: "null", +}; + +function expr(kind: string, args: readonly TypeExpr[] = [], symbolKey = ""): TypeExpr { + return { kind, arguments: args, symbolKey }; +} + +/** + * Replaces a reference to a definition that has no type of its own (a scalar, `any`, or another + * reference) with that definition's expression, following chains. Objects, enums, and collections + * keep their named reference. + */ +export function resolve( + expression: TypeExpr, + definitions: ReadonlyMap, + active: readonly string[] = [], +): TypeExpr { + if (expression.kind === "ref") { + const key = expression.symbolKey; + const definition = definitions.get(key); + if (definition === undefined) { + return fail("model", "RCT1012", key, "reference to a definition that was not planned"); + } + if (definition.symbolKey === key) return expression; + if (active.includes(key)) { + return fail("model", "RCT1012", key, "definition refers to itself without a structure"); + } + return resolve(definition, definitions, [...active, key]); + } + if (expression.arguments.length === 0) return expression; + return { + ...expression, + arguments: expression.arguments.map((item) => resolve(item, definitions, active)), + }; +} + +export function annotate(expression: TypeExpression, names: ReadonlyMap): string { + const { kind } = expression; + if (kind in SCALAR_ANNOTATIONS) return SCALAR_ANNOTATIONS[kind]!; + if (kind === "any") return "JSONValue"; + if (kind === "object" || kind === "enum" || kind === "ref" || kind === "alias") { + return names.get(expression.symbolKey)!; + } + const inner = expression.arguments.map((item) => annotate(item, names)); + if (kind === "array") return `ReadonlyArray<${inner[0]}>`; + if (kind === "map") return `{ readonly [key: string]: ${inner[0]} }`; + if (kind === "union") return inner.join(" | "); + throw new Error(`unplanned type kind ${kind}`); +} + +type TypeExpression = TypeExpr; + +function markerMethod(owner: string, coordinate: string): string { + const digest = createHash("sha256").update(owner).update("\0").update(coordinate).digest("hex"); + return `rcMarker_${digest.slice(0, 16)}`; +} + +function directDependencies(model: Model): Set { + const root = model["rootExtension"].id as string; + for (const extension of model["extensions"]) { + if (extension.id === root) return new Set(extension.dependencies ?? []); + } + return fail("model", "RCT1011", root, "root extension is absent from resolved extension closure"); +} + +function providerPackage(model: Model, target: PackageTarget, owner: string): string { + const graph = new Map( + model["extensions"].map((item: any) => [item.id, item.dependencies ?? []]), + ); + const reaches = (current: string, visited: Set): boolean => { + if (current === owner) return true; + if (visited.has(current)) return false; + visited.add(current); + return (graph.get(current) ?? []).some((dependency) => reaches(dependency, visited)); + }; + const dependencies = [...target.dependencies].sort((a, b) => + a.extension < b.extension ? -1 : 1, + ); + for (const dependency of dependencies) { + if (reaches(dependency.extension, new Set())) return dependency.packageName; + } + return fail("model", "RCT1011", owner, "imported declaration has no direct dependency provider"); +} + +function location(item: any): string { + return item.provenance.coordinate + (item.provenance.jsonPointer ?? ""); +} + +interface RawType { + kind: "object" | "alias" | "enum"; + shape: any; + expression: TypeExpr | null; + fields: [string, string, string, TypeExpr, boolean][]; + members: [string, string][]; + marker: MarkerReference | null; +} + +export function buildPlan(model: Model, target: PackageTarget): EmissionPlan { + const root: string = model["rootExtension"].id; + const pkg = target.packageKey; + if (root !== target.rootExtension) { + fail( + "package-config", + "RCT1009", + pkg, + `target root '${target.rootExtension}' differs from model root '${root}'`, + "/rootExtension", + ); + } + const direct = directDependencies(model); + const configured = new Set(target.dependencies.map((item) => item.extension)); + if (JSON.stringify([...direct].sort()) !== JSON.stringify([...configured].sort())) { + fail( + "package-config", + "RCT1009", + pkg, + `direct dependency set differs: model=${JSON.stringify([...direct].sort())}, target=${JSON.stringify([...configured].sort())}`, + "/dependencies", + ); + } + const extensionIds = new Set(model["extensions"].map((item: any) => item.id)); + if (![...configured].every((item) => extensionIds.has(item))) { + fail("model", "RCT1011", root, "target dependency is absent from resolved closure"); + } + + const vocabulary = model["vocabulary"]; + const symbols: Symbol[] = []; + const declarations: [string, string, string][] = []; + const imported: (Omit & { + functionKey: string; + protocolKey: string; + })[] = []; + const importedKinds = new Map(); + const ownedKinds = new Map(); + + for (const item of vocabulary.ownedDeclarations ?? []) { + const { coordinate, kind } = item; + if (item.owner !== root) + fail("model", "RCT1011", coordinate, "owned declaration has a foreign owner"); + if (ownedKinds.has(kind)) + fail("model", "RCT1011", coordinate, `duplicate owned kind '${kind}'`); + ownedKinds.set(kind, coordinate); + symbols.push( + { key: `function:${coordinate}`, coordinate, source: kind, style: "camel", fixed: true }, + { + key: `protocol:${coordinate}`, + coordinate, + source: `${kind} Field`, + style: "pascal", + fixed: true, + }, + ); + declarations.push([coordinate, `function:${coordinate}`, `protocol:${coordinate}`]); + } + if (declarations.length > 0) { + symbols.push({ + key: "builtin:Declaration", + coordinate: "typescript:Declaration", + source: "Declaration", + style: "pascal", + fixed: true, + }); + } + for (const item of vocabulary.importedDeclarations ?? []) { + if (item.owner === root || !extensionIds.has(item.owner)) { + fail("model", "RCT1011", item.coordinate, "invalid imported declaration owner"); + } + const { coordinate } = item; + const functionKey = `import:function:${coordinate}`; + const protocolKey = `import:protocol:${coordinate}`; + symbols.push( + { key: functionKey, coordinate, source: item.kind, style: "camel", fixed: true }, + { key: protocolKey, coordinate, source: `${item.kind} Field`, style: "pascal", fixed: true }, + ); + const plan = { + coordinate, + owner: item.owner as string, + kind: item.kind as string, + functionKey, + protocolKey, + method: markerMethod(item.owner, coordinate), + providerPackage: providerPackage(model, target, item.owner), + }; + imported.push(plan); + if (importedKinds.has(item.kind)) + fail("model", "RCT1011", coordinate, "duplicate imported kind"); + importedKinds.set(item.kind, plan); + } + + const rawTypes = new Map(); + const typeSources = new Map(); + const declarationFieldSchemas = new Map(); + let anyUsed = false; + const definitionKeys = new Map(); + const referencedDefinitions = new Set(); + const definitionExpressions = new Map(); + const pendingDefinitions = new Map(); + const ownedFieldRoots = new Set( + (vocabulary.conditionFields ?? []) + .filter((item: any) => item.owner === root) + .map((item: any) => + JSON.stringify([item.kind, item.interfaceType ?? "", item.segments[0].name]), + ), + ); + const scopeProjections = new Map( + (model["scopes"] ?? []).map((item: any) => [ + JSON.stringify([item.kind, item.interfaceType ?? ""]), + item.projection ?? {}, + ]), + ); + for (const schema of model["schemas"] ?? []) { + for (const definition of schema.definitions ?? []) { + definitionKeys.set( + JSON.stringify([schema.coordinate, definition.jsonPointer]), + `type:${schema.coordinate}:${definition.jsonPointer}`, + ); + } + } + + const registerShape = ( + shape: any, + key: string, + source: string, + parents: readonly string[], + marker: MarkerReference | null = null, + exactSource: string | null = null, + ): TypeExpr => { + const coordinate = location(shape); + const kind = shape.kind as string; + const sourceName = exactSource ?? source; + if (kind === "any") { + anyUsed = true; + return expr("any"); + } + if (kind === "scalar") { + const values: any[] = shape.values ?? []; + if (values.length > 0 && shape.scalar === "string") { + if (values.some((entry) => typeof entry.value !== "string")) { + fail("model", "RCT1012", coordinate, "string domain contains non-string value"); + } + symbols.push({ key, coordinate, source, style: "pascal", parents }); + const members: [string, string][] = []; + values.forEach((entry, index) => { + const memberKey = `member:${key}:${index}`; + symbols.push({ + key: memberKey, + coordinate: `${coordinate}#value:${entry.value}`, + source: entry.value, + style: "pascal", + namespace: `enum:${key}`, + }); + members.push([memberKey, entry.value]); + }); + rawTypes.set(key, { kind: "enum", shape, expression: null, fields: [], members, marker }); + typeSources.set(key, sourceName); + return expr("enum", [], key); + } + return expr(shape.scalar); + } + if (kind === "ref") { + const referenceKey = definitionKeys.get( + JSON.stringify([shape.provenance.coordinate, shape.ref.replace(/^#/, "")]), + ); + if (referenceKey === undefined) + fail("model", "RCT1012", coordinate, `unresolved reference '${shape.ref}'`); + referencedDefinitions.add(referenceKey); + return expr("ref", [], referenceKey); + } + if (kind === "object") { + symbols.push({ key, coordinate, source, style: "pascal", parents }); + if (marker) { + symbols.push({ + key: `method:${key}`, + coordinate: marker.owner, + source: marker.method, + style: "exact", + namespace: `fields:${key}`, + fixed: true, + }); + } + const rawFields: RawType["fields"] = []; + const required = new Set(shape.required ?? []); + const properties: any[] = shape.properties ?? []; + const propertyNames = new Set(properties.map((item) => item.name as string)); + const missing = [...required].filter((name) => !propertyNames.has(name)).sort(); + if (missing.length > 0) + fail( + "model", + "RCT1012", + coordinate, + `required properties are missing: ${JSON.stringify(missing)}`, + ); + if (propertyNames.size !== properties.length) + fail("model", "RCT1012", coordinate, "duplicate object property"); + for (const property of properties) { + const { name } = property; + const propertyCoordinate = location(property); + const fieldKey = `field:${key}:${name}`; + symbols.push({ + key: fieldKey, + coordinate: propertyCoordinate, + source: name, + style: "camel", + namespace: `fields:${key}`, + }); + const expression = registerShape(property.shape, `type:${key}:${name}`, name, [ + source, + ...parents, + ]); + rawFields.push([ + fieldKey, + propertyCoordinate, + name, + expression, + required.has(name) || property.required === true, + ]); + } + rawTypes.set(key, { + kind: "object", + shape, + expression: null, + fields: rawFields, + members: [], + marker, + }); + typeSources.set(key, sourceName); + return expr("object", [], key); + } + if (kind === "array" || kind === "map" || kind === "union") { + let children: any[]; + if (kind === "union") { + children = shape.variants; + if (children.length === 0) fail("model", "RCT1012", coordinate, "union has no variants"); + } else { + children = [kind === "array" ? shape.items : shape.mapValues]; + } + const expressions: TypeExpr[] = []; + children.forEach((child, index) => { + const suffix = kind === "array" ? "Item" : kind === "map" ? "Value" : `Variant${index + 1}`; + const childSource = `${kind === "array" ? source.replace(/ Value$/, "") : source} ${suffix}`; + expressions.push( + registerShape(child, `type:${key}:${suffix}`, childSource, parents, null, sourceName), + ); + }); + symbols.push({ key, coordinate, source, style: "pascal", parents }); + rawTypes.set(key, { + kind: "alias", + shape, + expression: expr(kind, expressions), + fields: [], + members: [], + marker, + }); + typeSources.set(key, sourceName); + return expr("alias", [], key); + } + return fail("model", "RCT1010", coordinate, `unknown structural node '${kind}'`); + }; + + for (const schema of model["schemas"] ?? []) { + if (schema.owner !== root) continue; + const schemaCoordinate: string = schema.coordinate; + const kind: string = schema.kind ?? schema.id; + const interfaceType: string | undefined = schema.interfaceType; + const scope = [interfaceType, kind].filter((part): part is string => Boolean(part)); + let marker: MarkerReference | null = null; + if (ownedKinds.has(kind)) { + const declarationCoordinate = ownedKinds.get(kind)!; + marker = { + owner: root, + protocol: `protocol:${declarationCoordinate}`, + method: markerMethod(root, declarationCoordinate), + providerPackage: null, + }; + } else if (importedKinds.has(kind)) { + const item = importedKinds.get(kind)!; + marker = { + owner: item.owner, + protocol: item.protocolKey, + method: item.method, + providerPackage: item.providerPackage, + }; + } + const projection = schema.projection; + if (projection.kind !== "object") { + fail("model", "RCT1012", schemaCoordinate, "root condition projection must be an object"); + } + for (const property of projection.properties ?? []) { + const propertyName: string = property.name; + const isInterface = propertyName === "interface" && Boolean(interfaceType); + const fieldSource = isInterface ? interfaceType! : propertyName; + const fieldParents = isInterface ? [propertyName, ...scope] : scope; + const key = `type:field:${schemaCoordinate}:${propertyName}`; + declarationFieldSchemas.set(key, schemaCoordinate); + let shape = property.shape; + if (ownedFieldRoots.has(JSON.stringify([kind, interfaceType ?? "", propertyName]))) { + const scopeProperties: any[] = + scopeProjections.get(JSON.stringify([kind, interfaceType ?? ""]))?.properties ?? []; + const projected = scopeProperties.find((item) => item.name === propertyName); + if (projected === undefined) { + fail( + "model", + "RCT1012", + schemaCoordinate, + `condition field '${propertyName}' is absent from its scope projection`, + ); + } + shape = projected.shape; + } + if (shape.kind === "object") { + registerShape(shape, key, fieldSource, fieldParents, marker); + continue; + } + if (marker === null) { + fail( + "model", + "RCT1011", + schemaCoordinate, + `field '${propertyName}' has no declaration contract`, + ); + } + const coordinate = location(property); + symbols.push({ + key, + coordinate, + source: fieldSource, + style: "pascal", + parents: fieldParents, + }); + symbols.push({ + key: `method:${key}`, + coordinate: marker.owner, + source: marker.method, + style: "exact", + namespace: `fields:${key}`, + fixed: true, + }); + const valueKey = `field:${key}:value`; + symbols.push({ + key: valueKey, + coordinate, + source: "value", + style: "camel", + namespace: `fields:${key}`, + fixed: true, + }); + const fieldExpression = registerShape( + shape, + `type:${key}:value`, + `${fieldSource} Value`, + fieldParents, + ); + rawTypes.set(key, { + kind: "object", + shape, + expression: null, + fields: [[valueKey, coordinate, "value", fieldExpression, true]], + members: [], + marker, + }); + typeSources.set(key, fieldSource); + } + for (const definition of schema.definitions ?? []) { + const definitionKey = definitionKeys.get( + JSON.stringify([schemaCoordinate, definition.jsonPointer]), + )!; + pendingDefinitions.set(definitionKey, [definition, scope, schemaCoordinate]); + if ((projection.properties ?? []).length === 0) referencedDefinitions.add(definitionKey); + } + } + + const pendingReferenced = (): string[] => + [...referencedDefinitions].filter((item) => pendingDefinitions.has(item)).sort(); + // Planning a definition can reference more definitions. Each pass removes the definitions it + // plans from pendingDefinitions, so the set only shrinks and the loop ends when none is left. + let required = pendingReferenced(); + while (required.length > 0) { + for (const definitionKey of required) { + const [definition, scope] = pendingDefinitions.get(definitionKey)!; + pendingDefinitions.delete(definitionKey); + definitionExpressions.set( + definitionKey, + registerShape(definition.shape, definitionKey, definition.name, scope), + ); + } + required = pendingReferenced(); + } + + const names = allocateSymbols(pkg, symbols); + const types: TypePlan[] = []; + for (const key of [...rawTypes.keys()].sort()) { + const raw = rawTypes.get(key)!; + const fields: FieldPlan[] = raw.fields.map( + ([fieldKey, coordinate, sourceName, planned, required]) => { + const typeExpr = resolve(planned, definitionExpressions); + return { + coordinate, + sourceName, + name: names.get(fieldKey)!, + typeExpr, + annotation: annotate(typeExpr, names), + required, + }; + }, + ); + fields.sort( + (a, b) => + Number(!a.required) - Number(!b.required) || + (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) || + (a.coordinate < b.coordinate ? -1 : a.coordinate > b.coordinate ? 1 : 0), + ); + const expression = raw.expression ? resolve(raw.expression, definitionExpressions) : null; + const annotation = expression ? annotate(expression, names) : ""; + types.push({ + coordinate: location(raw.shape), + name: names.get(key)!, + kind: raw.kind, + fields, + expression, + annotation, + members: raw.members.map(([memberKey, value]) => [names.get(memberKey)!, value] as const), + marker: raw.marker + ? { + owner: raw.marker.owner, + protocol: names.get(raw.marker.protocol)!, + method: raw.marker.method, + providerPackage: raw.marker.providerPackage, + } + : null, + isDeclarationField: raw.marker !== null && raw.kind === "object", + schemaCoordinate: declarationFieldSchemas.get(key) ?? "", + sourceName: typeSources.get(key)!, + }); + } + const sortedDeclarations = [...declarations].sort((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, + ); + const resultDeclarations: DeclarationPlan[] = sortedDeclarations.map( + ([coordinate, functionKey, protocolKey]) => ({ + coordinate, + kind: (vocabulary.ownedDeclarations as any[]).find((item) => item.coordinate === coordinate) + .kind, + function: names.get(functionKey)!, + protocol: names.get(protocolKey)!, + method: markerMethod(root, coordinate), + }), + ); + const resultImported: ImportedDeclarationPlan[] = [...imported] + .sort((a, b) => (a.coordinate < b.coordinate ? -1 : a.coordinate > b.coordinate ? 1 : 0)) + .map((item) => ({ + coordinate: item.coordinate, + owner: item.owner, + kind: item.kind, + function: names.get(item.functionKey)!, + protocol: names.get(item.protocolKey)!, + method: item.method, + providerPackage: item.providerPackage, + })); + return { + target, + names, + declarations: resultDeclarations, + importedDeclarations: resultImported, + types, + usesJsonValue: anyUsed, + modelDigest: model["metadata"].semanticSha256, + }; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/source.ts b/tooling/extension-bindings/emitters/typescript/src/source.ts new file mode 100644 index 0000000..890e86c --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/source.ts @@ -0,0 +1,222 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import * as prettier from "prettier"; + +import { fail } from "./diagnostics.js"; +import type { Model } from "./model.js"; +import type { EmissionPlan, TypePlan } from "./plan.js"; +import { header, renderResources } from "./metadata.js"; +import { JSON_VALUE_ALIAS_RHS } from "./plan.js"; +import { readTemplate } from "./templates.js"; + +function sourceHeader(model: Model): string { + return `// ${header(model)}`; +} + +function compare(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +export function exportedNames(plan: EmissionPlan): string[] { + const names = new Set(plan.declarations.length > 0 ? ["Declaration"] : []); + for (const item of plan.types) names.add(item.name); + for (const item of [...plan.declarations, ...plan.importedDeclarations]) { + names.add(item.function); + names.add(item.protocol); + } + if (plan.usesJsonValue) names.add("JSONValue"); + return [...names].sort(compare); +} + +function renderObject(item: TypePlan): string[] { + const lines: string[] = []; + const implement = item.marker !== null ? ` implements ${item.marker.protocol}` : ""; + lines.push(`export class ${item.name}${implement} {`); + for (const field of item.fields) { + lines.push(` readonly ${field.name}${field.required ? "" : "?"}: ${field.annotation};`); + } + if (item.fields.length > 0) { + const parameter = item.fields + .map((field) => `readonly ${field.name}${field.required ? "" : "?"}: ${field.annotation}`) + .join("; "); + const optional = item.fields.every((field) => !field.required) ? " = {}" : ""; + lines.push("", ` constructor(fields: { ${parameter} }${optional}) {`); + for (const field of item.fields) lines.push(` this.${field.name} = fields.${field.name};`); + lines.push(" }"); + } + if (item.marker !== null) { + lines.push("", ` ${item.marker.method}(): void {}`); + } + lines.push("}"); + return lines; +} + +function bindingsSource(plan: EmissionPlan, model: Model): string { + const lines: string[] = [sourceHeader(model), ""]; + const imports = new Map>(); + const importedTypes = new Set(); + for (const item of plan.importedDeclarations) { + if (item.providerPackage === null) { + fail( + "model", + "RCT1011", + item.coordinate, + "imported declaration has no direct dependency provider", + ); + } + const names = imports.get(item.providerPackage) ?? new Set(); + names.add(item.function); + names.add(item.protocol); + importedTypes.add(item.protocol); + imports.set(item.providerPackage, names); + } + for (const provider of [...imports.keys()].sort(compare)) { + const specifiers = [...imports.get(provider)!] + .sort(compare) + .map((name) => (importedTypes.has(name) ? `type ${name}` : name)); + lines.push(`import { ${specifiers.join(", ")} } from ${JSON.stringify(provider)};`); + } + if (imports.size > 0) { + const reexports = [...imports.values()].flatMap((names) => [...names]).sort(compare); + lines.push( + "", + `export { ${reexports.map((name) => (importedTypes.has(name) ? `type ${name}` : name)).join(", ")} };`, + ); + } + if (plan.declarations.length > 0) lines.push("", "export class Declaration {}"); + for (const declaration of [...plan.declarations].sort((a, b) => + compare(a.protocol, b.protocol), + )) { + lines.push( + "", + `export interface ${declaration.protocol} {`, + ` ${declaration.method}(): void;`, + "}", + ); + } + if (plan.usesJsonValue) lines.push("", `export type JSONValue = ${JSON_VALUE_ALIAS_RHS};`); + for (const item of [...plan.types].sort((a, b) => compare(a.name, b.name))) { + lines.push(""); + if (item.kind === "enum") { + lines.push(`export enum ${item.name} {`); + for (const [name, value] of item.members) lines.push(` ${name} = ${JSON.stringify(value)},`); + lines.push("}"); + } else if (item.kind === "alias") { + lines.push(`export type ${item.name} = ${item.annotation};`); + } else { + lines.push(...renderObject(item)); + } + } + for (const declaration of [...plan.declarations].sort((a, b) => + compare(a.function, b.function), + )) { + lines.push( + "", + `export function ${declaration.function}(...fields: ${declaration.protocol}[]): Declaration {`, + " void fields;", + " return new Declaration();", + "}", + ); + } + lines.push(""); + return lines.join("\n"); +} + +function indexSource(model: Model): string { + return `${sourceHeader(model)}\n\nexport * from "./bindings.js";\n`; +} + +export const FORMAT_OPTIONS: prettier.Options = JSON.parse(readTemplate("prettier.json")); + +export async function formatSource(source: string, filepath: string): Promise { + return prettier.format(source, { ...FORMAT_OPTIONS, filepath }); +} + +export async function renderSources( + plan: EmissionPlan, + model: Model, +): Promise> { + if ( + model["metadata"].semanticSha256 !== plan.modelDigest || + model["rootExtension"].id !== plan.target.rootExtension + ) { + fail("model", "RCT1003", "model", "emission plan does not match supplied model"); + } + const sources = { + "src/index.ts": indexSource(model), + "src/bindings.ts": bindingsSource(plan, model), + }; + const formatted: Record = {}; + for (const [path, source] of Object.entries(sources)) { + try { + formatted[path] = await formatSource(source, path); + } catch (error) { + fail( + "toolchain", + "RCT4002", + plan.target.packageKey, + `Prettier could not format ${path}: ${(error as Error).message.split("\n")[0]}`, + ); + } + } + return formatted; +} + +export async function renderPackage( + plan: EmissionPlan, + model: Model, +): Promise> { + return { ...(await renderSources(plan, model)), ...renderResources(plan, model) }; +} + +export function writeFiles( + files: Record, + plan: EmissionPlan, + outputDirectory: string, +): string[] { + let occupied = false; + try { + occupied = + existsSync(outputDirectory) && + (lstatSync(outputDirectory).isSymbolicLink() || + !lstatSync(outputDirectory).isDirectory() || + readdirSync(outputDirectory).length > 0); + } catch { + fail("output", "RCT3002", plan.target.packageKey, "cannot inspect output directory"); + } + if (occupied) { + fail( + "output", + "RCT3001", + plan.target.packageKey, + "output directory must be new or empty and not a symbolic link", + ); + } + try { + mkdirSync(outputDirectory, { recursive: true }); + for (const relative of Object.keys(files).sort(compare)) { + const destination = join(outputDirectory, relative); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, files[relative]!, { encoding: "utf-8", flag: "wx" }); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + fail( + "output", + "RCT3001", + plan.target.packageKey, + "output directory must be new or empty and not a symbolic link", + ); + } + fail("output", "RCT3002", plan.target.packageKey, "cannot write generated package files"); + } + return Object.keys(files).sort(compare); +} + +export async function emitPackage( + plan: EmissionPlan, + model: Model, + outputDirectory: string, +): Promise { + return writeFiles(await renderPackage(plan, model), plan, outputDirectory); +} diff --git a/tooling/extension-bindings/emitters/typescript/src/target.ts b/tooling/extension-bindings/emitters/typescript/src/target.ts new file mode 100644 index 0000000..eeb90f3 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/target.ts @@ -0,0 +1,180 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { Ajv2020, type ErrorObject, type ValidateFunction } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parse } from "yaml"; + +import { fail } from "./diagnostics.js"; +import { loadYaml, type JsonObject } from "./yamlio.js"; + +export interface PackageDependency { + readonly extension: string; + readonly packageName: string; + readonly version: string; + readonly versionRange?: string; +} + +export interface PackageTarget { + readonly packageKey: string; + readonly rootExtension: string; + readonly packageName: string; + readonly version: string; + readonly sourceDirectory: string; + readonly minimumNodeVersion: string; + readonly publicationMode: string; + readonly repositoryUrl: string; + readonly registryId: string | null; + readonly dependencies: readonly PackageDependency[]; + readonly emitterSha256: string; +} + +interface Described { + readonly code: string; + readonly message: string; + readonly pointer: string; +} + +export function targetSchemaPath(): string { + return ( + process.env["RC_PACKAGE_TARGET_SCHEMA"] ?? + fileURLToPath(new URL("../../package-target.schema.yaml", import.meta.url)) + ); +} + +let validator: ValidateFunction | undefined; + +function targetValidator(): ValidateFunction { + if (validator === undefined) { + const ajv = new Ajv2020({ strict: false, allErrors: true, allowUnionTypes: true }); + addFormats.default(ajv); + validator = ajv.compile(parse(readFileSync(targetSchemaPath(), "utf-8")) as object); + } + return validator; +} + +function configError(message: string, pointer = ""): Described { + return { code: "RCT1006", message, pointer }; +} + +function describe(error: ErrorObject): Described { + const path = error.instancePath; + const registry = configError( + "registryId is required exactly for registry publication", + "/registryId", + ); + if (error.keyword === "required") { + const missing = (error.params as { missingProperty: string }).missingProperty; + if (path === "") { + return missing === "registryId" + ? registry + : configError(`missing required field '${missing}'`, `/${missing}`); + } + return configError(`missing dependency field '${missing}'`, `${path}/${missing}`); + } + if (error.keyword === "additionalProperties") { + const extra = (error.params as { additionalProperty: string }).additionalProperty; + return path === "" + ? configError(`unknown field '${extra}'`, `/${extra}`) + : configError(`unknown dependency field '${extra}'`, `${path}/${extra}`); + } + if (error.keyword === "if" || error.keyword === "not") return registry; + if (path === "/apiVersion" || path === "/kind") { + return configError("unsupported target apiVersion or kind"); + } + if (path === "/sourceDirectory") { + return { + code: "RCT1007", + message: "source directory does not match package key", + pointer: path, + }; + } + if (path === "/minimumNodeVersion") { + return { code: "RCT1008", message: "unsupported minimum Node.js version", pointer: path }; + } + if (path === "/emitterSha256") { + return configError( + "emitterSha256 must be a 64-character lowercase SHA-256 of the emitter tool", + path, + ); + } + if (path === "/version") return configError("unsupported package version", path); + if (path === "/publicationMode") return configError("unsupported publication mode", path); + if (path === "/repositoryUrl") return configError("repositoryUrl must use https", path); + if (path === "/dependencies") return configError("dependencies must be a sequence", path); + const dependency = /^\/dependencies\/\d+(?:\/\w+)?$/.exec(path); + if (dependency) { + return configError( + path.split("/").length === 3 ? "dependency must be a mapping" : "invalid dependency package metadata", + path, + ); + } + return configError(`invalid ${path.split("/").pop()}`, path); +} + +function rank(error: ErrorObject): number { + if (error.instancePath !== "") return 3; + if (error.keyword === "required") { + return (error.params as { missingProperty: string }).missingProperty === "registryId" ? 2 : 0; + } + if (error.keyword === "additionalProperties") return 1; + return error.keyword === "if" || error.keyword === "not" ? 2 : 3; +} + +function first(errors: readonly ErrorObject[]): ErrorObject { + const key = (error: ErrorObject): string => + String(rank(error)) + + ((error.params as Record)["missingProperty"] ?? + (error.params as Record)["additionalProperty"] ?? + "") + + error.instancePath + + error.keyword; + return [...errors].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0))[0]!; +} + +export function loadTarget(path: string): PackageTarget { + const value: JsonObject = loadYaml(path, false); + const validate = targetValidator(); + const coordinate = typeof value["packageKey"] === "string" ? value["packageKey"] : "package-target"; + if (!validate(value)) { + const described = describe(first(validate.errors!)); + fail("package-config", described.code, coordinate, described.message, described.pointer); + } + const packageKey = value["packageKey"] as string; + if (value["sourceDirectory"] !== `bindings/${packageKey}/typescript`) { + fail( + "package-config", + "RCT1007", + packageKey, + "source directory does not match package key", + "/sourceDirectory", + ); + } + const dependencies: PackageDependency[] = []; + const seen = new Set(); + ((value["dependencies"] ?? []) as JsonObject[]).forEach((entry, index) => { + const extension = entry["extension"] as string; + if (seen.has(extension)) { + fail("package-config", "RCT1006", packageKey, `duplicate dependency '${extension}'`, `/dependencies/${index}`); + } + seen.add(extension); + dependencies.push({ + extension, + packageName: entry["packageName"] as string, + version: entry["version"] as string, + ...(typeof entry["versionRange"] === "string" ? { versionRange: entry["versionRange"] } : {}), + }); + }); + return { + packageKey, + rootExtension: value["rootExtension"] as string, + packageName: value["packageName"] as string, + version: value["version"] as string, + sourceDirectory: value["sourceDirectory"] as string, + minimumNodeVersion: value["minimumNodeVersion"] as string, + publicationMode: value["publicationMode"] as string, + repositoryUrl: value["repositoryUrl"] as string, + registryId: typeof value["registryId"] === "string" ? value["registryId"] : null, + dependencies, + emitterSha256: typeof value["emitterSha256"] === "string" ? value["emitterSha256"] : "", + }; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/templates.ts b/tooling/extension-bindings/emitters/typescript/src/templates.ts new file mode 100644 index 0000000..f5c5372 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/templates.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; + +import { fail } from "./diagnostics.js"; + +const PLACEHOLDER = /\{\{(\w+)\}\}/g; +const WHOLE = /^\{\{(\w+)\}\}$/; + +export function readTemplate(name: string): string { + try { + return readFileSync(new URL(`../../templates/${name}`, import.meta.url), "utf-8"); + } catch { + return fail("toolchain", "RCT4001", name, `template '${name}' cannot be read`); + } +} + +function lookup(variables: Readonly>, key: string, template: string): unknown { + if (!(key in variables)) { + return fail("toolchain", "RCT4001", template, `template placeholder '{{${key}}}' has no value`); + } + return variables[key]; +} + +export function fillText(template: string, text: string, variables: Readonly>): string { + return text.replace(PLACEHOLDER, (_match, key: string) => String(lookup(variables, key, template))); +} + +/** Replaces `{{name}}` in every string of a parsed JSON template; a string that is only one placeholder takes the value as is. */ +export function fillJson(template: string, value: unknown, variables: Readonly>): unknown { + if (typeof value === "string") { + const whole = WHOLE.exec(value); + return whole ? lookup(variables, whole[1]!, template) : fillText(template, value, variables); + } + if (Array.isArray(value)) return value.map((item) => fillJson(template, item, variables)); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, fillJson(template, item, variables)]), + ); + } + return value; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/verification.ts b/tooling/extension-bindings/emitters/typescript/src/verification.ts new file mode 100644 index 0000000..8a0ce98 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/verification.ts @@ -0,0 +1,309 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +import type { Model } from "./model.js"; +import { renderResources } from "./metadata.js"; +import type { EmissionPlan } from "./plan.js"; + +const MISMATCH = "native API or conformance mismatch"; + +function check(condition: boolean, description: string): void { + if (!condition) throw new Error(`${MISMATCH}: ${description}`); +} + +function parse(path: string): ts.SourceFile { + return ts.createSourceFile( + path, + readFileSync(path, "utf-8"), + ts.ScriptTarget.ES2022, + true, + ts.ScriptKind.TS, + ); +} + +function readOrMissing(path: string, description: string): string { + try { + return readFileSync(path, "utf-8"); + } catch { + throw new Error(`${description} is missing from the package`); + } +} + +function squash(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +function isExported(node: ts.Node): boolean { + return (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Export) !== 0; +} + +function exportedNames(source: ts.SourceFile): Set { + const names = new Set(); + for (const statement of source.statements) { + if ( + (ts.isClassDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isEnumDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isFunctionDeclaration(statement)) && + isExported(statement) && + statement.name + ) { + names.add(statement.name.text); + } else if ( + ts.isExportDeclaration(statement) && + statement.exportClause && + ts.isNamedExports(statement.exportClause) + ) { + for (const element of statement.exportClause.elements) names.add(element.name.text); + } + } + return names; +} + +function expectedNames(plan: EmissionPlan): Set { + const names = new Set(plan.types.map((item) => item.name)); + for (const declaration of [...plan.declarations, ...plan.importedDeclarations]) { + names.add(declaration.function); + names.add(declaration.protocol); + } + if (plan.declarations.length > 0) names.add("Declaration"); + if (plan.usesJsonValue) names.add("JSONValue"); + return names; +} + +function sameSet(left: Set, right: Set): boolean { + return left.size === right.size && [...left].every((item) => right.has(item)); +} + +function checkPublicApi(plan: EmissionPlan, directory: string): void { + const index = parse(join(directory, "src/index.ts")); + const bindings = parse(join(directory, "src/bindings.ts")); + const reexports = index.statements.filter(ts.isExportDeclaration); + check( + reexports.length === 1 && + reexports[0]!.exportClause === undefined && + reexports[0]!.moduleSpecifier !== undefined && + ts.isStringLiteral(reexports[0]!.moduleSpecifier) && + reexports[0]!.moduleSpecifier.text === "./bindings.js", + "index re-exports bindings", + ); + check(sameSet(exportedNames(bindings), expectedNames(plan)), "exported names == expected"); + + const classes = new Map(); + const interfaces = new Map(); + const enums = new Map(); + const aliases = new Map(); + const functions = new Map(); + for (const statement of bindings.statements) { + if (ts.isClassDeclaration(statement) && statement.name) + classes.set(statement.name.text, statement); + if (ts.isInterfaceDeclaration(statement)) interfaces.set(statement.name.text, statement); + if (ts.isEnumDeclaration(statement)) enums.set(statement.name.text, statement); + if (ts.isTypeAliasDeclaration(statement)) aliases.set(statement.name.text, statement); + if (ts.isFunctionDeclaration(statement) && statement.name) + functions.set(statement.name.text, statement); + } + const expectedClasses = new Set( + plan.types.filter((item) => item.kind === "object").map((item) => item.name), + ); + if (plan.declarations.length > 0) expectedClasses.add("Declaration"); + check(sameSet(new Set(classes.keys()), expectedClasses), "classes == object types + Declaration"); + check( + sameSet( + new Set(enums.keys()), + new Set(plan.types.filter((item) => item.kind === "enum").map((item) => item.name)), + ), + "enums == enum types", + ); + check( + sameSet(new Set(interfaces.keys()), new Set(plan.declarations.map((item) => item.protocol))), + "interfaces == declaration protocols", + ); + check( + sameSet(new Set(functions.keys()), new Set(plan.declarations.map((item) => item.function))), + "functions == declaration functions", + ); + const expectedAliases = new Set( + plan.types.filter((item) => item.kind === "alias").map((item) => item.name), + ); + if (plan.usesJsonValue) expectedAliases.add("JSONValue"); + check(sameSet(new Set(aliases.keys()), expectedAliases), "aliases == alias types + JSONValue"); + + for (const declaration of plan.declarations) { + const protocol = interfaces.get(declaration.protocol)!; + const methods = protocol.members + .filter(ts.isMethodSignature) + .map((member) => (member.name as ts.Identifier).text); + check( + JSON.stringify(methods) === JSON.stringify([declaration.method]), + "protocol has only the marker method", + ); + const fn = functions.get(declaration.function)!; + const parameters = fn.parameters; + check( + parameters.length === 1 && parameters[0]!.dotDotDotToken !== undefined, + "declaration takes rest fields", + ); + check( + (parameters[0]!.name as ts.Identifier).text === "fields", + "rest parameter is named fields", + ); + check( + squash(parameters[0]!.type!.getText()) === `${declaration.protocol}[]`, + "rest parameter type is the protocol array", + ); + check( + fn.type !== undefined && squash(fn.type.getText()) === "Declaration", + "declaration returns Declaration", + ); + } + for (const declaration of plan.importedDeclarations) { + check( + !functions.has(declaration.function) && !interfaces.has(declaration.protocol), + "imported declaration is not redeclared", + ); + } + for (const item of plan.types) { + if (item.kind === "alias") { + check( + squash(aliases.get(item.name)!.type.getText()) === item.annotation, + `alias ${item.name} matches the plan`, + ); + continue; + } + if (item.kind === "enum") { + const members = enums + .get(item.name)! + .members.map((member) => [ + (member.name as ts.Identifier).text, + (member.initializer as ts.StringLiteral).text, + ]); + check( + JSON.stringify(members) === JSON.stringify(item.members), + `enum ${item.name} members match the plan`, + ); + continue; + } + const declaration = classes.get(item.name)!; + const properties = declaration.members.filter(ts.isPropertyDeclaration); + const actual = properties.map((property) => [ + (property.name as ts.Identifier).text, + squash(property.type!.getText()), + property.questionToken === undefined, + property.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ReadonlyKeyword) === + true, + ]); + const expected = item.fields.map((field) => [ + field.name, + field.annotation, + field.required, + true, + ]); + check( + JSON.stringify(actual) === JSON.stringify(expected), + `class ${item.name} fields match the plan`, + ); + const markers = declaration.members + .filter(ts.isMethodDeclaration) + .map((member) => (member.name as ts.Identifier).text); + check( + JSON.stringify(markers) === JSON.stringify(item.marker ? [item.marker.method] : []), + `class ${item.name} markers match the plan`, + ); + const implemented = (declaration.heritageClauses ?? []).flatMap((clause) => + clause.types.map((type) => squash(type.getText())), + ); + check( + JSON.stringify(implemented) === JSON.stringify(item.marker ? [item.marker.protocol] : []), + `class ${item.name} implements its marker`, + ); + } +} + +export function apiSurface(directory: string): string[] { + const source = parse(join(directory, "src/bindings.ts")); + const exports = exportedNames(source); + const result: string[] = []; + for (const statement of source.statements) { + if (ts.isClassDeclaration(statement) && statement.name && exports.has(statement.name.text)) { + const name = statement.name.text; + const heritage = (statement.heritageClauses ?? []).flatMap((clause) => + clause.types.map((type) => squash(type.getText())), + ); + result.push(`class|${name}|${heritage.join(",")}`); + for (const member of statement.members) { + if (ts.isPropertyDeclaration(member)) { + result.push( + `field|${name}|${(member.name as ts.Identifier).text}|${squash(member.type!.getText())}|${member.questionToken ? "optional" : "required"}`, + ); + } else if (ts.isConstructorDeclaration(member)) { + result.push( + `constructor|${name}|${member.parameters.map((parameter) => squash(parameter.getText())).join(",")}`, + ); + } else if (ts.isMethodDeclaration(member)) { + result.push( + `method|${name}|${(member.name as ts.Identifier).text}|${member.parameters.map((parameter) => squash(parameter.getText())).join(",")}|${member.type ? squash(member.type.getText()) : ""}`, + ); + } + } + } else if (ts.isInterfaceDeclaration(statement) && exports.has(statement.name.text)) { + result.push(`interface|${statement.name.text}`); + for (const member of statement.members) { + if (ts.isMethodSignature(member)) { + result.push( + `method|${statement.name.text}|${(member.name as ts.Identifier).text}|${member.parameters.map((parameter) => squash(parameter.getText())).join(",")}|${member.type ? squash(member.type.getText()) : ""}`, + ); + } + } + } else if (ts.isEnumDeclaration(statement) && exports.has(statement.name.text)) { + result.push(`enum|${statement.name.text}`); + for (const member of statement.members) { + result.push(`member|${statement.name.text}|${squash(member.getText())}`); + } + } else if ( + ts.isFunctionDeclaration(statement) && + statement.name && + exports.has(statement.name.text) + ) { + result.push( + `function|${statement.name.text}|${statement.parameters.map((parameter) => squash(parameter.getText())).join(",")}|${statement.type ? squash(statement.type.getText()) : ""}`, + ); + } else if (ts.isTypeAliasDeclaration(statement) && exports.has(statement.name.text)) { + result.push(`alias|${statement.name.text}|${squash(statement.type.getText())}`); + } else if ( + ts.isImportDeclaration(statement) && + statement.importClause?.namedBindings && + ts.isNamedImports(statement.importClause.namedBindings) + ) { + for (const element of statement.importClause.namedBindings.elements) { + if (exports.has(element.name.text)) { + result.push( + `import|${(statement.moduleSpecifier as ts.StringLiteral).text}|${element.name.text}`, + ); + } + } + } + } + return result.sort(); +} + +export function verifyPackage( + plan: EmissionPlan, + directory: string, + model: Model, +): { api: string[]; modelMapping: boolean } { + checkPublicApi(plan, directory); + const expected = renderResources(plan, model); + for (const path of ["package.json", "tsconfig.json"]) { + if (readOrMissing(join(directory, path), path) !== expected[path]) { + throw new Error(`${path} differs from the package target and normalized model`); + } + } + const manifest = "runtimeconditions/runtimeconditions.bindings.yaml"; + if (readOrMissing(join(directory, manifest), "binding manifest") !== expected[manifest]) { + throw new Error("binding manifest mappings differ from normalized model"); + } + return { api: apiSurface(directory), modelMapping: true }; +} diff --git a/tooling/extension-bindings/emitters/typescript/src/yamlio.ts b/tooling/extension-bindings/emitters/typescript/src/yamlio.ts new file mode 100644 index 0000000..a96e6d5 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/src/yamlio.ts @@ -0,0 +1,180 @@ +import { closeSync, openSync, readSync } from "node:fs"; +import { + CST, + Lexer, + isAlias, + isMap, + isNode, + isPair, + isScalar, + parseAllDocuments, + visit, +} from "yaml"; + +import { fail } from "./diagnostics.js"; + +export const MAX_BYTES = 64 * 1024 * 1024; +export const MAX_NODES = 1_000_000; +export const MAX_DEPTH = 256; +export const MAX_ALIASES = 100; + +export type JsonObject = Record; + +/** Reads at most `limit` bytes, so an oversized or endless input is never held in memory. */ +function readAtMost(path: string, limit: number): Buffer { + const descriptor = openSync(path, "r"); + try { + const chunks: Buffer[] = []; + let total = 0; + while (total < limit) { + const chunk = Buffer.allocUnsafe(Math.min(1024 * 1024, limit - total)); + const count = readSync(descriptor, chunk, 0, chunk.length, null); + if (count === 0) break; + chunks.push(chunk.subarray(0, count)); + total += count; + } + return Buffer.concat(chunks, total); + } finally { + closeSync(descriptor); + } +} + +function strictDecode(data: Buffer, coordinate: string): string { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data); + } catch { + return fail("input", "RCT1002", coordinate, "input is not valid UTF-8"); + } +} + +function checkModelBytes(data: Buffer, coordinate: string): void { + const startsWithBom = data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf; + if ( + startsWithBom || + data.includes(0x0d) || + data[data.length - 1] !== 0x0a || + (data.length > 1 && data[data.length - 2] === 0x0a) + ) { + fail( + "input", + "RCT1002", + coordinate, + "binding model must use UTF-8 without BOM and LF with one trailing newline", + ); + } +} + +function checkModelTokens(source: string, coordinate: string): void { + for (const token of new Lexer().lex(source)) { + if (token.startsWith("#")) { + fail("input", "RCT1002", coordinate, "binding model may not contain YAML comments"); + } + } + void CST; +} + +function checkDataTree(value: unknown, coordinate: string): void { + let count = 0; + const visitValue = (node: unknown, depth: number): void => { + count += 1; + if (count > MAX_NODES) { + fail("input", "RCT1002", coordinate, "decoded YAML node limit exceeded"); + } + if (depth > MAX_DEPTH) { + fail("input", "RCT1002", coordinate, "decoded YAML nesting limit exceeded"); + } + if (typeof node === "number" && !Number.isFinite(node)) { + fail("input", "RCT1002", coordinate, "non-finite YAML number"); + } + if (Array.isArray(node)) { + for (const child of node) visitValue(child, depth + 1); + } else if (node !== null && typeof node === "object") { + for (const child of Object.values(node)) visitValue(child, depth + 1); + } + }; + visitValue(value, 0); +} + +export function loadYaml(path: string, model: boolean): JsonObject { + const coordinate = model ? "model" : "package-target"; + let data: Buffer; + try { + data = readAtMost(path, MAX_BYTES + 1); + } catch (error) { + const reason = (error as NodeJS.ErrnoException).code ?? "unreadable"; + return fail("input", "RCT1001", coordinate, `cannot read YAML input: ${reason}`); + } + if (data.length > MAX_BYTES) { + fail("input", "RCT1001", coordinate, "YAML input exceeds 64 MiB"); + } + if (model) checkModelBytes(data, coordinate); + const source = strictDecode(data, coordinate); + if (model) checkModelTokens(source, coordinate); + + const documents = parseAllDocuments(source, { uniqueKeys: true, merge: false, version: "1.2" }); + const list = Array.isArray(documents) ? documents : [documents]; + if (list.length === 0) fail("input", "RCT1002", coordinate, "YAML document is empty"); + if (list.length > 1) { + fail("input", "RCT1002", coordinate, "YAML input must contain exactly one document"); + } + const document = list[0]!; + const first = document.errors[0]; + if (first) fail("input", "RCT1002", coordinate, first.message.split("\n")[0] ?? first.message); + if (document.contents === null) fail("input", "RCT1002", coordinate, "YAML document is empty"); + + let nodes = 0; + let aliases = 0; + visit(document, (_key, node, path_) => { + if (isPair(node)) return undefined; + nodes += 1; + if (nodes > MAX_NODES) fail("input", "RCT1002", coordinate, "YAML node limit exceeded"); + if (path_.length > MAX_DEPTH) + fail("input", "RCT1002", coordinate, "YAML nesting limit exceeded"); + if (isAlias(node)) { + aliases += 1; + if (model) { + fail("input", "RCT1002", coordinate, "binding model may not contain YAML aliases"); + } + if (aliases > MAX_ALIASES) fail("input", "RCT1002", coordinate, "YAML alias limit exceeded"); + } + if (isNode(node)) { + if (model && "anchor" in node && node.anchor) { + fail( + "input", + "RCT1002", + coordinate, + "binding model may not contain YAML anchors, aliases, or tags", + ); + } + if (model && "tag" in node && node.tag) { + fail( + "input", + "RCT1002", + coordinate, + "binding model may not contain YAML anchors, aliases, or tags", + ); + } + } + if (isMap(node)) { + for (const pair of node.items) { + const key = pair.key; + if (!isScalar(key) || typeof key.value !== "string") { + fail("input", "RCT1002", coordinate, "YAML mapping keys must be strings"); + } + } + } + return undefined; + }); + + let value: unknown; + try { + value = document.toJS({ maxAliasCount: MAX_ALIASES }); + } catch (error) { + return fail("input", "RCT1002", coordinate, (error as Error).message); + } + checkDataTree(value, coordinate); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail("input", "RCT1002", coordinate, "YAML document must be a mapping"); + } + return value as JsonObject; +} diff --git a/tooling/extension-bindings/emitters/typescript/templates/package.json.tmpl b/tooling/extension-bindings/emitters/typescript/templates/package.json.tmpl new file mode 100644 index 0000000..b359d2a --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/templates/package.json.tmpl @@ -0,0 +1,32 @@ +{ + "//": "{{header}}", + "name": "{{name}}", + "version": "{{version}}", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": ["dist", "runtimeconditions", "runtimeconditions.file-manifest.yaml"], + "engines": { + "node": ">={{minimumNodeVersion}}" + }, + "repository": { + "type": "git", + "url": "git+{{repositoryUrl}}", + "directory": "{{sourceDirectory}}" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepack": "npm run build" + }, + "dependencies": "{{dependencies}}", + "devDependencies": { + "typescript": "{{typescriptVersion}}" + } +} diff --git a/tooling/extension-bindings/emitters/typescript/templates/prettier.json b/tooling/extension-bindings/emitters/typescript/templates/prettier.json new file mode 100644 index 0000000..ada76ed --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/templates/prettier.json @@ -0,0 +1,7 @@ +{ + "parser": "typescript", + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/tooling/extension-bindings/emitters/typescript/templates/tsconfig.json.tmpl b/tooling/extension-bindings/emitters/typescript/templates/tsconfig.json.tmpl new file mode 100644 index 0000000..bb3ae0a --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/templates/tsconfig.json.tmpl @@ -0,0 +1,18 @@ +// {{header}} +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": false, + "types": [] + }, + "include": ["src"] +} diff --git a/tooling/extension-bindings/emitters/typescript/test/cli.test.ts b/tooling/extension-bindings/emitters/typescript/test/cli.test.ts new file mode 100644 index 0000000..fb23d12 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/cli.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, rmSync, writeFileSync, mkdirSync, truncateSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { EMITTER_ROOT, TARGET, emitterDigest, modelPath, temporaryDirectory } from "./support.js"; +import { loadTarget } from "../src/target.js"; + +const MAIN = join(EMITTER_ROOT, "dist/src/main.js"); + +function run(...args: string[]): { status: number | null; stdout: string; stderr: string } { + const result = spawnSync(process.execPath, [MAIN, ...args], { encoding: "utf-8" }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function targetFile(directory: string): string { + const path = join(directory, "target.yaml"); + const target = loadTarget(TARGET); + writeFileSync( + path, + readFileSync(TARGET, "utf-8").replace( + "repositoryUrl:", + `emitterSha256: ${emitterDigest()}\nrepositoryUrl:`, + ), + ); + void target; + return path; +} + +test("emit, verify, and api work end to end through the command line", () => { + const directory = temporaryDirectory(); + try { + const target = targetFile(directory); + const model = modelPath("01-owned-kind-interface"); + const output = join(directory, "package"); + const emitted = run("emit", "--model", model, "--package-config", target, "--output", output); + assert.equal(emitted.status, 0, emitted.stderr); + assert.deepEqual(JSON.parse(emitted.stdout), { output }); + const verified = run("verify", "--model", model, "--package-config", target, "--input", output); + assert.equal(verified.status, 0, verified.stderr); + const result = JSON.parse(verified.stdout) as { api: string[]; modelMapping: boolean }; + assert.equal(result.modelMapping, true); + assert.ok(result.api.includes("function|service|...fields: ServiceField[]|Declaration")); + const api = run("api", "--input", output); + assert.equal(api.status, 0, api.stderr); + assert.deepEqual(JSON.parse(api.stdout), { api: result.api }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("emit refuses an occupied output directory and writes nothing", () => { + const directory = temporaryDirectory(); + try { + const target = targetFile(directory); + const output = join(directory, "occupied"); + mkdirSync(output); + writeFileSync(join(output, "keep.txt"), "keep"); + const result = run( + "emit", + "--model", + modelPath("01-owned-kind-interface"), + "--package-config", + target, + "--output", + output, + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /RCT3001/); + assert.equal(readFileSync(join(output, "keep.txt"), "utf-8"), "keep"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("emit requires the emitter digest for package resources", () => { + const result = run( + "emit", + "--model", + modelPath("01-owned-kind-interface"), + "--package-config", + TARGET, + "--output", + join(temporaryDirectory(), "out"), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /emitterSha256/); +}); + +test("bad usage and mismatched targets fail with a nonzero status", () => { + assert.equal(run().status, 1); + assert.equal(run("generate").status, 1); + assert.equal(run("emit", "--model", "x").status, 1); + assert.equal(run("emit", "--bogus").status, 1); + assert.equal(run("api").status, 1); + assert.equal( + run("--version").stdout.trim(), + JSON.parse(readFileSync(join(EMITTER_ROOT, "package.json"), "utf-8")).version, + ); + const directory = temporaryDirectory(); + try { + const target = targetFile(directory); + const result = run( + "emit", + "--model", + modelPath("02-additive-field"), + "--package-config", + target, + "--output", + join(directory, "out"), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /RCT1009/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("verify rejects a tampered API or manifest", () => { + const directory = temporaryDirectory(); + try { + const target = targetFile(directory); + const model = modelPath("01-owned-kind-interface"); + const output = join(directory, "package"); + assert.equal( + run("emit", "--model", model, "--package-config", target, "--output", output).status, + 0, + ); + const bindings = join(output, "src/bindings.ts"); + const original = readFileSync(bindings, "utf-8"); + writeFileSync( + bindings, + original.replace("readonly endpoint: string;", "readonly endpoint: number;"), + ); + const tamperedApi = run( + "verify", + "--model", + model, + "--package-config", + target, + "--input", + output, + ); + assert.equal(tamperedApi.status, 1); + assert.match(tamperedApi.stderr, /native API or conformance mismatch/); + writeFileSync(bindings, original); + const manifest = join(output, "runtimeconditions/runtimeconditions.bindings.yaml"); + const text = readFileSync(manifest, "utf-8"); + writeFileSync(manifest, text.replace("#schema", "#wrong")); + const tamperedManifest = run( + "verify", + "--model", + model, + "--package-config", + target, + "--input", + output, + ); + assert.equal(tamperedManifest.status, 1); + assert.match(tamperedManifest.stderr, /manifest mappings/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("an oversized input is rejected without being read into memory", () => { + const directory = temporaryDirectory(); + try { + const target = targetFile(directory); + const oversized = join(directory, "model.yaml"); + // A sparse 3 GiB file: reading it whole fails for another reason than the size limit. + writeFileSync(oversized, ""); + truncateSync(oversized, 3 * 1024 * 1024 * 1024); + const result = run( + "emit", + "--model", + oversized, + "--package-config", + target, + "--output", + join(directory, "out"), + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /RCT1001.*exceeds 64 MiB/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/conformance.test.ts b/tooling/extension-bindings/emitters/typescript/test/conformance.test.ts new file mode 100644 index 0000000..58fcfab --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/conformance.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import * as prettier from "prettier"; + +import { loadModel } from "../src/model.js"; +import { buildPlan } from "../src/plan.js"; +import { emitPackage, FORMAT_OPTIONS } from "../src/source.js"; +import { apiSurface, verifyPackage } from "../src/verification.js"; +import { + CASES, + buildCase, + developmentTarget, + modelPath, + planFor, + runTsc, + snapshot, + temporaryDirectory, + writeConformance, +} from "./support.js"; + +for (const name of CASES) { + test(`${name}: emits, verifies, and compiles under strict tsc`, async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase(name, workspace); + verifyPackage(built.plan, built.directory, built.model); + writeConformance(built); + const compiled = runTsc(built.directory, true); + assert.equal(compiled.status, 0, compiled.output); + assert.ok(apiSurface(built.directory).length > 0); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + test(`${name}: three independent emissions are byte-identical and Prettier-stable`, async () => { + const model = loadModel(modelPath(name)); + const runs: Record[] = []; + const workspace = temporaryDirectory(); + try { + for (let run = 0; run < 3; run += 1) { + const plan = planFor(model, name); + const directory = join(workspace, `run-${run}`); + await emitPackage(plan, model, directory); + runs.push(snapshot(directory)); + } + assert.deepEqual(runs[1], runs[0]); + assert.deepEqual(runs[2], runs[0]); + for (const [path, content] of Object.entries(runs[0]!)) { + if (path.endsWith(".ts")) { + assert.ok( + await prettier.check(content, { ...FORMAT_OPTIONS, filepath: path }), + `${path} is not Prettier-formatted`, + ); + assert.ok( + content.startsWith( + "// Code generated by runtimeconditions-binding-emitter-typescript 0.1.0 from model ", + ), + ); + assert.ok(content.split("\n")[0]!.endsWith("DO NOT EDIT.")); + } + } + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +} + +test("the emitter output contains exactly the declared package files", async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase("01-owned-kind-interface", workspace); + assert.deepEqual(Object.keys(snapshot(built.directory)).sort(), [ + "package.json", + "runtimeconditions/runtimeconditions.bindings.yaml", + "src/bindings.ts", + "src/index.ts", + "tsconfig.json", + ]); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("a JSON-valued root field maps to the recursive JSONValue alias and compiles", async () => { + const model = loadModel(modelPath("01-owned-kind-interface")); + const shape = model["schemas"][0].projection.properties[1].shape; + delete shape.scalar; + shape.kind = "any"; + model["scopes"][0].projection.properties[1].shape = structuredClone(shape); + const plan = buildPlan(model, { + ...developmentTarget(), + rootExtension: model["rootExtension"].id, + }); + assert.equal(plan.usesJsonValue, true); + const workspace = temporaryDirectory(); + try { + const directory = join(workspace, "package"); + await emitPackage(plan, model, directory); + verifyPackage(plan, directory, model); + const built = { directory, model, plan }; + writeConformance(built); + const compiled = runTsc(directory, true); + assert.equal(compiled.status, 0, compiled.output); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("generated types reject values the model does not allow", async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase("10-scoped-domains-collisions", workspace); + const probes: [string, string][] = [ + ["wrong enum value", 'const _x = new b.HttpMode({ value: "streaming" });'], + ["wrong scalar type", "const _x = new b.BaseUrl({ value: 1 });"], + ["missing required field", "const _x = new b.BaseUrl({});"], + ["non-field argument to a declaration", 'const _x = b.service({ value: "x" });'], + ]; + for (const [description, line] of probes) { + writeFileSync( + join(built.directory, "src/_probe.ts"), + `import * as b from "./bindings.js";\nexport function probe(): void {\n ${line}\n void _x;\n}\n`, + ); + const compiled = runTsc(built.directory, true); + assert.notEqual(compiled.status, 0, `${description} should not compile`); + } + rmSync(join(built.directory, "src/_probe.ts")); + writeFileSync( + join(built.directory, "src/_probe.ts"), + 'import * as b from "./bindings.js";\nexport const ok = b.service(new b.BaseUrl({ value: "x" }), new b.HttpMode({ value: b.HttpModeValue.Direct }));\n', + ); + assert.equal(runTsc(built.directory, true).status, 0); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("an additive field type is checked against the provider's marker", async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase("02-additive-field", workspace); + assert.equal(runTsc(built.directory, true).status, 0); + rmSync(join(workspace, "node_modules"), { recursive: true, force: true }); + const mismatched = await buildCase( + "02-additive-field", + temporaryDirectory(), + "rcMarker_0000000000000000", + ); + const compiled = runTsc(mismatched.directory, true); + assert.notEqual(compiled.status, 0); + assert.match(compiled.output, /rcMarker_/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/contract.test.ts b/tooling/extension-bindings/emitters/typescript/test/contract.test.ts new file mode 100644 index 0000000..651691d --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/contract.test.ts @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import { parse } from "yaml"; + +import { EMITTER_NAME, EMITTER_VERSION, PRETTIER_VERSION, TYPESCRIPT_VERSION } from "../src/metadata.js"; +import { buildPlan } from "../src/plan.js"; +import { renderPackage } from "../src/source.js"; +import { EMITTER_ROOT, TARGET, TOOLING, buildCase, temporaryDirectory } from "./support.js"; +import { rmSync } from "node:fs"; + +function validator(path: string) { + const ajv = new Ajv2020({ strict: false, allowUnionTypes: true }); + addFormats.default(ajv); + return ajv.compile(parse(readFileSync(path, "utf-8")) as object); +} + +const manifestSchema = validator( + join(TOOLING, "model/runtimeconditions.binding-manifest.schema.yaml"), +); + +test("the package-target schema accepts the test target and rejects drift", () => { + const validate = validator(join(EMITTER_ROOT, "package-target.schema.yaml")); + const target = parse(readFileSync(TARGET, "utf-8")) as Record; + assert.ok(validate(target), JSON.stringify(validate.errors)); + for (const change of [ + { sourceDirectory: "bindings/other/python" }, + { minimumNodeVersion: "18.0" }, + { packageName: "Bad Name" }, + { publicationMode: "registry" }, + { extra: true }, + ]) { + assert.equal(validate({ ...target, ...change }), false, JSON.stringify(change)); + } +}); + +test("every emitted manifest validates against the shared binding-manifest schema", async () => { + const workspace = temporaryDirectory(); + try { + for (const name of [ + "01-owned-kind-interface", + "02-additive-field", + "06-recursive-reference", + "08-heterogeneous-union", + "09-collections-and-maps", + "10-scoped-domains-collisions", + ]) { + const built = await buildCase(name, workspace); + const files = await renderPackage(built.plan, built.model); + const manifest = parse(files["runtimeconditions/runtimeconditions.bindings.yaml"]!); + assert.ok(manifestSchema(manifest), `${name}: ${JSON.stringify(manifestSchema.errors?.[0])}`); + } + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("the manifest schema keeps each language's identity separate", async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase("01-owned-kind-interface", workspace); + const files = await renderPackage(built.plan, built.model); + const manifest = parse(files["runtimeconditions/runtimeconditions.bindings.yaml"]!) as any; + const mutations: [string, (value: any) => void][] = [ + [ + "a TypeScript manifest needs minimumNodeVersion", + (value) => delete value.package.minimumNodeVersion, + ], + [ + "a TypeScript manifest cannot carry a Python version", + (value) => (value.package.minimumPythonVersion = "3.12"), + ], + [ + "a TypeScript manifest cannot name a Python file", + (value) => (value.declarations[0].file = "bindings.py"), + ], + [ + "an npm package name must be a valid npm name", + (value) => (value.package.name = "Bad Name"), + ], + ["an unknown language is rejected", (value) => (value.package.language = "ruby")], + ]; + for (const [description, change] of mutations) { + const copy = structuredClone(manifest); + change(copy); + assert.equal(manifestSchema(copy), false, description); + } + const python = structuredClone(manifest); + python.package = { + language: "python", + coordinate: "x", + name: "x", + version: "1.0.0", + minimumNodeVersion: "20.0", + }; + assert.equal(manifestSchema(python), false, "a Python manifest cannot carry a Node version"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("the emitter's tool pins equal the shared toolchain lock", () => { + const lock = parse(readFileSync(join(TOOLING, "toolchain.lock.yaml"), "utf-8")) as any; + const tools = lock.languages.typescript.tools; + assert.equal(tools.typescript, TYPESCRIPT_VERSION); + assert.equal(tools.prettier, PRETTIER_VERSION); + const manifest = JSON.parse(readFileSync(join(EMITTER_ROOT, "package.json"), "utf-8")); + for (const [name, version] of Object.entries(tools)) { + assert.equal(manifest.dependencies[name], version, `${name} dependency matches the lock`); + } + assert.equal(lock.languages.typescript.packageManager, "npm"); +}); + +test("emitter identity and tool versions are read from the installed packages", () => { + const manifest = JSON.parse(readFileSync(join(EMITTER_ROOT, "package.json"), "utf-8")); + assert.equal(EMITTER_VERSION, manifest.version); + assert.equal(EMITTER_NAME, Object.keys(manifest.bin)[0]); + assert.equal(TYPESCRIPT_VERSION, JSON.parse(readFileSync(join(EMITTER_ROOT, "node_modules/typescript/package.json"), "utf-8")).version); + assert.equal(PRETTIER_VERSION, JSON.parse(readFileSync(join(EMITTER_ROOT, "node_modules/prettier/package.json"), "utf-8")).version); +}); + +test("a dependency range defaults to the npm caret and can be set per dependency", async () => { + const workspace = temporaryDirectory(); + try { + const built = await buildCase("02-additive-field", workspace); + const dependency = built.plan.target.dependencies[0]!; + const caret = JSON.parse((await renderPackage(built.plan, built.model))["package.json"]!); + assert.equal(caret.dependencies[dependency.packageName], `^${dependency.version}`); + assert.equal(caret.devDependencies.typescript, TYPESCRIPT_VERSION); + const target = { + ...built.plan.target, + dependencies: [{ ...dependency, versionRange: ">=1.0.0 <3.0.0" }], + }; + const custom = JSON.parse((await renderPackage(buildPlan(built.model, target), built.model))["package.json"]!); + assert.equal(custom.dependencies[dependency.packageName], ">=1.0.0 <3.0.0"); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/definitions.test.ts b/tooling/extension-bindings/emitters/typescript/test/definitions.test.ts new file mode 100644 index 0000000..3293ece --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/definitions.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { DiagnosticError } from "../src/diagnostics.js"; +import { loadModel, type Model } from "../src/model.js"; +import { emitPackage } from "../src/source.js"; +import { verifyPackage } from "../src/verification.js"; +import { + modelPath, + planFor, + runTsc, + temporaryDirectory, + writeConformance, + type BuiltCase, +} from "./support.js"; + +const CASE = "06-recursive-reference"; + +function provenance(base: any, pointer: string): any { + return { ...base, jsonPointer: pointer }; +} + +function reference(base: any, pointer: string, target: string): any { + return { kind: "ref", ref: `#/$defs/${target}`, provenance: provenance(base, pointer) }; +} + +function addDefinition(definitions: any[], base: any, name: string, shape: any): void { + definitions.push({ + name, + jsonPointer: `/$defs/${name}`, + shape, + provenance: provenance(base, `/$defs/${name}`), + }); +} + +function addProperty(properties: any[], base: any, name: string, shape: any): void { + properties.push({ + name, + required: true, + shape, + provenance: provenance(base, `/$defs/node/properties/${name}`), + }); +} + +function modelWithScalarDefinitions( + extra: (definitions: any[], base: any, properties: any[]) => void, +): Model { + const model = loadModel(modelPath(CASE)); + const definitions: any[] = model["schemas"][0].definitions; + const node = definitions[0]; + const base = node.provenance; + addDefinition(definitions, base, "label", { + kind: "scalar", + scalar: "string", + provenance: provenance(base, "/$defs/label"), + }); + addDefinition(definitions, base, "anything", { + kind: "any", + provenance: provenance(base, "/$defs/anything"), + }); + addDefinition(definitions, base, "labelAlias", reference(base, "/$defs/labelAlias", "label")); + addDefinition( + definitions, + base, + "labelAliasAlias", + reference(base, "/$defs/labelAliasAlias", "labelAlias"), + ); + const properties: any[] = node.shape.properties; + addProperty(properties, base, "label", reference(base, "/$defs/node/properties/label", "label")); + addProperty( + properties, + base, + "anything", + reference(base, "/$defs/node/properties/anything", "anything"), + ); + addProperty( + properties, + base, + "chained", + reference(base, "/$defs/node/properties/chained", "labelAliasAlias"), + ); + addProperty(properties, base, "labels", { + kind: "array", + items: reference(base, "/$defs/node/properties/labels/items", "labelAlias"), + provenance: provenance(base, "/$defs/node/properties/labels"), + }); + extra(definitions, base, properties); + return model; +} + +test("scalar, any, and chained definitions keep their resolved types", async () => { + const workspace = temporaryDirectory(); + try { + const model = modelWithScalarDefinitions(() => undefined); + const plan = planFor(model, CASE); + const directory = join(workspace, "pkg"); + await emitPackage(plan, model, directory); + verifyPackage(plan, directory, model); + const built: BuiltCase = { directory, model, plan }; + writeConformance(built); + const source = readFileSync(join(directory, "src/bindings.ts"), "utf-8"); + assert.ok(!source.includes("undefined"), "no undefined annotation"); + assert.match(source, /readonly label: string;/); + assert.match(source, /readonly anything: JSONValue;/); + assert.match(source, /readonly chained: string;/); + assert.match(source, /export type Labels = ReadonlyArray;/); + const manifest = readFileSync( + join(directory, "runtimeconditions/runtimeconditions.bindings.yaml"), + "utf-8", + ); + assert.ok(!manifest.includes("undefined"), "no undefined manifest type"); + const compiled = runTsc(directory, true); + assert.equal(compiled.status, 0, compiled.output); + assert.ok(readdirSync(directory).includes("package.json")); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("a definition that only refers to itself is rejected", () => { + const model = modelWithScalarDefinitions((definitions, base, properties) => { + addDefinition(definitions, base, "loopA", reference(base, "/$defs/loopA", "loopB")); + addDefinition(definitions, base, "loopB", reference(base, "/$defs/loopB", "loopA")); + addProperty(properties, base, "loop", reference(base, "/$defs/node/properties/loop", "loopA")); + }); + assert.throws( + () => planFor(model, CASE), + (error: unknown) => error instanceof DiagnosticError && error.diagnostic.code === "RCT1012", + ); +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/loader.test.ts b/tooling/extension-bindings/emitters/typescript/test/loader.test.ts new file mode 100644 index 0000000..b7091a7 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/loader.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { stringify } from "yaml"; + +import { DiagnosticError } from "../src/diagnostics.js"; +import { loadModel } from "../src/model.js"; +import { loadTarget } from "../src/target.js"; +import { modelPath, temporaryDirectory, TARGET } from "./support.js"; + +const GOOD = readFileSync(modelPath("01-owned-kind-interface"), "utf-8"); + +function expectDiagnostic(action: () => unknown, code: string, message?: RegExp): void { + assert.throws(action, (error: unknown) => { + assert.ok(error instanceof DiagnosticError, String(error)); + assert.equal(error.diagnostic.code, code, error.message); + if (message) assert.match(error.diagnostic.message, message); + return true; + }); +} + +function withFile(content: string, action: (path: string) => void): void { + const directory = temporaryDirectory(); + try { + const path = join(directory, "input.yaml"); + writeFileSync(path, content); + action(path); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test("the committed conformance model loads", () => { + withFile(GOOD, (path) => assert.equal(loadModel(path)["kind"], "RuntimeConditionsBindingModel")); +}); + +test("a binding model rejects a BOM, CRLF, and missing or doubled trailing newlines", () => { + const cases: [string, RegExp][] = [ + [`${GOOD}`, /without BOM and LF/], + [GOOD.replace(/\n/g, "\r\n"), /without BOM and LF/], + [GOOD.replace(/\n$/, ""), /without BOM and LF/], + [`${GOOD}\n`, /without BOM and LF/], + ]; + for (const [content, message] of cases) { + withFile(content, (path) => expectDiagnostic(() => loadModel(path), "RCT1002", message)); + } +}); + +test("a binding model rejects comments, anchors, aliases, tags, and duplicate keys", () => { + const withComment = GOOD.replace( + "kind: RuntimeConditionsBindingModel", + "kind: RuntimeConditionsBindingModel # note", + ); + withFile(withComment, (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /comments/)); + withFile(`# leading\n${GOOD}`, (path) => + expectDiagnostic(() => loadModel(path), "RCT1002", /comments/), + ); + withFile( + GOOD.replace("kind: RuntimeConditionsBindingModel", "kind: &a RuntimeConditionsBindingModel"), + (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /anchors/), + ); + withFile( + GOOD.replace( + "kind: RuntimeConditionsBindingModel", + "kind: !!str RuntimeConditionsBindingModel", + ), + (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /tags/), + ); + withFile(`${GOOD}kind: RuntimeConditionsBindingModel\n`, (path) => + expectDiagnostic(() => loadModel(path), "RCT1002"), + ); + withFile(`x: &a 1\ny: *a\n`, (path) => + expectDiagnostic(() => loadModel(path), "RCT1002", /aliases/), + ); +}); + +test("a binding model rejects empty, multi-document, non-mapping, and non-string-key input", () => { + withFile("\n", (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /empty/)); + withFile("a: 1\n---\nb: 2\n", (path) => + expectDiagnostic(() => loadModel(path), "RCT1002", /exactly one document/), + ); + withFile("- 1\n- 2\n", (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /mapping/)); + withFile("1: x\n", (path) => + expectDiagnostic(() => loadModel(path), "RCT1002", /keys must be strings/), + ); + withFile("a: .nan\n", (path) => expectDiagnostic(() => loadModel(path), "RCT1002", /non-finite/)); +}); + +test("a missing file is reported as unreadable input", () => { + expectDiagnostic(() => loadModel("/nonexistent/model.yaml"), "RCT1001", /cannot read/); +}); + +test("unsupported model versions and shapes are rejected with distinct codes", () => { + const model = (change: (value: any) => void): string => { + const value = JSON.parse(JSON.stringify(loadModel(modelPath("01-owned-kind-interface")))); + change(value); + return stringify(value); + }; + withFile( + model((value) => (value.apiVersion = "runtimeconditions.io/binding-model/v9")), + (path) => expectDiagnostic(() => loadModel(path), "RCT1004"), + ); + withFile( + model((value) => (value.kind = "Other")), + (path) => expectDiagnostic(() => loadModel(path), "RCT1005"), + ); + withFile( + model((value) => (value.schemas[0].projection.kind = "tuple")), + (path) => expectDiagnostic(() => loadModel(path), "RCT1003"), + ); + withFile( + model((value) => delete value.vocabulary), + (path) => expectDiagnostic(() => loadModel(path), "RCT1003"), + ); + withFile( + model((value) => (value.surprise = true)), + (path) => expectDiagnostic(() => loadModel(path), "RCT1003"), + ); +}); + +test("package targets are validated field by field", () => { + const base = readFileSync(TARGET, "utf-8"); + assert.equal( + loadTarget(TARGET).packageName, + "@runtimeconditions/conformance-owned-kind-interface", + ); + const bad: [string, string, string, RegExp?][] = [ + ["packageKey: conformance-owned-kind-interface", "packageKey: Bad_Key", "RCT1006"], + [ + "packageName: '@runtimeconditions/conformance-owned-kind-interface'", + "packageName: 'Bad Name'", + "RCT1006", + ], + ["version: 1.0.0", "version: 1.0", "RCT1006"], + [ + "sourceDirectory: bindings/conformance-owned-kind-interface/typescript", + "sourceDirectory: bindings/other/typescript", + "RCT1007", + ], + ["minimumNodeVersion: '20.0'", "minimumNodeVersion: '18.0'", "RCT1008"], + ["minimumNodeVersion: '20.0'", "minimumNodeVersion: '20'", "RCT1008"], + ["publicationMode: github-tag", "publicationMode: registry", "RCT1006", /registryId/], + ["publicationMode: github-tag", "publicationMode: other", "RCT1006"], + [ + "repositoryUrl: https://github.com/runtimeconditions/extensions", + "repositoryUrl: http://example.com/x", + "RCT1006", + ], + ["kind: RuntimeConditionsTypeScriptPackageTarget", "kind: Other", "RCT1006"], + ]; + for (const [from, to, code, message] of bad) { + assert.ok(base.includes(from), from); + withFile(base.replace(from, to), (path) => + expectDiagnostic(() => loadTarget(path), code, message), + ); + } + withFile(`${base}extra: 1\n`, (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /unknown field 'extra'/), + ); + withFile(base.replace(/^repositoryUrl:.*\n/m, ""), (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /missing required field/), + ); + withFile(`${base}emitterSha256: nothex\n`, (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /emitterSha256/), + ); + withFile(`${base}registryId: pypi\n`, (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /registryId/), + ); + const dependency = (entry: string): string => `${base}dependencies:\n${entry}`; + withFile( + dependency( + " - extension: a\n packageName: dep\n version: 1.0.0\n - extension: a\n packageName: dep2\n version: 1.0.0\n", + ), + (path) => expectDiagnostic(() => loadTarget(path), "RCT1006", /duplicate dependency/), + ); + withFile(dependency(" - extension: a\n packageName: dep\n"), (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /missing dependency field/), + ); + withFile(dependency(" - extension: a\n packageName: Bad\n version: 1.0.0\n"), (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /invalid dependency/), + ); + withFile(dependency(" - bad\n"), (path) => + expectDiagnostic(() => loadTarget(path), "RCT1006", /mapping/), + ); +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/naming.test.ts b/tooling/extension-bindings/emitters/typescript/test/naming.test.ts new file mode 100644 index 0000000..6a028cf --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/naming.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DiagnosticError } from "../src/diagnostics.js"; +import { allocateSymbols, typescriptName } from "../src/naming.js"; + +test("names are derived from tokens with Pascal and camel case", () => { + const cases: [string, "pascal" | "camel", string][] = [ + ["base_url", "pascal", "BaseUrl"], + ["base_url", "camel", "baseUrl"], + ["HTTPServer", "pascal", "HttpServer"], + ["requestBody Schema", "camel", "requestBodySchema"], + ["key-value", "pascal", "KeyValue"], + ["GET", "pascal", "Get"], + ["3d", "pascal", "X3d"], + ["3d", "camel", "x3d"], + ["Ünï cøde", "pascal", "ÜnïCøde"], + ]; + for (const [source, style, expected] of cases) { + assert.equal(typescriptName(source, style, "coordinate"), expected); + } +}); + +test("reserved words gain a trailing underscore", () => { + assert.equal(typescriptName("delete", "camel", "c"), "delete_"); + assert.equal(typescriptName("class", "camel", "c"), "class_"); + assert.equal(typescriptName("constructor", "camel", "c"), "constructor_"); + assert.equal(typescriptName("default", "camel", "c"), "default_"); +}); + +test("a source with no letters or digits has no identifier", () => { + assert.throws( + () => typescriptName("💥", "pascal", "c"), + (error: unknown) => error instanceof DiagnosticError && error.diagnostic.code === "RCT2000", + ); + assert.throws(() => typescriptName("---", "camel", "c"), DiagnosticError); +}); + +test("exact names must already be identifiers", () => { + assert.equal(typescriptName("rcMarker_0123", "exact", "c"), "rcMarker_0123"); + assert.throws(() => typescriptName("not-valid", "exact", "c"), DiagnosticError); +}); + +test("collisions are resolved with parent path components, nearest first", () => { + const names = allocateSymbols("pkg", [ + { key: "a", coordinate: "c1", source: "mode", style: "pascal", parents: ["http", "service"] }, + { key: "b", coordinate: "c2", source: "mode", style: "pascal", parents: ["grpc", "service"] }, + ]); + assert.equal(names.get("a"), "HttpMode"); + assert.equal(names.get("b"), "GrpcMode"); +}); + +test("a collision that survives the full path stops emission without renaming", () => { + assert.throws( + () => + allocateSymbols("pkg", [ + { key: "a", coordinate: "c1", source: "mode", style: "pascal", parents: ["x"] }, + { key: "b", coordinate: "c2", source: "Mode", style: "pascal", parents: ["x"] }, + ]), + (error: unknown) => + error instanceof DiagnosticError && /after full path/.test(error.diagnostic.message), + ); +}); + +test("fixed symbols and TypeScript globals are never renamed", () => { + assert.throws( + () => + allocateSymbols("pkg", [ + { key: "a", coordinate: "c1", source: "service", style: "camel", fixed: true }, + { key: "b", coordinate: "c2", source: "Service", style: "camel", fixed: true }, + ]), + (error: unknown) => + error instanceof DiagnosticError && /fixed symbol/.test(error.diagnostic.message), + ); + assert.throws( + () => + allocateSymbols("pkg", [{ key: "a", coordinate: "c1", source: "Promise", style: "pascal" }]), + (error: unknown) => + error instanceof DiagnosticError && + /typescript-global:Promise/.test(error.diagnostic.message), + ); + assert.throws( + () => + allocateSymbols("pkg", [ + { key: "a", coordinate: "c1", source: "x", style: "pascal" }, + { key: "a", coordinate: "c2", source: "y", style: "pascal" }, + ]), + (error: unknown) => + error instanceof DiagnosticError && + /duplicate symbol request key/.test(error.diagnostic.message), + ); +}); + +test("allocation is independent of input order", () => { + const symbols = [ + { key: "a", coordinate: "c1", source: "mode", style: "pascal" as const, parents: ["http"] }, + { key: "b", coordinate: "c2", source: "mode", style: "pascal" as const, parents: ["grpc"] }, + { key: "c", coordinate: "c3", source: "other", style: "camel" as const }, + ]; + const forward = [...allocateSymbols("pkg", symbols).entries()].sort(); + const reversed = [...allocateSymbols("pkg", [...symbols].reverse()).entries()].sort(); + assert.deepEqual(forward, reversed); +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/negative.test.ts b/tooling/extension-bindings/emitters/typescript/test/negative.test.ts new file mode 100644 index 0000000..01c3d40 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/negative.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import { parse } from "yaml"; + +import { DiagnosticError } from "../src/diagnostics.js"; +import { createValidator, loadModel, modelSchemaPath, type Model } from "../src/model.js"; +import { buildPlan } from "../src/plan.js"; +import { EMITTER_ROOT, developmentTarget, modelPath } from "./support.js"; + +interface Fixture { + case: string; + positiveCase: string; + expected: Record; +} + +function mutate(name: string, model: Model): void { + if (name === "reserved-global") { + model["schemas"][0].projection.properties[1].name = "record"; + } else if (name === "fixed-function-collision") { + const second = structuredClone(model["vocabulary"].ownedDeclarations[0]); + second.coordinate = "kind:Service"; + second.kind = "Service"; + model["vocabulary"].ownedDeclarations.push(second); + } else if (name === "enum-member-collision") { + const shape = model["schemas"][0].projection.properties.find( + (item: any) => item.name === "mode", + ).shape; + shape.values.push({ value: shape.values[0].value.toUpperCase() }); + const scoped = model["scopes"][0].projection.properties.find( + (item: any) => item.name === "mode", + ).shape; + for (const key of Object.keys(scoped)) delete scoped[key]; + Object.assign(scoped, structuredClone(shape)); + } else if (name === "unnameable-field") { + model["schemas"][0].projection.properties[1].name = "💥"; + } else { + throw new Error(name); + } +} + +const fixtures = parse( + readFileSync(join(EMITTER_ROOT, "testdata/negative-diagnostics.yaml"), "utf-8"), +) as Fixture[]; + +for (const fixture of fixtures) { + test(`negative diagnostic: ${fixture.case}`, () => { + const model = loadModel(modelPath(fixture.positiveCase)); + mutate(fixture.case, model); + const validate = createValidator(modelSchemaPath()).getSchema("model")!; + assert.ok(validate(model), "the mutated model must stay schema-valid"); + assert.throws( + () => buildPlan(model, { ...developmentTarget(), rootExtension: model["rootExtension"].id }), + (error: unknown) => { + assert.ok(error instanceof DiagnosticError); + const { category, code, coordinate, jsonPointer, message } = error.diagnostic; + assert.deepEqual({ category, code, coordinate, jsonPointer, message }, fixture.expected); + return true; + }, + ); + }); +} diff --git a/tooling/extension-bindings/emitters/typescript/test/package-files.test.ts b/tooling/extension-bindings/emitters/typescript/test/package-files.test.ts new file mode 100644 index 0000000..d4cc4cf --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/package-files.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { verifyPackage } from "../src/verification.js"; +import { buildCase, temporaryDirectory } from "./support.js"; + +const CASE = "03-transitive-closure"; + +async function expectRejected( + name: string, + change: (directory: string) => void, + message: RegExp, +): Promise { + const workspace = temporaryDirectory(); + try { + const built = await buildCase(CASE, workspace); + verifyPackage(built.plan, built.directory, built.model); + change(built.directory); + assert.throws(() => verifyPackage(built.plan, built.directory, built.model), message, name); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +function edit(directory: string, file: string, from: RegExp | string, to: string): void { + const path = join(directory, file); + const text = readFileSync(path, "utf-8"); + const changed = text.replace(from, to); + assert.notEqual(changed, text, `${file} was not changed`); + writeFileSync(path, changed); +} + +test("verify compares package.json and tsconfig.json with the target and model", async () => { + await expectRejected( + "missing package.json", + (directory) => unlinkSync(join(directory, "package.json")), + /package\.json is missing from the package/, + ); + await expectRejected( + "changed name", + (directory) => edit(directory, "package.json", /"name": "[^"]+"/, '"name": "other"'), + /package\.json differs from the package target and normalized model/, + ); + await expectRejected( + "dropped dependency", + (directory) => + edit(directory, "package.json", /"dependencies": \{[^}]*\}/, '"dependencies": {}'), + /package\.json differs/, + ); + await expectRejected( + "weaker compiler options", + (directory) => edit(directory, "tsconfig.json", '"strict": true', '"strict": false'), + /tsconfig\.json differs/, + ); +}); diff --git a/tooling/extension-bindings/emitters/typescript/test/real-models.test.ts b/tooling/extension-bindings/emitters/typescript/test/real-models.test.ts new file mode 100644 index 0000000..3efd2bf --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/real-models.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { loadModel } from "../src/model.js"; +import { buildPlan } from "../src/plan.js"; +import { emitPackage } from "../src/source.js"; +import { verifyPackage } from "../src/verification.js"; +import { conformanceSource } from "../src/conformance.js"; +import { TOOLING, developmentTarget, runTsc, temporaryDirectory } from "./support.js"; + +const COMMON = join( + TOOLING, + "../../bindings/common-integrations/go/runtimeconditions.binding-model.yaml", +); +const ENV = join( + TOOLING, + "../../bindings/env-configuration/go/runtimeconditions.binding-model.yaml", +); +const COMMON_ID = + "https://runtimeconditions.io/extensions/common-integrations/v1alpha1/runtimeconditions.extension.yaml"; + +test( + "the committed first-party models emit packages that compile, including recursive maps", + { skip: !existsSync(COMMON) || !existsSync(ENV) }, + async () => { + const workspace = temporaryDirectory(); + try { + const common = loadModel(COMMON); + const commonPlan = buildPlan(common, { + ...developmentTarget(), + packageKey: "common-integrations", + sourceDirectory: "bindings/common-integrations/typescript", + packageName: "@runtimeconditions/common-integrations", + rootExtension: common["rootExtension"].id, + }); + const commonDirectory = join( + workspace, + "node_modules/@runtimeconditions/common-integrations", + ); + mkdirSync(commonDirectory, { recursive: true }); + rmSync(commonDirectory, { recursive: true }); + await emitPackage(commonPlan, common, commonDirectory); + verifyPackage(commonPlan, commonDirectory, common); + writeFileSync( + join(commonDirectory, "src/_conformance.ts"), + conformanceSource(commonPlan, common), + ); + const compiled = runTsc(commonDirectory, false); + assert.equal(compiled.status, 0, compiled.output); + + const env = loadModel(ENV); + const envPlan = buildPlan(env, { + ...developmentTarget(), + packageKey: "env-configuration", + sourceDirectory: "bindings/env-configuration/typescript", + packageName: "@runtimeconditions/env-configuration", + rootExtension: env["rootExtension"].id, + dependencies: [ + { + extension: COMMON_ID, + packageName: "@runtimeconditions/common-integrations", + version: "0.1.0", + }, + ], + }); + const envDirectory = join(workspace, "node_modules/@runtimeconditions/env-configuration"); + await emitPackage(envPlan, env, envDirectory); + verifyPackage(envPlan, envDirectory, env); + writeFileSync(join(envDirectory, "src/_conformance.ts"), conformanceSource(envPlan, env)); + const envCompiled = runTsc(envDirectory, true); + assert.equal(envCompiled.status, 0, envCompiled.output); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }, +); diff --git a/tooling/extension-bindings/emitters/typescript/test/support.ts b/tooling/extension-bindings/emitters/typescript/test/support.ts new file mode 100644 index 0000000..fdf0339 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/support.ts @@ -0,0 +1,202 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { conformanceSource } from "../src/conformance.js"; +import { loadModel, type Model } from "../src/model.js"; +import { buildPlan, type EmissionPlan } from "../src/plan.js"; +import { emitPackage } from "../src/source.js"; +import { loadTarget, type PackageTarget } from "../src/target.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +export const EMITTER_ROOT = resolve(here, "../.."); +export const TOOLING = resolve(EMITTER_ROOT, "../.."); +export const MODELS = join(TOOLING, "model/conformance/expected"); +export const TARGET = join(EMITTER_ROOT, "testdata/package-target.yaml"); +export const TSC = join(EMITTER_ROOT, "node_modules/typescript/bin/tsc"); + +export const CASES = readdirSync(MODELS) + .filter((name) => existsSync(join(MODELS, name, "runtimeconditions.binding-model.yaml"))) + .sort(); + +export function modelPath(name: string): string { + return join(MODELS, name, "runtimeconditions.binding-model.yaml"); +} + +export function temporaryDirectory(): string { + return mkdtempSync(join(tmpdir(), "rc-ts-emitter-")); +} + +export function emitterDigest(): string { + const files = [ + "package.json", + ...readdirSync(join(EMITTER_ROOT, "src")) + .filter((name) => name.endsWith(".ts")) + .sort() + .map((name) => `src/${name}`), + ]; + const inventory = Object.fromEntries( + files.map((path) => [ + path, + createHash("sha256") + .update(readFileSync(join(EMITTER_ROOT, path))) + .digest("hex"), + ]), + ); + return createHash("sha256").update(JSON.stringify(inventory)).digest("hex"); +} + +export function developmentTarget(): PackageTarget { + return { ...loadTarget(TARGET), emitterSha256: emitterDigest() }; +} + +export function rootOf(model: Model): string { + return model["rootExtension"].id as string; +} + +function directDependencies(model: Model): string[] { + return model["extensions"].find((item: any) => item.id === rootOf(model)).dependencies ?? []; +} + +export function targetFor( + name: string, + model: Model, + providers: Map, +): PackageTarget { + return { + ...developmentTarget(), + packageKey: name, + sourceDirectory: `bindings/${name}/typescript`, + packageName: `@rc/${name}`, + rootExtension: rootOf(model), + dependencies: directDependencies(model).map((extension) => ({ + extension, + packageName: + providers.get(extension) ?? + `unresolved-${createHash("sha256").update(extension).digest("hex").slice(0, 8)}`, + version: "1.0.0", + })), + }; +} + +export function planFor(model: Model, name = "case"): EmissionPlan { + const providers = new Map(); + for (const extension of directDependencies(model)) + providers.set(extension, `provider-${providers.size}`); + return buildPlan(model, targetFor(name, model, providers)); +} + +export function runTsc( + projectDirectory: string, + noEmit: boolean, +): { status: number; output: string } { + try { + const output = execFileSync( + process.execPath, + [TSC, "-p", join(projectDirectory, "tsconfig.json"), ...(noEmit ? ["--noEmit"] : [])], + { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return { status: 0, output }; + } catch (error) { + const failure = error as { status?: number; stdout?: string; stderr?: string }; + return { + status: failure.status ?? 1, + output: `${failure.stdout ?? ""}${failure.stderr ?? ""}`, + }; + } +} + +export interface BuiltCase { + readonly directory: string; + readonly model: Model; + readonly plan: EmissionPlan; +} + +/** Declare the provider packages a consumer imports, from its plan, as type-only stubs. */ +export function writeProviderStubs( + plan: EmissionPlan, + workspace: string, + markerOverride?: string, +): void { + const byProvider = new Map(); + for (const item of plan.importedDeclarations) { + byProvider.set(item.providerPackage!, [...(byProvider.get(item.providerPackage!) ?? []), item]); + } + for (const [name, declarations] of byProvider) { + const directory = join(workspace, "node_modules", name); + mkdirSync(directory, { recursive: true }); + writeFileSync( + join(directory, "package.json"), + JSON.stringify({ + name, + version: "1.0.0", + type: "module", + types: "./index.d.ts", + exports: { ".": { types: "./index.d.ts" } }, + }), + ); + const lines = ["export declare class Declaration {}"]; + for (const item of declarations) { + lines.push(`export interface ${item.protocol} { ${markerOverride ?? item.method}(): void }`); + lines.push( + `export declare function ${item.function}(...fields: ${item.protocol}[]): Declaration;`, + ); + } + writeFileSync(join(directory, "index.d.ts"), `${lines.join("\n")}\n`); + } +} + +/** Emit a case into a workspace whose node_modules hold stubs of its direct providers. */ +export async function buildCase( + name: string, + workspace: string, + markerOverride?: string, +): Promise { + const model = loadModel(modelPath(name)); + const providers = new Map(); + for (const extension of directDependencies(model)) + providers.set(extension, `provider-${providers.size}`); + const plan = buildPlan(model, targetFor(name, model, providers)); + writeProviderStubs(plan, workspace, markerOverride); + const directory = join(workspace, "pkg", name); + mkdirSync(dirname(directory), { recursive: true }); + await emitPackage(plan, model, directory); + return { directory, model, plan }; +} + +export function writeConformance(built: BuiltCase): void { + writeFileSync( + join(built.directory, "src/_conformance.ts"), + conformanceSource(built.plan, built.model), + "utf-8", + ); +} + +export function snapshot(directory: string): Record { + const result: Record = {}; + const visit = (current: string): void => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isDirectory()) { + if (entry.name !== "node_modules" && entry.name !== "dist") visit(path); + } else { + result[relative(directory, path)] = readFileSync(path, "utf-8"); + } + } + }; + visit(directory); + return result; +} diff --git a/tooling/extension-bindings/emitters/typescript/test/transitive.test.ts b/tooling/extension-bindings/emitters/typescript/test/transitive.test.ts new file mode 100644 index 0000000..3dc6603 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/test/transitive.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { loadModel, type Model } from "../src/model.js"; +import { buildPlan } from "../src/plan.js"; +import { emitPackage } from "../src/source.js"; +import type { PackageTarget } from "../src/target.js"; +import { verifyPackage } from "../src/verification.js"; +import { developmentTarget, modelPath, runTsc, temporaryDirectory } from "./support.js"; + +function target(key: string, root: string, dependencies: [string, string][]): PackageTarget { + return { + ...developmentTarget(), + packageKey: key, + sourceDirectory: `bindings/${key}/typescript`, + packageName: `@rc/${key}`, + rootExtension: root, + dependencies: dependencies.map(([extension, packageName]) => ({ + extension, + packageName, + version: "1.0.0", + })), + }; +} + +test("root -> middle -> leaf compile together through the middle package's re-export", async () => { + const model = loadModel(modelPath("03-transitive-closure")); + const rootId: string = model["rootExtension"].id; + const leafId: string = model["vocabulary"].importedDeclarations[0].owner; + const middle = model["extensions"].find((item: any) => + (item.dependencies ?? []).includes(leafId), + ); + const middleId: string = middle.id; + const extension = (id: string): any => model["extensions"].find((item: any) => item.id === id); + + const leafModel: Model = structuredClone(model); + leafModel["rootExtension"] = { ...extension(leafId) }; + leafModel["extensions"] = [extension(leafId)]; + leafModel["vocabulary"].ownedDeclarations = model["vocabulary"].importedDeclarations; + leafModel["vocabulary"].importedDeclarations = []; + + const middleModel: Model = structuredClone(model); + middleModel["rootExtension"] = { ...extension(middleId) }; + middleModel["extensions"] = [extension(leafId), extension(middleId)]; + middleModel["vocabulary"].ownedDeclarations = []; + + const workspace = temporaryDirectory(); + try { + const packages = [ + { key: "leaf", model: leafModel, target: target("leaf", leafId, []) }, + { + key: "middle", + model: middleModel, + target: target("middle", middleId, [[leafId, "@rc/leaf"]]), + }, + { key: "root", model, target: target("root", rootId, [[middleId, "@rc/middle"]]) }, + ]; + // Each package sits in node_modules, where the next one resolves it, and is built before its consumer. + for (const item of packages) { + const directory = join(workspace, "node_modules", "@rc", item.key); + mkdirSync(directory, { recursive: true }); + const plan = buildPlan(item.model, item.target); + await emitPackage(plan, item.model, directory); + verifyPackage(plan, directory, item.model); + const built = runTsc(directory, false); + assert.equal(built.status, 0, `${item.key}: ${built.output}`); + } + const root = readFileSync(join(workspace, "node_modules/@rc/root/src/bindings.ts"), "utf-8"); + assert.match(root, /from "@rc\/middle"/); + const middleSource = readFileSync( + join(workspace, "node_modules/@rc/middle/src/bindings.ts"), + "utf-8", + ); + assert.match(middleSource, /from "@rc\/leaf"/); + assert.match(middleSource, /export \{[^}]*WorkerField/); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/tooling/extension-bindings/emitters/typescript/testdata/negative-diagnostics.yaml b/tooling/extension-bindings/emitters/typescript/testdata/negative-diagnostics.yaml new file mode 100644 index 0000000..c21f4c2 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/testdata/negative-diagnostics.yaml @@ -0,0 +1,32 @@ +- case: reserved-global + positiveCase: 01-owned-kind-interface + expected: + category: typescript-naming + code: RCT2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "symbol 'Record' conflicts: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http/properties/region and typescript-global:Record" +- case: fixed-function-collision + positiveCase: 01-owned-kind-interface + expected: + category: typescript-naming + code: RCT2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "fixed symbol 'ServiceField' conflicts: kind:Service and kind:service" +- case: enum-member-collision + positiveCase: 10-scoped-domains-collisions + expected: + category: typescript-naming + code: RCT2001 + coordinate: conformance-owned-kind-interface + jsonPointer: '' + message: "symbol 'Streaming' conflicts after full path: https://runtimeconditions.io/conformance/scoped-domains-collisions:1.0.0#schema:service-grpc/properties/mode#value:STREAMING and https://runtimeconditions.io/conformance/scoped-domains-collisions:1.0.0#schema:service-grpc/properties/mode#value:streaming" +- case: unnameable-field + positiveCase: 01-owned-kind-interface + expected: + category: typescript-naming + code: RCT2000 + coordinate: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0#schema:service-http/properties/region + jsonPointer: '' + message: "cannot derive TypeScript identifier from '💥'" diff --git a/tooling/extension-bindings/emitters/typescript/testdata/package-target.yaml b/tooling/extension-bindings/emitters/typescript/testdata/package-target.yaml new file mode 100644 index 0000000..f0bac90 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/testdata/package-target.yaml @@ -0,0 +1,10 @@ +apiVersion: runtimeconditions.io/typescript-package-target/v1alpha1 +kind: RuntimeConditionsTypeScriptPackageTarget +packageKey: conformance-owned-kind-interface +rootExtension: https://runtimeconditions.io/conformance/owned-kind-interface:1.0.0 +packageName: '@runtimeconditions/conformance-owned-kind-interface' +version: 1.0.0 +sourceDirectory: bindings/conformance-owned-kind-interface/typescript +minimumNodeVersion: '20.0' +publicationMode: github-tag +repositoryUrl: https://github.com/runtimeconditions/extensions diff --git a/tooling/extension-bindings/emitters/typescript/tsconfig.json b/tooling/extension-bindings/emitters/typescript/tsconfig.json new file mode 100644 index 0000000..a1f7312 --- /dev/null +++ b/tooling/extension-bindings/emitters/typescript/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": false, + "types": ["node"] + }, + "include": ["src", "test"] +} diff --git a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml index d051aca..e4ac3c0 100644 --- a/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml +++ b/tooling/extension-bindings/model/runtimeconditions.binding-manifest.schema.yaml @@ -57,30 +57,55 @@ properties: For Go, coordinate is the package import path, name is the Go package name, and version is the resolved module version. For Python, coordinate is the distribution name, name is the import package, and version is the - installed distribution version. Consumers must compare all applicable - fields to native package-manager metadata. + installed distribution version. For TypeScript, coordinate and name are + the npm package name and version is the installed package version. + Consumers must compare all applicable fields to native package-manager + metadata. type: object additionalProperties: false required: [language, coordinate, name, version] properties: - language: {enum: [go, python]} + language: {enum: [go, python, typescript]} coordinate: {type: string, minLength: 1} - name: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_]*$'} + name: {type: string, minLength: 1} version: {type: string, minLength: 1} minimumGoVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} minimumPythonVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} + minimumNodeVersion: {type: string, pattern: '^[0-9]+\.[0-9]+$'} allOf: - if: properties: language: {const: go} then: required: [minimumGoVersion] + properties: + name: {pattern: '^[A-Za-z_][A-Za-z0-9_]*$'} not: - required: [minimumPythonVersion] - else: + anyOf: + - required: [minimumPythonVersion] + - required: [minimumNodeVersion] + - if: + properties: + language: {const: python} + then: required: [minimumPythonVersion] + properties: + name: {pattern: '^[A-Za-z_][A-Za-z0-9_]*$'} + not: + anyOf: + - required: [minimumGoVersion] + - required: [minimumNodeVersion] + - if: + properties: + language: {const: typescript} + then: + required: [minimumNodeVersion] + properties: + name: {pattern: '^(?:@[a-z0-9][a-z0-9-]*/)?[a-z0-9][a-z0-9-]*$'} not: - required: [minimumGoVersion] + anyOf: + - required: [minimumGoVersion] + - required: [minimumPythonVersion] declarations: type: array items: {$ref: '#/$defs/declaration'} @@ -109,7 +134,12 @@ allOf: items: properties: file: {const: bindings.go} - else: + - if: + properties: + package: + properties: + language: {const: python} + then: properties: declarations: items: @@ -122,6 +152,24 @@ allOf: items: properties: file: {const: bindings.py} + - if: + properties: + package: + properties: + language: {const: typescript} + then: + properties: + declarations: + items: + properties: + file: {const: bindings.ts} + importedMarkerContracts: + items: + required: [providerPackage] + types: + items: + properties: + file: {const: bindings.ts} $defs: extensionIdentifier: type: string @@ -163,12 +211,13 @@ $defs: description: >- Exactly one local native type or language builtin. Pointer records Go pointer syntax; nullable records a Python annotation accepting None. + TypeScript records optional properties through the field's required flag. A consumer must resolve every type to exactly one entry in types. type: object additionalProperties: false properties: type: {$ref: '#/$defs/nativeIdentifier'} - builtin: {enum: [string, bool, int64, float64, 'struct{}', any, str, int, float, 'None', JSONValue]} + builtin: {enum: [string, bool, int64, float64, 'struct{}', any, str, int, float, 'None', JSONValue, boolean, number, 'null']} pointer: {const: true} nullable: {const: true} not: @@ -187,7 +236,7 @@ $defs: function: {$ref: '#/$defs/nativeIdentifier'} markerInterface: {$ref: '#/$defs/nativeIdentifier'} markerMethod: {$ref: '#/$defs/nativeIdentifier'} - file: {enum: [bindings.go, bindings.py]} + file: {enum: [bindings.go, bindings.py, bindings.ts]} importedMarkerContract: type: object additionalProperties: false @@ -302,7 +351,7 @@ $defs: sourceName: {type: string, minLength: 1} nativeName: {$ref: '#/$defs/nativeIdentifier'} construct: {enum: [scalar, object, collection, map, union, any, wrapper]} - file: {enum: [bindings.go, bindings.py]} + file: {enum: [bindings.go, bindings.py, bindings.ts]} implements: type: array items: {$ref: '#/$defs/implements'} diff --git a/tooling/extension-bindings/toolchain.lock.yaml b/tooling/extension-bindings/toolchain.lock.yaml index a58967b..4d5e93b 100644 --- a/tooling/extension-bindings/toolchain.lock.yaml +++ b/tooling/extension-bindings/toolchain.lock.yaml @@ -19,3 +19,14 @@ python: types-pyyaml: '6.0.12.20260906' jsonschema: '4.26.0' packaging: '26.3' +languages: + typescript: + languageVersion: '20.19.6' + packageManager: npm + buildBackend: tsc + tools: + typescript: '5.9.3' + prettier: '3.9.10' + yaml: '2.9.1' + ajv: '8.20.0' + ajv-formats: '3.0.1'