@typra/emitter 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,51 @@
1
1
  # @typra/emitter
2
2
 
3
- Generic TypeSpec emitter for generating multi-runtime model, protocol, test,
4
- JSON AST, and documentation surfaces from TypeSpec.
3
+ `@typra/emitter` generates runtime model surfaces from TypeSpec. Use it when
4
+ you want TypeSpec to be the source of truth for shared model contracts and need
5
+ generated code, tests, JSON AST output, or documentation for one or more
6
+ runtimes.
7
+
8
+ Typra is emitter-only: it generates model/protocol surfaces, but it does not
9
+ ship runtime service implementations or product-specific contracts.
5
10
 
6
11
  ## Install
7
12
 
8
13
  ```powershell
9
- npm install --save-dev @typra/emitter
14
+ npm install --save-dev @typra/emitter @typespec/compiler
15
+ ```
16
+
17
+ ## Configure TypeSpec
18
+
19
+ Add the emitter to `tspconfig.yaml`:
20
+
21
+ ```yaml
22
+ emit:
23
+ - "@typra/emitter"
24
+
25
+ options:
26
+ "@typra/emitter":
27
+ emitter-output-dir: "{cwd}/generated"
28
+ root-object: "MyProject.ApiRoot"
29
+ root-namespace: "MyProject"
30
+ emit-targets:
31
+ - type: TypeScript
32
+ output-dir: "generated/typescript"
33
+ test-dir: "generated/typescript/tests"
34
+ import-path: "../index"
35
+ ```
36
+
37
+ Import the emitter library from your TypeSpec entry point:
38
+
39
+ ```typespec
40
+ import "@typra/emitter";
41
+
42
+ namespace MyProject;
43
+ ```
44
+
45
+ Compile with TypeSpec:
46
+
47
+ ```powershell
48
+ npx tsp compile ./path/to/main.tsp --config ./tspconfig.yaml
10
49
  ```
11
50
 
12
51
  ## CLI
@@ -17,14 +56,29 @@ The package includes the `typra-generate` command:
17
56
  npx typra-generate --help
