Project: Transform TypeSpec models into AsyncAPI 3.1 specifications Architecture: AssetEmitter-based with custom TypeEmitter for schema generation
pnpm install # Install dependencies
pnpm run build # Build TypeScript → JavaScript (0 errors)
pnpm run lint # ESLint + oxlint (0 errors, 0 warnings)
pnpm run test # Run tests via vitest
pnpm run verify # Full gate: build + lint + typecheck:test + test + coverage:gate + duplicateImportant: Use pnpm for everything — never npm/npx or raw bun (details in Critical Constraints). Run commands inside nix develop .#default.
- Toolchain:
pnpmfor package management and scripts. Tests via vitest (Node.js/V8, stable GC under heavy compilation)..tsscripts viabun run(NOTtsx— needs real Node.js, unavailable on NixOS wherenodeis a Bun wrapper). - Build-before-test policy: Tests won't run if TypeScript compilation fails. The compiler loads the emitter from
dist/via a virtual filesystem — always build before testing emitter changes. - Test suite typechecked via
tsconfig.test.json(pnpm run typecheck:test, averifystage): relaxed (strict:false,noUncheckedIndexedAccess:false,types:["node","vitest/globals"]). Keep it at 0 errors. vtsls/LSP diagnostics intest/are stale/unreliable — trusttsc -p tsconfig.test.jsonand vitest output instead. - Coverage runs via
bun test --coverage(NOT vitest or c8). The TypeSpec compiler loads the emitter fromdist/through a virtual filesystem, bypassing vitest's module transform. Only Bun's native runtime-level coverage captures these dynamically-loadeddist/*.jsfiles — vitest V8, istanbul, and c8 all fail to see them. The gate script (scripts/coverage-gate.ts) remapsdist/src/*.jsback tosrc/*.tspaths and merges coverage, preferring the higher-coverage entry. Bun is kept inflake.nixsolely for this purpose and banned everywhere else. Gate: 75% per-file minimum. - git commit --no-verify: The pre-commit hook (
.husky/pre-commit,#!/bin/sh) runs the FULL verify gate (~2 min). The established convention: commit with--no-verifyand runpnpm run verifymanually before committing. Always run the full gate — "tests" ≠ "gate" (lint/duplication/coverage catch what vitest can't). - Lint/duplication tools are pinned devDependencies (
oxlint,jscpdin package.json), resolving fromnode_modules/.bin— NOT from system/Nix packages. Unpinned binaries broke GitHub CI (command not foundon ubuntu-latest) and local gates on host drift. - All source files under 400 lines (oxlint
max-lines, excluding auto-generatedgenerated-bindings.ts). When a file approaches the limit, extract (precedents:store-protocol-config.ts,extension-decorators.ts). - Duplication budget: jscpd threshold 0% (
.jscpd.json); baseline 0 clones / 0% tokens. Remaining structural patterns (e.g.(context, target, config): voiddecorator signatures) are intrinsic to TypeSpec's decorator API. Runpnpm run duplicateto verify. When duplication appears, extract shared interfaces/factories (precedents:DocumentBody,DiagnosticContext,makeStringIdDecorator<T>,messageDecorator<K>,checkBoundHOF). - Diagnostic system:
reportDiagnostic()indecorator-helpers.tsuses$lib.reportDiagnostic()(TypeSpec library API), NOT rawprogram.reportDiagnostic(). All codes are declared insrc/lib.tsand compile-time validated viakeyof typeof $lib.diagnostics— count them there, never trust a hardcoded number in docs. The library name is auto-prefixed by the TypeSpec runtime. All codes actively referenced — no dead codes. - Zero
anytypes in emitter.ts (achieved). - ESLint config:
strict+strictTypeCheckedfrom typescript-eslint, all rules aterror(noany, no unsafe operations, no floating promises, no misused promises, require-await, no unnecessary conditions/assertions).noUncheckedIndexedAccessin tsconfig makes indexed-access types honest, enablingno-unnecessary-condition. - Linting strategy (dual linter): ESLint handles type-aware rules on
src/only. oxlint handles non-type-aware rules on ALL files (style, perf, complexity metrics, suspicious patterns, eqeqeq). Complexity thresholds: max-lines 400, max-lines-per-function 200, max-statements 80, complexity 50, max-params 5. Zero rule conflicts.pnpm run lintruns both; usepnpm run lint:eslint/pnpm run lint:oxfor faster iteration.
- Entry Point:
src/index.ts→ exports$onEmitfor TypeSpec compiler - Emitter (9 core files, split from the original 831-line monolith):
src/emitter.ts—$onEmitentry point, writes output file, handlessplit-schemasoptionsrc/schema-emitter.ts—AsyncAPISchemaEmitterextendsTypeEmitter<JsonSchema, AsyncAPIEmitterOptions>; overridesmodelDeclaration(allOffor inheritance,discriminatorfor polymorphic models),modelInstantiation/unionInstantiation(argument-derived declaration names),unionDeclaration(oneOffor all-Model variants,anyOffor mixed),enumDeclaration,intrinsic,operationReturnType, etc.src/schema-ref.ts—refForNamedType()resolves named TypeSpec types to$refpointers;declarationNameOf()mirrors the framework's instantiation naming;schemaNameForType()is the public APIsrc/schema-generator.ts—generateSchemas()entry point, creates asset emitter and collects declarationssrc/extract-value.ts—extractValue()narrowsEmitEntity<T>discriminated union, filtersPlaceholder<T>lazy valuessrc/stdlib-helpers.ts—isStdlibType()andcollectAllStdlibNames()utilitiessrc/constraint-mapper.ts—applyConstraints(),applyMetadata(),resolveEncode(): maps 16 TypeSpec stdlib constraint/metadata mappings to JSON Schema keywords via table-drivenCONSTRAINT_TABLE(10 validation entries) + inline metadata. Validation keywords skipped on$refschemassrc/document-builder.ts—buildAsyncAPIDocument()entry point; delegates tosrc/builders/for assembly. Handles$refchain constructionsrc/intrinsic-mapping.ts—intrinsicToSchema()maps TypeSpec scalar names to JSON Schema types (~30 cases)src/schema-splitter.ts—splitSchemas()extracts schemas into individual files, rewrites$refpointers to external paths
- Document Builders (
src/builders/, 11 files):operation-discovery.ts— discovers channel-decorated operations from a TypeSpec namespacemessage-builder.ts— builds message objects with$refregistrationshared-utils.ts— shared helpers:registerMessage(),resolveMessageKey(),inferActionFromName(),normalizeOAuth2Scopes(),buildProtocolBindings()operation-builder.ts— builds operation objects with action, channel ref, messages, and replychannel-builder.ts— builds channel objects with address, messages, bindingsserver-builder.ts— builds server objects with host, protocol, variables, and namespace-scoped bindingssecurity-builder.ts— buildscomponents.securitySchemesfrom@securitystatetag-builder.ts— collects@tagsstate into reusablecomponents.tagsmap (dedup by name)components-builder.ts— builds reusablecomponents.*(operationTraits, messageTraits, parameters, correlationIds, operation/message/server/channel bindings) and applies$refreferences via 3-way binding dispatchtypes.ts— shared builder context and result types_imports.ts— re-exports from@typespec/compilerfor tree-shakeable imports across builders
- Decorators:
lib/main.tspdeclares all 30 decorators (19 core + 11 reusable-component: 5 definition on Namespace + 6 reference) + theEmitterOptionsmodel for IDE autocomplete. - Decorator Implementations:
src/decorators.ts(unified registry),src/minimal-decorators.tsandsrc/namespace-decorators.ts(thin wrappers with runtime validation, helpers insrc/decorator-helpers.ts, state writing insrc/state-writers.ts).$server,$defaultContentType, and the 5 reusable-component definition decorators use thenamedConfigDecoratorfactory; reference decorators usemakeUseDecorator(src/use-decorators.ts).$bindingstargetsOperation | Model | Namespace(Namespace → server bindings viabindingTargetKind).@jsonSchemaExtension/@extensionlive insrc/extension-decorators.ts. - Reusable Components: 11 decorators populate
components.operationTraits,messageTraits,parameters,correlationIds,operationBindings,messageBindings,channelBindings. Definition decorators (@operationTrait,@messageTrait,@parameter,@reusableCorrelationId,@reusableBinding) target Namespace. Reference decorators (@useOperationTrait,@useMessageTrait,@useCorrelationId,@useBinding,@useChannelBinding,@useChannelServer) target Operation/Model. Channel address parameters ({paramName}) auto-upgrade to$refwhen a matching@parameterexists. Inline@correlationIdstill works without populatingcomponents.correlationIds. - State Management:
src/state.ts(consolidation),src/state-compatibility.ts(TypeSpec stateMap access) - Configuration:
src/infrastructure/configuration/— types only, no runtime validation - Protocols:
src/constants/protocols.ts— single source of truth (const array → derived type → runtime Set + type guard). 22 protocols (HTTP, HTTPS, WS, WSS, MQTT, MQTT5, Kafka, AMQP, AMQP1, NATS, JMS, SNS, SQS, STOMP, Redis, GooglePubSub, Mercure, IBMMQ, Pulsar, Solace, AnypointMQ, ROS2). Aliases accepted (websocket→ws) vianormalizeProtocol(). Canonical names only inPROTOCOL_LIST;isSupportedProtocol()narrows toAcceptedProtocol. - Binding Versions:
src/constants/binding-versions.ts— single source of truth per protocol. 19 binding protocols, versions auto-generated from@asyncapi/specs/bindings/viascripts/generate-binding-specs.ts.normalizeBindingProtocol()mapswss→wsfor binding keys (the AsyncAPI schema useswsfor both). Auto-injectsbindingVersionwhen missing. TheBINDING_PLACEMENTmatrix is auto-generated;supportsBindingPlacement()/getValidPlacements()consume it. - Binding Validation:
src/validation/binding-validator.ts+src/validation/binding-field-validator.ts—processBindings()takes an optionaltargetKind(Operation→"operation",Model→"message"). Normalizes binding keys, validates versions, auto-injects missingbindingVersion, warns on misplaced bindings.validateBindingFields()checks field values against spec-derived rules (auto-generated ingenerated-bindings.ts).BindingDiagnosticCodeunion replaces the oldstringcode field. - Security Scheme Types:
src/domain/models/asyncapi-document.ts—SECURITY_SCHEME_TYPESconst array →SecuritySchemeTypeunion →isValidSchemeTyperuntime guard. Matches AsyncAPI 3.1 exactly (nosasl/mutualTLS/external/oauthBearer). Multi-security per namespace via array accumulation. - Document Model:
src/domain/models/asyncapi-document.ts— strongly-typed AsyncAPI 3.1 interfaces (OAuth2Flows,ProtocolBindings,SecuritySchemeType) plus one-lineref*constructors (ref(),refSchema(),refMessage(),refChannel(), …) over a sharedref(pointer). No index signatures exceptJsonSchema(standard JSON Schema extension pattern). - Cross-emitter Shared Module:
src/shared/— exportsJsonSchema,SchemaRef,SchemaMapandgenerateSchemas,extractValue,intrinsicToSchema,AsyncAPISchemaEmitterfor reuse by other TypeSpec emitters. Subpath export:@lars-artmann/typespec-asyncapi/shared. - Multi-file Output:
split-schemasoption splits schemas into individual files underschemas/; all$refvalues rewritten from#/components/schemas/Nametoschemas/Name.{ext}in both main document and schema files. asyncapi-idoption: sets the root documentid(e.g."urn:com:example:api"); omitted when unset.@protocolfield placement mapping: config fields emit at spec-correct placements, not where they're written: kafkapartitions/replicationFactor→ channel bindingpartitions/replicas; kafkaconsumerGroup→ operation bindinggroupIdas a{ type: "string", const: ... }schema (AsyncAPI 3.1 requires schema|boolean — plain strings fail AJV); mqttqos/retain→ operation binding (mqtt has NO channel binding); wsheaders/queryParams→ channel bindingheaders/query. Unknown top-level keys and the nestedbinding:map pass through to the channel binding (or operation binding when the protocol defines no channel binding, e.g. http). No defaults fabricated; version-only binding shells never emitted;mqtt5normalizes to themqttbinding key.- tsconfig:
"types": ["node"]forstructuredClone(used inschema-splitter.ts);"noUncheckedIndexedAccess": truefor honest indexed access.
The document MUST follow this reference chain:
operations → #/channels/{id}/messages/{id}
channels → #/components/messages/{id}
components.messages → #/components/schemas/{name}
Nested model properties use $ref for named user-defined models; model inheritance uses allOf with $ref to the base model.
Tests use vitest with the TypeSpec compiler testing API (createTester). All compilation is programmatic via test/utils/test-helpers.ts — no process spawning. Test files use vitest globals (globals: true in vitest config). compileAsyncAPI() returns allOutputFiles: Map<string, string> for multi-file output testing. Test helpers auto-detect @typespec/versioning imports and add the library to the virtual filesystem. compileAsyncAPI uses tester.compileAndDiagnose() — all compilation APIs consistently surface decorator-reported diagnostics.
src/document-builder.ts imports getVersion() from @typespec/versioning. When a namespace is @versioned, the latest enum value is the info.version fallback (precedence: emitter version option > @apiVersion > @versioned enum > "1.0.0").
test/utils/test-helpers.ts—compileAsyncAPI,compileAsyncAPISpecRaw,compileAsyncAPISpecWithoutErrors(all returndiagnostics)test/utils/cli-test-helpers.ts— CLI-compatible wrappertest/utils/type-guards.ts—inlineObject<T>(value, label?)(narrowsRef | T, throws on surprise$ref) andasJsonSchema(value, label?)(narrowsitems/additionalPropertiesunions) — prefer these overascasts so a surprise$reffails loudlytest/utils/schema-validator.ts— reusable AJV harness:compileAndValidate(),compileAndValidateOrThrow(),validateAsyncAPIDocument(doc)(validates an already-parsed document, throws with formatted errors),formatValidationErrors()- Emitter diagnostic codes are library-prefixed in test assertions: filter with
d.code?.endsWith("<code>")or the full"@lars-artmann/typespec-asyncapi/<code>"string — a bared.code === "<code>"never matches. - Two blockless namespaces in one file are invalid (
asyncApiDocbecomes null). For multi-namespace tests use nestednamespace Root;+namespace First { ... }blocks (seetest/integration/multi-namespace-isolation.test.ts). - AsyncAPI 3.1 root schema is
additionalProperties: false— never AJV-validate a document object decorated with test extras (compileAsyncAPISpecreturns the doc withdiagnostics/outputFilesmerged in); parse fromoutputFilesor strip extras first.
test/golden/— golden-file locks (livesession-xyd, channel-bindings, polymorphism, reusable-components, server-security). Regenerate viabun run scripts/regenerate-golden.tsafter intentional fixture/output changestest/compliance/— AsyncAPI 3.1.0 spec compliance suite (~270 tests, 18+ files), all AJV-validated viacompileAndValidateOrThrow()test/property/emitter-properties.test.ts— fast-check invariants over randomly generated specstest/realworld/— external repo patterns + canonical AsyncAPI spec ports + golden regression guardtest/integration/,test/e2e/,test/external/,test/bdd/,test/benchmark/,test/domain/,test/unit/,test/decorators/— see FEATURES.md for the inventoryexamples/— 13 runnable pnpm-workspace examples;pnpm run check-examplescompiles all with 0 diagnostics and AJV-validates each (enforced in CI)
Decorators accept BOTH {} (Model expression types) AND #{} (value literals); targets vary per decorator:
extern dec security(target: Operation | Namespace, config: {} | valueof Record<unknown>);
extern dec bindings(target: Operation | Model | Namespace, value: {} | valueof Record<unknown>);@typespec/asset-emitter returns EmitEntity<T> objects that must be narrowed by entity.kind ("declaration", "code", "none", "circular" — only the first two carry .value). Placeholder<T> lazy values are detected by duck-typing an onValue function and must NOT be treated as final values. extractValue() in src/extract-value.ts is the single authoritative implementation.
- Intrinsic mappings:
unknown/void/never→{}(unconstrained schema, NOT{type:"string"});null→{type:"null"}(sostring | null→anyOf: [{type:"string"},{type:"null"}]). Locked bytest/compliance/type-mapping-completeness.test.ts"intrinsic types". - Framework-interned schema values must not be mutated: the asset-emitter treats intrinsics as declarations and shares ONE value object across every usage of that type.
applyConstraints/applyMetadatamutate in place — mutating a shared value leaks metadata onto unrelated usages (e.g. stdlibOperationExample's doc appearing on everyunknown).propertyToSchemaclones ({ ...schema }) before applying constraints; keep that clone when touching the property path. - Diagnostic severity enums differ by library: TypeSpec's
DiagnosticSeverityis a string union ("error" | "warning"—String(d.severity) === "error"works). Spectral's@stoplight/typesDiagnosticSeverityis a NUMERIC enum (Error = 0) — comparingd.severity === "error"is a silent no-op; importDiagnosticSeverityfrom@asyncapi/parserand compareDiagnosticSeverity.Error. - Root config is
tspconfig.yamlonly. TypeSpec resolves the yaml first; atspconfig.jsonalongside it is a split-brain. Do not recreate the json. - Use
#{ url: "...", protocol: "..." }syntax for@server(comma-separated, not semicolons) SERIALIZATION_FORMAT_OPTION_JSONis an object{format, pretty, indent}, not a stringemitFileneeds theemitterOutputDirprefix or crashes in CLI mode- Channel addresses with
/are JSON-pointer-escaped:$reftokens use~1for/and~0for~per RFC 6901. Object keys stay raw. file-typeoption can be string"json"/"yaml"/"yml"or object{ format: "json", pretty: true, indent: 2 }— all honored- Arrays/Records of named models:
Item[]must emititems: { $ref: "#/components/schemas/Item" };Record<Item>must emitadditionalProperties: { $ref };Record<string>maps to{ type: "object", additionalProperties: { type: "string" } }(NOTtype: "array"). Fix pattern: callrefForNamedType()BEFOREemitTypeReference(which returns NoEmit for declaration refs, causingextractValue→{}→ wrong intrinsic fallback). - AsyncAPI 3.1 binding key names: the binding object key MUST match the official schema —
ws(notwebsocket/websockets),kafka,http,amqp, etc. - Kafka binding placement: channel bindings allow
topic,partitions,replicas,topicConfiguration,bindingVersion; operation bindings allowgroupId,clientId,bindingVersion; message bindings allowkey,schemaIdLocation,schemaIdPayloadEncoding,schemaLookupStrategy,bindingVersion. All requirebindingVersionfor schema validation. - OAuth2 scopes: AsyncAPI 3.1 uses
availableScopes(notscopes) — a map{scopeName: "description"}, not an array. The emitter accepts both input keys, always outputsavailableScopesvianormalizeOAuth2Scopes(). - TypeSpec value literals (
#{}): property names must be valid identifiers. Reserved words (const,enum,default,export,function,model,op, …) cannot be keys — including as MODEL PROPERTY NAMES (the parser treats them as keywords;model: stringfails withtoken-expected). Quoted keys ("retention.ms","x-custom-field") are not supported — use camelCase/PascalCase.#{}members require COMMAS (newline-separated is a parse error, unlike model properties). - Protocol alias normalization:
websocketis accepted as INPUT but normalized towsvianormalizeProtocol(); never emitwebsocketas a binding key (schema accepts onlyws/wss). - ProtocolConfigData is a discriminated union on
protocol(KafkaConfigData | WebSocketConfigData | MqttConfigData | GenericProtocolConfigData) — protocol-specific fields can only exist on their owning variant. - Security scheme types match AsyncAPI 3.1 exactly: valid:
apiKey,asymmetricEncryption,gssapi,http,httpApiKey,oauth2,openIdConnect,plain,scramSha256,scramSha512,symmetricEncryption,userPassword,X509. NOT valid:sasl(use the specific mechanism as the type),mutualTLS,external,oauthBearer.apiKeyvshttpApiKey(from the AsyncAPI 3.1 schema itself):apiKey={type, in}within∈"user"|"password"ONLY (SASL-style, NOname, additionalProperties false);httpApiKey={type, name, in}all REQUIRED,in∈"header"|"query"|"cookie". SASL schemes (plain/scramSha256/scramSha512/gssapi) accept{type, description?}only. Locked bytest/domain/security-schemes.test.ts. - WS binding version: ws channel bindings require
bindingVersion: "0.1.0"(NOT Kafka's"0.5.0") — each protocol has its own constant. Auto-injected when missing. - WSS binding key: the binding schema uses
wsfor BOTH ws and wss.normalizeBindingProtocol()mapswss→wsfor binding keys;server.protocolretains the distinction. @asyncapi/parserBun incompatibility: the parser fails under Bun (AJVnew Function()codegen in its Spectral ruleset). Parser-based tests run under vitest/Node; use manual$refresolution elsewhere (test/validation/semantic-ref-resolution.test.ts).@serviceis core TypeSpec (in theTypeSpecnamespace, notTypeSpec.AsyncAPI): requires#{}value-literal syntax (@service(#{title: "My API"})), NOT{}(compilerexpect-valueerror). Accepts onlytitle— use@apiVersionforinfo.version. Title is read vialistServices(program)as theinfo.titlefallback (emitter options take precedence).#deprecatedis a compiler directive, NOT a decorator: use#deprecated "message"(hash prefix); there is no@deprecatedin stdlib.isDeprecated(program, type)checks it;applyDeprecated()inconstraint-mapper.tsapplies it to properties and model/enum declarations.- Constraint decorators target specific types:
@patternonly onstring | ModelPropertyof string type;@minValue/@maxValueon numeric scalars;@minItems/@maxItemson arrays. The compiler validates targets at compile time. $refconstraint siblings: validation keywords (minimum,pattern, …) apply to inline schemas only — skipped on$ref(Draft-07 ignores siblings). Metadata (deprecated,description,title,examples,readOnly,writeOnly,default,@jsonSchemaExtensionkeywords) IS applied as$refsiblings, which AJV accepts.@summary/@example/@visibility/@encodedNameare stdlib (from@typespec/compiler, NOT declared inlib/main.tsp):@summary→titleviagetSummary();@example→examplesviagetExamples()+serializeValueAsJson();@visibility→readOnly/writeOnlyviagetVisibilityForClass();@encodedName("application/json", "wire")→ wire-formatpropertieskeys,requiredentries, anddiscriminatorvalues viaresolveEncodedName()(components schemas always use theapplication/jsonencoding; MIME-subtype resolution is insideresolveEncodedName).- Default values use core
=syntax, NOT@default:prop: Type = value(the compiler stores it onprop.defaultValue). Enum defaults require an enum member reference (MyEnum.value, not"value"); string-literal unions allow literal defaults. Applied as an annotation keyword (a valid$refsibling). @visibilitymapping is lossy (5 Lifecycle values → 2 booleans): Read-only →readOnly, Create/Update-only →writeOnly, both/neither → nothing; Delete and Query silently ignored. Requires enum members in current TypeSpec:@visibility(Lifecycle.Read), NOT@visibility("read").- Protocol count is 22; binding protocols are 19 (no separate https/wss/mqtt5 binding entries). Different concerns, not a split-brain.
- Model inheritance uses
allOf:model Derived extends BaseemitsallOf: [{ $ref }]— each model has only its ownproperties/required; multi-level chains produce linked refs. Inherited properties are NOT in the derived model'sproperties. @discriminatortargets Model only (compiler rejects on unions).getDiscriminator()returns{ propertyName }orundefined; the discriminator property is auto-added torequired.oneOfvsanyOffor unions: all-Model variants →oneOf(exclusive); mixed (string | int32) →anyOf; string-literal →enum. Named model variants must emit$ref— callrefForNamedType()beforeemitTypeReference(else empty{}objects).- Template instantiations (
Page<User>→PageUser): the asset-emitter's namespace walk NEVER declares instantiations; any$refto them dangles without explicitemitTypeReference. Naming mirrors the framework'sdeclarationName: base name + each argument's recursively-resolved name, first letter capitalized (PageUser,BoxInt32,PagePageUser); anonymous/literal/union args → inline. Anonymous models havename === ""(empty string, NOT undefined) — guard with falsiness.refOrFallback()short-circuits on hand-built refs ONLY for non-instantiations.operationReturnTypemust stay overridden (framework default returns none). Indexed instantiations (Record<K,V>) inline asadditionalPropertiesto avoid junk declarations. Unspeakable bases (extends BaseEvent<{...}>) compose via INLINEallOf. Collisions (explicitmodel PageUser+Page<User>) emitduplicate-schema-namewarning (last declaration wins). Locked bytest/compliance/template-instantiations.test.ts. - TypeEmitter overrides dispatch by exact method name (
modelInstantiation,unionDeclaration,enumDeclaration,scalarDeclaration,operationReturnType, …) fromtypeEmitterKeyin asset-emitter. Overrides namedunion,enum,scalar,namespace,modelProperty, etc. are DEAD CODE — verify the method name matches a dispatch key before adding overrides. - Examples are a pnpm workspace:
pnpm-workspace.yamllistsexamples/*; each example links the emitter viaworkspace:*(directory symlink). Any tool walkingexamples/MUST skipnode_modulesandtsp-output(symlink cycle → ELOOP).@operationSecurity(#{name})only attaches a$ref— the scheme must ALSO be declared via@securityor the ref dangles.@reply's second arg is a runtime expression ("$message.header#/replyTo"), not a channel address. - fast-check property suite: seed pinned via
FC_SEEDenv; reproduce failures withFC_SEED=<seed> pnpm exec vitest run test/property. Generator gotchas: filter lowercase identifiers against TypeSpec reserved words (reserved words cause parse-error throws, not assertion failures); pass{ nil: null }tofc.optionwhen the type says| null. - EFv1 containment guard: eslint
no-restricted-importsbans@typespec/asset-emitterfor all ofsrc/EXCEPT the three-file schema seam (schema-generator.ts,schema-emitter.ts,extract-value.ts). The 11 document builders consume only@typespec/compiler. Needing asset-emitter elsewhere means widening the seam — reconsider (ROADMAP.md "EFv1 containment" documents the v0.4.0 direct-AST exit plan). @extension("x-...", value): AsyncAPI spec extensions. Namespace → document root, Operation → operation object, Model → message object (keyed viaresolveMessageKey). Keys MUST startx-(elseinvalid-extension-keywarning); repeatable with merge (outermost same-key wins). AJV ignoresx-keys, so output still validates.@jsonSchemaExtension(key, value): arbitrary JSON Schema keywords on Model/ModelProperty/Union/Enum/Scalar. Impl insrc/extension-decorators.ts; applied inapplyMetadata()inline AND as$refsiblings. Merge: decorators execute bottom-up, so the OUTERMOST same-key wins. Key validation/^[A-Za-z_][A-Za-z0-9_.-]*$/u→invalid-json-schema-extension-keywarning.- Table-driven constraint mapping: adding a validation constraint = one
CONSTRAINT_TABLEentry, not a 4-line if-block. @encodeserialization:resolveEncode()wrapsgetEncode()(which only acceptsModelProperty | Scalar; returnsundefinedfor Model/Enum/Union). TheencodeAsgoes toserializeValueAsJson()for examples and defaults.@summaryon operations/channels populatessummary(viagetSummary(), separate from@doc→description). Channel summaries flow through thechannelSummariesmap (populated in operation-discovery, applied in channel-builder).@messagetitlepopulates BOTH the messagenameANDtitlefields (always set; defaults totarget.name).@tagsaccepts rich tag objects: arrays of strings AND/OR#{name, description?, externalDocs?}. Objects withoutname, empty strings, andname: ""all triggerinvalid-tags-config.storeTags()accepts pre-normalizedTag[]only.@parameterlocation validation:location(if present) must start with$message.and contain a#JSON-pointer separator, elseinvalid-parameter-locationwarning.- Traits extract rich fields via
extraPickercallbacks innamespace-decorators.ts:@operationTraitextractssecurity,tags,bindings(+ summary/description);@messageTraitextractsheaders,correlationId,summary,tags,bindings(+ name/description). enumis reserved in#{}value literals: the server builder maps configvalues→ outputenum— usevalues: #["a", "b"]in config to getenum: ["a", "b"]in output.- AsyncAPI 3.1 security format:
securityon operations/servers contains$refpointers tocomponents.securitySchemes(or inline schemes), NOT OpenAPI-style{ schemeName: [scopes] }maps.@operationSecurity(#{name: "jwt"})→{ $ref: "#/components/securitySchemes/jwt" }.SecurityRequirementisRef | Partial<SecurityScheme>. - Blockless namespace ordering:
namespace Foo;MUST appear before any other declarations, elseblockless-namespace-firsterrors. - Decorator execution is bottom-up: decorators closest to the declaration execute first.
@useChannelServer("a")above@useChannelServer("b")aboveop foo()stores[b, a]. Affects ordering of server refs, security requirements, and other multi-value state. - Build pipeline ordering:
buildServersmust run BEFOREattachChannelServerRefsindocument-builder.ts, elsectx.serversis empty andchannel.serversis silently omitted. storeMessageConfigstores the full config (includingschemaFormatandexamples), not just contentType/description/title.storeProtocolConfiglives insrc/store-protocol-config.ts(extracted to keepstate-writers.tsunder 400 lines); re-exported fromstate-writers.tsfor compatibility.- Property override narrowing: a derived model can narrow
eventType: "specific.literal"only if the base property isstring(not another literal); overriding one literal with another causesoverride-property-mismatch. decimalrenders astype: "string"+format: "decimal"— correct by design (JSON floats lose precision; string representation is the JSON Schema convention).- Generic model instantiation:
model Foo<T extends string>with spread...Bar<"value">resolves at compile time; derivedextends BaseEvent<{...}>produceallOfwith$refto the base schema (inherited properties NOT in the derived model). - Named unions: emit
oneOf; the union itself is declared incomponents.schemasand receives@doc/@summaryasdescription/title(public contract, locked bytest/compliance/polymorphism.test.ts). Properties typed by the union inline theoneOfdirectly rather than$ref-ing the union schema. - asset-emitter (EFv1) is terminal but slow to die: its own description says "to be replaced by the new emitter framework", but Microsoft's
openapi3/json-schemaemitters still use it. EFv2 (alloy-js/tree-sitter) targets source-code emitters, not data documents — never adopt it here. Our usage is confined to schema generation (3 files import it, 480 lines;schema-ref.tsmirrors its naming without importing);generateSchemas()is the sole seam. Plan: contain → monitor openapi3 migration → direct-AST rewrite in v0.4.0 (ROADMAP.md). - Competing emitter
tsp-asyncapi(npm, marvin-hsu): direct-AST, AsyncAPI 3.0-schema-validated. We lead on 3.1 validation, reusable components/traits, split-schemas output,@typespec/versioning, spec-generated binding validation, property-based tests, npm distribution with provenance. They lead on: docs site. Remaining competitive plan items live in TODO_LIST/ROADMAP. @typespec/compiler^1.15.0 — checkpackage.jsonfor the current pin.