18
57
  ```
19
58
 
20
- ## TypeSpec emitter
59
+ ## Supported output
60
+
61
+ Typra includes emitters for:
62
+
63
+ - TypeScript
64
+ - Python
65
+ - C#
66
+ - Go
67
+ - Rust
68
+ - Markdown documentation
69
+ - JSON AST
21
70
 
22
- Use `@typra/emitter` from TypeSpec configuration to generate runtime surfaces.
23
71
  The first Typra fixture slice validates TypeScript and JSON AST generation from
24
- synthetic TypeSpec shapes.
72
+ synthetic TypeSpec shapes. Additional fixture coverage will expand as the
73
+ extracted emitter hardens.
74
+
75
+ ## Generated files
76
+
77
+ Generated source files include Typra markers, and the emitter records a
78
+ generated-file manifest for each output root. Stale-file deletion is not enabled
79
+ yet, so Typra will not remove hand-authored runtime files.
25
80
 
26
- ## Publishing
81
+ ## Links
27
82
 
28
- This package is published from GitHub Actions using npm Trusted
29
- Publishing/OIDC. Do not use an `NPM_TOKEN` secret for the trusted-publishing
30
- path.
83
+ - Repository: <https://github.com/sethjuarez/typra>
84
+ - Package: <https://www.npmjs.com/package/@typra/emitter>
@@ -2,5 +2,6 @@ import { EmitContext } from "@typespec/compiler";
2
2
  import { TypraEmitterOptions } from "../lib.js";
3
3
  export declare function emitGeneratedFile(context: EmitContext<TypraEmitterOptions>, filePath: string, content: string, options?: {
4
4
  marker?: boolean;
5
+ outputRoot?: string;
5
6
  }): Promise<void>;
6
7
  export declare function emitGeneratedManifest(context: EmitContext<TypraEmitterOptions>): Promise<void>;
@@ -4,7 +4,7 @@ const generatedFilesByProgram = new WeakMap();
4
4
  export async function emitGeneratedFile(context, filePath, content, options = {}) {
5
5
  const marker = options.marker ?? shouldMark(filePath);
6
6
  const finalContent = marker ? addMarker(filePath, content) : content;
7
- recordGeneratedFile(context.program, filePath, marker);
7
+ recordGeneratedFile(context.program, filePath, marker, options.outputRoot);
8
8
  await emitFile(context.program, {
9
9
  path: filePath,
10
10
  content: finalContent,
@@ -24,13 +24,14 @@ export async function emitGeneratedManifest(context) {
24
24
  content: JSON.stringify(manifest, null, 2),
25
25
  });
26
26
  }
27
- function recordGeneratedFile(program, filePath, marker) {
27
+ function recordGeneratedFile(program, filePath, marker, outputRoot) {
28
28
  let entries = generatedFilesByProgram.get(program);
29
29
  if (!entries) {
30
30
  entries = new Map();
31
31
  generatedFilesByProgram.set(program, entries);
32
32
  }
33
33
  entries.set(normalizePath(filePath), {
34
+ outputRoot: normalizePath(outputRoot || dirname(filePath)),
34
35
  path: normalizePath(filePath),
35
36
  marker,
36
37
  });
@@ -52,10 +53,6 @@ function markerFor(filePath) {
52
53
  return "// <auto-generated by typra-emitter>";
53
54
  }
54
55
  function normalizePath(filePath) {
55
- const absolute = resolve(filePath);
56
- const base = dirname(absolute);
57
- return relative(base, absolute).startsWith("..")
58
- ? absolute.replace(/\\/g, "/")
59
- : filePath.replace(/\\/g, "/");
56
+ return relative(process.cwd(), resolve(filePath)).replace(/\\/g, "/");
60
57
  }
61
58
  //# sourceMappingURL=generated-file.js.map
@@ -10,4 +10,5 @@ export interface GeneratorOptions {
10
10
  * Matches against model name (e.g., "AgentManifest") or fully qualified name (e.g., "Prompty.AgentManifest")
11
11
  */
12
12
  export declare function filterNodes(nodes: TypeNode[], options?: GeneratorOptions): TypeNode[];
13
+ export declare function inferRootNamespace(rootObject: string): string;
13
14
  export declare function $onEmit(context: EmitContext<TypraEmitterOptions>): Promise<void>;
@@ -36,6 +36,28 @@ export function filterNodes(nodes, options) {
36
36
  return !omitModels.includes(name) && !omitModels.includes(fullName);
37
37
  });
38
38
  }
39
+ export function inferRootNamespace(rootObject) {
40
+ const lastDot = rootObject.lastIndexOf(".");
41
+ return lastDot > 0 ? rootObject.slice(0, lastDot) : "Typra";
42
+ }
43
+ function inferRootAlias(rootNamespace) {
44
+ return rootNamespace.split(".").filter(Boolean).at(-1) || rootNamespace || "Typra";
45
+ }
46
+ function isUninstantiatedTemplate(model) {
47
+ return !!(model.node &&
48
+ "templateParameters" in model.node &&
49
+ model.node.templateParameters.length > 0 &&
50
+ !model.templateMapper);
51
+ }
52
+ function collectNamespaceModels(namespace, models = []) {
53
+ for (const [, model] of namespace.models) {
54
+ models.push(model);
55
+ }
56
+ for (const [, childNamespace] of namespace.namespaces) {
57
+ collectNamespaceModels(childNamespace, models);
58
+ }
59
+ return models;
60
+ }
39
61
  // Registry of available code generators
40
62
  const generators = {
41
63
  markdown: generateMarkdown,
@@ -57,7 +79,9 @@ export async function $onEmit(context) {
57
79
  if (!m[0] || m[0].kind !== "Model") {
58
80
  throw new Error(`${rootObject} model not found or is not a model type.`);
59
81
  }
60
- const model = resolveModel(context.program, m[0], new Set(), options["root-namespace"] || "Typra", options["root-alias"] || "Typra");
82
+ const rootNamespace = options["root-namespace"] || inferRootNamespace(rootObject);
83
+ const rootAlias = options["root-alias"] || inferRootAlias(rootNamespace);
84
+ const model = resolveModel(context.program, m[0], new Set(), rootNamespace, rootAlias);
61
85
  if (options["root-alias"]) {
62
86
  model.typeName = {
63
87
  namespace: model.typeName.namespace,
@@ -67,7 +91,6 @@ export async function $onEmit(context) {
67
91
  // Discover additional models not reachable from the root.
68
92
  // If root-namespace is specified, resolve all models in that namespace
69
93
  // so new types are automatically emitted without manual additional-roots.
70
- const rootNamespace = options["root-namespace"] || "Typra";
71
94
  const additionalModels = [];
72
95
  const visited = new Set();
73
96
  // Collect names already in the main model tree to avoid duplicates
@@ -90,16 +113,15 @@ export async function $onEmit(context) {
90
113
  const nsRef = context.program.resolveTypeReference(rootNamespace);
91
114
  if (nsRef[0] && nsRef[0].kind === "Namespace") {
92
115
  const ns = nsRef[0];
93
- for (const [, nsModel] of ns.models) {
116
+ for (const nsModel of collectNamespaceModels(ns)) {
94
117
  const fullName = `${rootNamespace}.${nsModel.name}`;
95
118
  if (visited.has(fullName))
96
119
  continue;
97
120
  // Skip uninstantiated template declarations (e.g., Named<T>, Id<T>)
98
- if (nsModel.node && 'templateParameters' in nsModel.node &&
99
- nsModel.node.templateParameters.length > 0 && !nsModel.templateMapper) {
121
+ if (isUninstantiatedTemplate(nsModel)) {
100
122
  continue;
101
123
  }
102
- const additionalNode = resolveModel(context.program, nsModel, new Set(), rootNamespace, options["root-alias"] || "Typra");
124
+ const additionalNode = resolveModel(context.program, nsModel, new Set(), rootNamespace, rootAlias);
103
125
  additionalModels.push(additionalNode);
104
126
  visited.add(fullName);
105
127
  }
@@ -114,7 +136,7 @@ export async function $onEmit(context) {
114
136
  console.warn(`Warning: additional-root '${rootName}' not found or is not a model type. Skipping.`);
115
137
  continue;
116
138
  }
117
- const additionalNode = resolveModel(context.program, ref[0], new Set(), rootNamespace, options["root-alias"] || "Typra");
139
+ const additionalNode = resolveModel(context.program, ref[0], new Set(), rootNamespace, rootAlias);
118
140
  additionalModels.push(additionalNode);
119
141
  visited.add(rootName);
120
142
  }
@@ -461,7 +461,21 @@ function resolveImports(rootNode, types, registry) {
461
461
  * Handles formats like "Prompty", "Message[]", "Record<unknown>", "string", "unknown".
462
462
  */
463
463
  function extractMethodTypeRefs(method) {
464
- const SCALARS = new Set(["string", "int32", "float32", "float64", "boolean", "unknown"]);
464
+ const SCALARS = new Set([
465
+ "void",
466
+ "string",
467
+ "number",
468
+ "integer",
469
+ "int32",
470
+ "int64",
471
+ "float",
472
+ "float32",
473
+ "float64",
474
+ "numeric",
475
+ "boolean",
476
+ "unknown",
477
+ "any",
478
+ ]);
465
479
  const refs = [];
466
480
  const extract = (typeStr) => {
467
481
  // Strip nullable suffix and array suffix: "string?" → "string", "Message[]" → "Message"
@@ -72,7 +72,7 @@ export const generateCsharp = async (context, node, emitTarget, options) => {
72
72
  const csEnumName = field.enumName.charAt(0).toUpperCase() + field.enumName.slice(1);
73
73
  const grp = enumGroup.get(field.enumName) || "";
74
74
  const enumOutDir = grp ? `${emitTarget["output-dir"]}/${grp}` : emitTarget["output-dir"];
75
- await emitCsharpFile(context, nodes[0], enumCode, `${csEnumName}.cs`, enumOutDir);
75
+ await emitCsharpFile(context, nodes[0], enumCode, `${csEnumName}.cs`, enumOutDir, emitTarget["output-dir"]);
76
76
  }
77
77
  }
78
78
  }
@@ -81,10 +81,10 @@ export const generateCsharp = async (context, node, emitTarget, options) => {
81
81
  const classCode = emitCSharpClass(typeDecl, csharpNamespace, visitor, allTypeDecls, findTypeDecl);
82
82
  // Emit into group subfolder (C# uses namespaces, no re-export files needed)
83
83
  const outDir = n.group ? `${emitTarget["output-dir"]}/${n.group}` : emitTarget["output-dir"];
84
- await emitCsharpFile(context, n, classCode, `${n.typeName.name}.cs`, outDir);
84
+ await emitCsharpFile(context, n, classCode, `${n.typeName.name}.cs`, outDir, emitTarget["output-dir"]);
85
85
  if (emitTarget["test-dir"] && !n.isProtocol) {
86
86
  const testDir = n.group ? `${emitTarget["test-dir"]}/${n.group}` : emitTarget["test-dir"];
87
- await emitCsharpFile(context, n, renderTests(n, csharpNamespace), `${n.typeName.name}ConversionTests.cs`, testDir);
87
+ await emitCsharpFile(context, n, renderTests(n, csharpNamespace), `${n.typeName.name}ConversionTests.cs`, testDir, emitTarget["test-dir"]);
88
88
  }
89
89
  }
90
90
  // Format emitted files if format option is enabled (default: true)
@@ -250,13 +250,13 @@ const renderCsharpFactoryTestValue = (typeStr) => {
250
250
  default: return '"test"';
251
251
  }
252
252
  };
253
- const emitCsharpFile = async (context, type, python, filename, outputDir) => {
253
+ const emitCsharpFile = async (context, type, python, filename, outputDir, outputRoot) => {
254
254
  outputDir = outputDir || `${context.emitterOutputDir}/CSharp`;
255
255
  const typePath = type.typeName.namespace.split(".");
256
256
  // replace typename with file
257
257
  typePath.push(filename);
258
258
  const path = resolvePath(outputDir, filename);
259
- await emitGeneratedFile(context, path, python);
259
+ await emitGeneratedFile(context, path, python, { outputRoot: outputRoot || outputDir });
260
260
  };
261
261
  /**
262
262
  * Format C# files using dotnet format.
@@ -136,6 +136,8 @@ function protocolCSharpType(typeStr) {
136
136
  return "Dictionary<string, object?>";
137
137
  if (typeStr === "unknown" || typeStr === "any")
138
138
  return "object";
139
+ if (typeStr === "void")
140
+ return "void";
139
141
  // Handle nullable types (e.g., "string?")
140
142
  if (typeStr.endsWith("?")) {
141
143
  const inner = typeStr.slice(0, -1);
@@ -173,7 +175,9 @@ function emitCSharpInterface(type, namespace, lines) {
173
175
  // Synchronous method
174
176
  if (method.optional) {
175
177
  // Return type already includes nullability — provide default body
176
- lines.push(` ${ret} ${toPascalCase(method.name)}(${params}) => default!;`);
178
+ lines.push(ret === "void"
179
+ ? ` void ${toPascalCase(method.name)}(${params}) { }`
180
+ : ` ${ret} ${toPascalCase(method.name)}(${params}) => default!;`);
177
181
  }
178
182
  else {
179
183
  lines.push(` ${ret} ${toPascalCase(method.name)}(${params});`);
@@ -182,10 +186,17 @@ function emitCSharpInterface(type, namespace, lines) {
182
186
  else {
183
187
  // Async method
184
188
  if (method.optional) {
185
- lines.push(` Task<${ret}> ${toPascalCase(method.name)}Async(${params}) => Task.FromResult<${ret}>(default!);`);
189
+ if (ret === "void") {
190
+ lines.push(` Task ${toPascalCase(method.name)}Async(${params}) => Task.CompletedTask;`);
191
+ }
192
+ else {
193
+ lines.push(` Task<${ret}> ${toPascalCase(method.name)}Async(${params}) => Task.FromResult<${ret}>(default!);`);
194
+ }
186
195
  }
187
196
  else {
188
- lines.push(` Task<${ret}> ${toPascalCase(method.name)}Async(${params});`);
197
+ lines.push(ret === "void"
198
+ ? ` Task ${toPascalCase(method.name)}Async(${params});`
199
+ : ` Task<${ret}> ${toPascalCase(method.name)}Async(${params});`);
189
200
  }
190
201
  }
191
202
  }
@@ -10,3 +10,4 @@ export declare const goTypeMapper: Record<string, string>;
10
10
  * Main entry point for Go code generation.
11
11
  */
12
12
  export declare const generateGo: (context: EmitContext<TypraEmitterOptions>, node: TypeNode, emitTarget: EmitTarget, options?: GeneratorOptions) => Promise<void>;
13
+ export declare function goPackageNameFromNamespace(namespace: string): string;
@@ -40,8 +40,7 @@ export const generateGo = async (context, node, emitTarget, options) => {
40
40
  // Build the expression IR infrastructure
41
41
  const registry = TypeRegistry.fromTypeGraph(allTypes);
42
42
  const visitor = new GoExprVisitor(registry);
43
- // Determine package name from root node namespace (e.g., "Typra" -> "typra")
44
- const packageName = node.typeName.namespace.toLowerCase().replace(/\./g, '');
43
+ const packageName = emitTarget["package-name"] || goPackageNameFromNamespace(node.typeName.namespace);
45
44
  // Collect all polymorphic type names across all nodes
46
45
  const polymorphicTypeNames = new Set();
47
46
  for (const n of nodes) {
@@ -61,7 +60,7 @@ export const generateGo = async (context, node, emitTarget, options) => {
61
60
  // Go stays flat: pass group as a header comment only, no subfolder emission
62
61
  const fileContent = emitGoFileContent(fileDecl.types, packageName, visitor, polymorphicTypeNames, fileDecl.enums, n.group || "");
63
62
  const fileName = toSnakeCase(n.typeName.name) + '.go';
64
- await emitGoFile(context, fileName, fileContent, emitTarget["output-dir"]);
63
+ await emitGoFile(context, fileName, fileContent, emitTarget["output-dir"], emitTarget["output-dir"]);
65
64
  }
66
65
  // Emit test file for each type (skip protocols — they have no data to test)
67
66
  if (emitTarget["test-dir"] && !n.isProtocol) {
@@ -69,7 +68,7 @@ export const generateGo = async (context, node, emitTarget, options) => {
69
68
  const testContext = { ...buildTestContext(n, packageName), importPath };
70
69
  const testContent = emitGoTest(testContext);
71
70
  const testFileName = toSnakeCase(n.typeName.name) + '_test.go';
72
- await emitGoFile(context, testFileName, testContent, emitTarget["test-dir"]);
71
+ await emitGoFile(context, testFileName, testContent, emitTarget["test-dir"], emitTarget["test-dir"]);
73
72
  }
74
73
  }
75
74
  // Format emitted files if format option is enabled (default: true)
@@ -117,12 +116,15 @@ function formatGoFiles(outputDir, testDir) {
117
116
  function buildTestContext(node, packageName) {
118
117
  return buildBaseTestContext(node, packageName, goTestOptions);
119
118
  }
119
+ export function goPackageNameFromNamespace(namespace) {
120
+ return namespace.toLowerCase().replace(/\./g, "");
121
+ }
120
122
  /**
121
123
  * Write generated Go content to file.
122
124
  */
123
- async function emitGoFile(context, filename, content, outputDir) {
125
+ async function emitGoFile(context, filename, content, outputDir, outputRoot) {
124
126
  outputDir = outputDir || `${context.emitterOutputDir}/go`;
125
127
  const filePath = resolvePath(outputDir, filename);
126
- await emitGeneratedFile(context, filePath, content);
128
+ await emitGeneratedFile(context, filePath, content, { outputRoot: outputRoot || outputDir });
127
129
  }
128
130
  //# sourceMappingURL=driver.js.map
@@ -814,12 +814,15 @@ function emitMethodStubs(typeName, methods, lines) {
814
814
  if (method.description) {
815
815
  lines.push(`\t// ${toPascalCase(method.name)} — ${method.description}`);
816
816
  }
817
- lines.push(`\t${toPascalCase(method.name)}() ${goMethodReturnType(method.returns)}`);
817
+ const ret = goMethodReturnType(method.returns);
818
+ lines.push(`\t${toPascalCase(method.name)}()${ret ? ` ${ret}` : ""}`);
818
819
  }
819
820
  lines.push("}");
820
821
  lines.push("");
821
822
  }
822
823
  function goMethodReturnType(returns) {
824
+ if (returns === "void")
825
+ return "";
823
826
  return GO_TYPE_MAP[returns] || returns;
824
827
  }
825
828
  // ============================================================================
@@ -841,8 +844,20 @@ function protocolGoType(typeStr) {
841
844
  return "map[string]interface{}";
842
845
  if (typeStr === "unknown" || typeStr === "any")
843
846
  return "interface{}";
847
+ if (typeStr === "void")
848
+ return "";
844
849
  return GO_TYPE_MAP[typeStr] || typeStr;
845
850
  }
851
+ function goProtocolReturn(method) {
852
+ const ret = protocolGoType(method.returns);
853
+ if (method.sync && method.optional) {
854
+ return ret ? ` ${ret}` : "";
855
+ }
856
+ if (method.sync) {
857
+ return ret ? ` (${ret}, error)` : " error";
858
+ }
859
+ return ret ? ` (${ret}, error)` : " error";
860
+ }
846
861
  /**
847
862
  * Emit a Go interface for a protocol type.
848
863
  */
@@ -859,19 +874,7 @@ function emitProtocolInterface(type, lines) {
859
874
  const params = Object.entries(method.params)
860
875
  .map(([pName, pType]) => `${pName} ${protocolGoType(pType)}`)
861
876
  .join(", ");
862
- const ret = protocolGoType(method.returns);
863
- if (method.sync) {
864
- // Sync method — return type without error for optional
865
- if (method.optional) {
866
- lines.push(`\t${toPascalCase(method.name)}(${params}) ${ret}`);
867
- }
868
- else {
869
- lines.push(`\t${toPascalCase(method.name)}(${params}) (${ret}, error)`);
870
- }
871
- }
872
- else {
873
- lines.push(`\t${toPascalCase(method.name)}(${params}) (${ret}, error)`);
874
- }
877
+ lines.push(`\t${toPascalCase(method.name)}(${params})${goProtocolReturn(method)}`);
875
878
  }
876
879
  lines.push("}");
877
880
  lines.push("");
@@ -403,6 +403,6 @@ export const generateCoercions = (node) => {
403
403
  };
404
404
  const emitMarkdownFile = async (context, name, markdown, outputDir) => {
405
405
  const dir = outputDir || `${context.emitterOutputDir}/markdown`;
406
- await emitGeneratedFile(context, resolvePath(dir, `${name}.md`), markdown);
406
+ await emitGeneratedFile(context, resolvePath(dir, `${name}.md`), markdown, { outputRoot: dir });
407
407
  };
408
408
  //# sourceMappingURL=driver.js.map
@@ -100,14 +100,14 @@ export const generatePython = async (context, node, emitTarget, options) => {
100
100
  const fileDecl = lowerFile(n, registry, polymorphicTypeNames);
101
101
  const fileContent = emitPythonFileDecl(fileDecl, visitor, group);
102
102
  const outDir = group ? `${emitTarget["output-dir"]}/${group}` : emitTarget["output-dir"];
103
- await emitPythonFile(context, `_${n.typeName.name}.py`, fileContent, outDir);
103
+ await emitPythonFile(context, `_${n.typeName.name}.py`, fileContent, outDir, emitTarget["output-dir"]);
104
104
  }
105
105
  // Render test file for each type (skip protocols — they have no data to test)
106
106
  if (emitTarget["test-dir"] && !n.isProtocol) {
107
107
  const testDir = n.group ? `${emitTarget["test-dir"]}/${n.group}` : emitTarget["test-dir"];
108
108
  const testContext = buildTestContext(n, importPath);
109
109
  const testContent = emitPythonTest(testContext);
110
- await emitPythonFile(context, `test_${toSnakeCase(n.typeName.name)}.py`, testContent, testDir);
110
+ await emitPythonFile(context, `test_${toSnakeCase(n.typeName.name)}.py`, testContent, testDir, emitTarget["test-dir"]);
111
111
  }
112
112
  }
113
113
  // Emit group-level __init__.py for each group
@@ -115,7 +115,7 @@ export const generatePython = async (context, node, emitTarget, options) => {
115
115
  if (!group)
116
116
  continue; // Root-level types (if any) are covered by the root __init__.py
117
117
  const groupInitContent = emitPythonGroupInit(group, groupNodes);
118
- await emitPythonFile(context, '__init__.py', groupInitContent, `${emitTarget["output-dir"]}/${group}`);
118
+ await emitPythonFile(context, '__init__.py', groupInitContent, `${emitTarget["output-dir"]}/${group}`, emitTarget["output-dir"]);
119
119
  }
120
120
  // Format emitted files if format option is enabled (default: true)
121
121
  if (emitTarget.format !== false) {
@@ -364,9 +364,9 @@ function getUniqueImportTypes(node) {
364
364
  /**
365
365
  * Write generated Python content to file using TypeSpec's emitFile API.
366
366
  */
367
- async function emitPythonFile(context, filename, content, outputDir) {
367
+ async function emitPythonFile(context, filename, content, outputDir, outputRoot) {
368
368
  outputDir = outputDir || `${context.emitterOutputDir}/python`;
369
369
  const filePath = resolvePath(outputDir, filename);
370
- await emitGeneratedFile(context, filePath, content);
370
+ await emitGeneratedFile(context, filePath, content, { outputRoot: outputRoot || outputDir });
371
371
  }
372
372
  //# sourceMappingURL=driver.js.map
@@ -309,6 +309,8 @@ function protocolType(typeStr) {
309
309
  return "dict[str, Any]";
310
310
  if (typeStr === "unknown" || typeStr === "any")
311
311
  return "Any";
312
+ if (typeStr === "void")
313
+ return "None";
312
314
  // Scalar types
313
315
  const mapped = TYPE_MAP[typeStr];
314
316
  if (mapped)
@@ -11,3 +11,14 @@ export declare const rustTypeMapper: Record<string, string>;
11
11
  * Main entry point for Rust code generation.
12
12
  */
13
13
  export declare const generateRust: (context: EmitContext<TypraEmitterOptions>, node: TypeNode, emitTarget: EmitTarget, options?: GeneratorOptions) => Promise<void>;
14
+ /**
15
+ * Emit the root mod.rs file content (module declarations).
16
+ *
17
+ * @param rootModules - Module names emitted directly in the root (e.g. ["context"])
18
+ * @param groups - Group subfolder names (e.g. ["connection", "tools"])
19
+ */
20
+ export declare function emitRustLib(rootModules: string[], groups?: string[]): string;
21
+ /**
22
+ * Emit a per-group mod.rs file that declares and re-exports all modules in that group.
23
+ */
24
+ export declare function emitRustGroupMod(moduleNames: string[]): string;
@@ -103,7 +103,7 @@ export const generateRust = async (context, node, emitTarget, options) => {
103
103
  const fileContent = emitRustFileDecl(fileDecl, visitor, polymorphicTypeNames, childToParent);
104
104
  const fileName = toSnakeCase(n.typeName.name) + '.rs';
105
105
  const outDir = group ? `${emitTarget["output-dir"]}/${group}` : emitTarget["output-dir"];
106
- await emitRustFile(context, fileName, fileContent, outDir);
106
+ await emitRustFile(context, fileName, fileContent, outDir, emitTarget["output-dir"]);
107
107
  if (!groupModuleNames.has(group))
108
108
  groupModuleNames.set(group, []);
109
109
  groupModuleNames.get(group).push(toSnakeCase(n.typeName.name));
@@ -121,7 +121,7 @@ export const generateRust = async (context, node, emitTarget, options) => {
121
121
  const testFileName = toSnakeCase(n.typeName.name) + '_test.rs';
122
122
  const testGroup = n.group || "";
123
123
  const testDir = testGroup ? `${emitTarget["test-dir"]}/${testGroup}` : emitTarget["test-dir"];
124
- await emitRustFile(context, testFileName, testContent, testDir);
124
+ await emitRustFile(context, testFileName, testContent, testDir, emitTarget["test-dir"]);
125
125
  if (!testGroupModuleNames.has(testGroup))
126
126
  testGroupModuleNames.set(testGroup, []);
127
127
  testGroupModuleNames.get(testGroup).push(toSnakeCase(n.typeName.name) + '_test');
@@ -132,7 +132,7 @@ export const generateRust = async (context, node, emitTarget, options) => {
132
132
  if (!group)
133
133
  continue; // Root-level types handled in root mod.rs
134
134
  const groupModContent = emitRustGroupMod(modules);
135
- await emitRustFile(context, 'mod.rs', groupModContent, `${emitTarget["output-dir"]}/${group}`);
135
+ await emitRustFile(context, 'mod.rs', groupModContent, `${emitTarget["output-dir"]}/${group}`, emitTarget["output-dir"]);
136
136
  }
137
137
  // Render test group mod.rs files and test main.rs
138
138
  if (emitTarget["test-dir"]) {
@@ -142,7 +142,7 @@ export const generateRust = async (context, node, emitTarget, options) => {
142
142
  if (group) {
143
143
  const groupModContent = '// Code generated by Typra emitter; DO NOT EDIT.\n\n#![allow(unused_imports, dead_code, non_camel_case_types, unused_variables, clippy::all)]\n\n'
144
144
  + testMods.map(m => `mod ${m};`).join('\n') + '\n';
145
- await emitRustFile(context, 'mod.rs', groupModContent, `${emitTarget["test-dir"]}/${group}`);
145
+ await emitRustFile(context, 'mod.rs', groupModContent, `${emitTarget["test-dir"]}/${group}`, emitTarget["test-dir"]);
146
146
  testGroups.push(group);
147
147
  }
148
148
  }
@@ -209,10 +209,10 @@ function buildTestContext(node) {
209
209
  /**
210
210
  * Write generated Rust content to file.
211
211
  */
212
- async function emitRustFile(context, filename, content, outputDir) {
212
+ async function emitRustFile(context, filename, content, outputDir, outputRoot) {
213
213
  outputDir = outputDir || `${context.emitterOutputDir}/rust`;
214
214
  const filePath = resolvePath(outputDir, filename);
215
- await emitGeneratedFile(context, filePath, `${content.trimEnd()}\n`);
215
+ await emitGeneratedFile(context, filePath, `${content.trimEnd()}\n`, { outputRoot: outputRoot || outputDir });
216
216
  }
217
217
  /**
218
218
  * Emit the context.rs file content (LoadContext/SaveContext structs).
@@ -398,7 +398,7 @@ impl SaveContext {
398
398
  * @param rootModules - Module names emitted directly in the root (e.g. ["context"])
399
399
  * @param groups - Group subfolder names (e.g. ["connection", "tools"])
400
400
  */
401
- function emitRustLib(rootModules, groups = []) {
401
+ export function emitRustLib(rootModules, groups = []) {
402
402
  let out = '// Code generated by Typra emitter; DO NOT EDIT.\n\n#![allow(unused_imports, dead_code, non_camel_case_types, unused_variables, clippy::all)]\n';
403
403
  for (const module of rootModules) {
404
404
  out += `\npub mod ${module};\npub use ${module}::*;\n`;
@@ -411,7 +411,7 @@ function emitRustLib(rootModules, groups = []) {
411
411
  /**
412
412
  * Emit a per-group mod.rs file that declares and re-exports all modules in that group.
413
413
  */
414
- function emitRustGroupMod(moduleNames) {
414
+ export function emitRustGroupMod(moduleNames) {
415
415
  let out = '// Code generated by Typra emitter; DO NOT EDIT.\n\n#![allow(unused_imports, dead_code, non_camel_case_types, unused_variables, clippy::all)]\n';
416
416
  for (const module of moduleNames) {
417
417
  out += `\npub mod ${module};\npub use ${module}::*;\n`;
@@ -995,6 +995,8 @@ function emitMethodTrait(type, lines) {
995
995
  lines.push("}");
996
996
  }
997
997
  function methodReturnType(method) {
998
+ if (method.returns === "void")
999
+ return "()";
998
1000
  if (method.returns === "string")
999
1001
  return "String";
1000
1002
  return RUST_TYPE_MAP[method.returns] || method.returns;
@@ -1018,6 +1020,8 @@ function protocolRustType(typeStr) {
1018
1020
  return "serde_json::Value";
1019
1021
  if (typeStr === "unknown" || typeStr === "any")
1020
1022
  return "serde_json::Value";
1023
+ if (typeStr === "void")
1024
+ return "()";
1021
1025
  if (typeStr === "string")
1022
1026
  return "String";
1023
1027
  return RUST_TYPE_MAP[typeStr] || typeStr;
@@ -1046,7 +1050,7 @@ function emitProtocolTrait(type, lines) {
1046
1050
  if (method.optional) {
1047
1051
  // Return type already includes nullability from ? suffix — don't double-wrap
1048
1052
  lines.push(` fn ${toSnakeCase(method.name)}(&self, ${params}) -> ${ret} {`);
1049
- lines.push(" None");
1053
+ lines.push(ret === "()" ? " ()" : " None");
1050
1054
  lines.push(" }");
1051
1055
  }
1052
1056
  else {
@@ -66,14 +66,14 @@ export const generateTypeScript = async (context, node, emitTarget, options) =>
66
66
  const fileDecl = lowerFile(n, registry, polymorphicTypeNames);
67
67
  const code = emitTypeScriptFileDecl(fileDecl, visitor, tsNamespace, group);
68
68
  const outDir = group ? `${emitTarget["output-dir"]}/${group}` : emitTarget["output-dir"];
69
- await emitTypeScriptFile(context, `${toKebabCase(n.typeName.name)}.ts`, code, outDir);
69
+ await emitTypeScriptFile(context, `${toKebabCase(n.typeName.name)}.ts`, code, outDir, emitTarget["output-dir"]);
70
70
  }
71
71
  // Emit group index.ts files
72
72
  for (const [group, groupNodes] of groupMap) {
73
73
  if (!group)
74
74
  continue;
75
75
  const groupIndexCode = emitTypeScriptGroupIndex(group, groupNodes);
76
- await emitTypeScriptFile(context, "index.ts", groupIndexCode, `${emitTarget["output-dir"]}/${group}`);
76
+ await emitTypeScriptFile(context, "index.ts", groupIndexCode, `${emitTarget["output-dir"]}/${group}`, emitTarget["output-dir"]);
77
77
  }
78
78
  // Emit test files for all types (skip protocols — they have no data to test)
79
79
  if (emitTarget["test-dir"]) {
@@ -91,7 +91,7 @@ export const generateTypeScript = async (context, node, emitTarget, options) =>
91
91
  importPath: testImportPath,
92
92
  namespace: tsNamespace,
93
93
  });
94
- await emitTypeScriptFile(context, `${toKebabCase(n.typeName.name)}.test.ts`, testCode, testDir);
94
+ await emitTypeScriptFile(context, `${toKebabCase(n.typeName.name)}.test.ts`, testCode, testDir, emitTarget["test-dir"]);
95
95
  }
96
96
  }
97
97
  // Emit root index.ts file — re-exports from group sub-indexes
@@ -201,9 +201,9 @@ function buildTestContext(node) {
201
201
  /**
202
202
  * Write generated TypeScript content to file.
203
203
  */
204
- async function emitTypeScriptFile(context, filename, content, outputDir) {
204
+ async function emitTypeScriptFile(context, filename, content, outputDir, outputRoot) {
205
205
  outputDir = outputDir || `${context.emitterOutputDir}/typescript`;
206
206
  const filePath = resolvePath(outputDir, filename);
207
- await emitGeneratedFile(context, filePath, content);
207
+ await emitGeneratedFile(context, filePath, content, { outputRoot: outputRoot || outputDir });
208
208
  }
209
209
  //# sourceMappingURL=driver.js.map
@@ -85,6 +85,8 @@ function returnType(typeStr) {
85
85
  }
86
86
  if (typeStr === "Record<unknown>")
87
87
  return "Record<string, unknown>";
88
+ if (typeStr === "void")
89
+ return "void";
88
90
  return TYPE_MAP[typeStr] || typeStr;
89
91
  }
90
92
  // ============================================================================
package/dist/src/lib.d.ts CHANGED
@@ -8,6 +8,7 @@ export interface EmitTarget {
8
8
  "format"?: boolean;
9
9
  "namespace"?: string;
10
10
  "import-path"?: string;
11
+ "package-name"?: string;
11
12
  }
12
13
  export interface TypraEmitterOptions {
13
14
  "root-object": string;
package/dist/src/lib.js CHANGED
@@ -39,6 +39,11 @@ const TypraEmitterOptionsSchema = {
39
39
  type: "string",
40
40
  nullable: true,
41
41
  description: "Import path for generated code in tests. Defaults vary by language."
42
+ },
43
+ "package-name": {
44
+ type: "string",
45
+ nullable: true,
46
+ description: "Language package/module name override. Currently used by Go; defaults to the emitted root namespace."
42
47
  }
43
48
  },
44
49
  required: ["type"]
@@ -1,4 +1,6 @@
1
1
  import "@typra/emitter";
2
+ import "./model/events/session.tsp";
3
+ import "./model/pipeline/harness.tsp";
2
4
 
3
5
  namespace Typra.Fixtures;
4
6
 
@@ -0,0 +1,13 @@
1
+ import "@typra/emitter";
2
+
3
+ namespace Typra.Fixtures;
4
+
5
+ model Checkpoint {
6
+ id: string;
7
+ label?: string;
8
+ }
9
+
10
+ model SessionSummary {
11
+ sessionId: string;
12
+ latestCheckpoint?: Checkpoint;
13
+ }
@@ -0,0 +1,11 @@
1
+ import "@typra/emitter";
2
+
3
+ namespace Typra.Fixtures;
4
+
5
+ @@protocol(EventSink);
6
+ @@method(EventSink, "emit", "void", "Emit an event.", #{ event: "unknown" }, false, true);
7
+ model EventSink {}
8
+
9
+ @@protocol(CheckpointStore);
10
+ @@method(CheckpointStore, "save", "void", "Save a checkpoint.", #{ checkpoint: "Checkpoint" }, false, false);
11
+ model CheckpointStore {}
@@ -4,10 +4,25 @@ options:
4
4
  "@typra/emitter":
5
5
  emitter-output-dir: "{cwd}/generated/fixtures"
6
6
  root-object: "Typra.Fixtures.FixtureRoot"
7
- root-namespace: "Typra.Fixtures"
8
7
  emit-targets:
9
8
  - type: TypeScript
10
9
  output-dir: "generated/fixtures/typescript"
11
10
  test-dir: "generated/fixtures/typescript/tests"
12
11
  import-path: "../index"
13
12
  format: false
13
+ - type: Python
14
+ output-dir: "generated/fixtures/python"
15
+ test-dir: "generated/fixtures/python/tests"
16
+ import-path: "fixtures"
17
+ format: false
18
+ - type: Go
19
+ output-dir: "generated/fixtures/go"
20
+ test-dir: "generated/fixtures/go/tests"
21
+ import-path: "fixtures"
22
+ package-name: "fixtures"
23
+ format: false
24
+ - type: Rust
25
+ output-dir: "generated/fixtures/rust"
26
+ test-dir: "generated/fixtures/rust/tests"
27
+ import-path: "fixtures::model"
28
+ format: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typra/emitter",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Generic TypeSpec emitter for generating multi-runtime model surfaces",
5
5
  "license": "MIT",
6
6
  "repository": {