-
Notifications
You must be signed in to change notification settings - Fork 0
@mmmnt emit ts
Emits TypeScript type definitions, aggregate interfaces, barrel files, and test scaffolds from the IR and TestSuiteTopology.
Package path: packages/emit-ts
Milestone: M3 (v0.1.0-m3)
@mmmnt/emit-ts owns all TypeScript output:
-
TypeScriptEmitter -- IR -->
.types.ts,.aggregate.ts,index.ts -
TestScaffoldEmitter -- IR + topology -->
.spec.tsfiles (flow + aggregate scaffolds) -
Policy --
EmitTypeScriptOnTopologyDerivedwires both emitters as aTopologyDerivedHook
The TestScaffoldEmitter is the sole owner of all .spec.ts production (invariant SG-01).
// Types
export type {
GenerationScope,
GenerationResult,
TypeScriptConvention,
TestScaffoldResult,
} from './types/index.js';
// Emitters
export { TypeScriptEmitter } from './services/typescript-emitter.js';
export type { EmitOptions, TypeScriptEmitterOutput } from './services/typescript-emitter.js';
export { TestScaffoldEmitter } from './services/test-scaffold-emitter.js';
export type { TestScaffoldEmitterOutput } from './services/test-scaffold-emitter.js';
// Policy
export { EmitTypeScriptOnTopologyDerived } from './policies/emit-typescript-on-topology-derived.js';
export type { EmitTypeScriptResult } from './policies/emit-typescript-on-topology-derived.js';Transforms IR contexts and aggregates into TypeScript interface files.
import { TypeScriptEmitter } from '@mmmnt/emit-ts';
const emitter = new TypeScriptEmitter();
const output = emitter.emit(ir, { scope: { level: 'system' } });
for (const [path, content] of output.files) {
console.log(`${path}:`);
console.log(content);
}
console.log(`Files written: ${output.result.filesWritten.length}`);| Rule | Description |
|---|---|
| TG-01 | Exact specification vocabulary in emitted types -- no renaming |
| TG-02 | Deterministic: same IR always produces identical output |
| TG-04 | All value object fields are readonly
|
| TG-05 |
dryRun support -- compute output without side effects |
src/
{context-name}/ # kebab-case context name
{aggregate-name}.types.ts # VO, command, event interfaces
{aggregate-name}.aggregate.ts # Aggregate root interface
index.ts # Re-exports all types + aggregates
For an aggregate with a command, event, and value object:
/**
* Types for the Order aggregate.
*/
export interface OrderItem {
readonly productId: string;
readonly quantity: number;
readonly unitPrice: Money;
}
export interface Address {
readonly street: string;
readonly city: string;
readonly state: string;
readonly postalCode: string;
readonly country: string;
}
export interface PlaceOrder {
readonly customerId: string;
readonly items: readonly OrderItem[];
readonly shippingAddress: Address;
}
export interface OrderPlaced {
readonly orderId: string;
readonly customerId: string;
readonly items: readonly OrderItem[];
readonly shippingAddress: Address;
readonly placedAt: Date;
}import type { PlaceOrder, OrderPlaced, OrderItem, Address } from './order.types.js';
/**
* Aggregate root for Order.
*/
export interface OrderAggregate {
readonly orderId: string;
placeOrder(command: PlaceOrder): OrderPlaced;
}The aggregate interface:
- Imports all referenced types from the
.types.tsfile (deduplicated and sorted) - Exposes the identity field as
readonly - Has one method per command, taking the command type and returning the emitted event type
- Method names use camelCase (first character lowercased from PascalCase command name)
export * from './order.types.js';
export * from './order.aggregate.js';Control what gets emitted with GenerationScope:
interface GenerationScope {
readonly level: 'system' | 'context' | 'aggregate';
readonly targets?: string[];
}
// Emit everything
emitter.emit(ir, { scope: { level: 'system' } });
// Emit only the Ordering context
emitter.emit(ir, { scope: { level: 'context', targets: ['Ordering'] } });
// Emit only the Order aggregate
emitter.emit(ir, { scope: { level: 'aggregate', targets: ['Order'] } });
// Dry run (no side effects, just compute)
emitter.emit(ir, { scope: { level: 'system' }, dryRun: true });| DSL Type | TypeScript Type |
|---|---|
string |
string |
number |
number |
boolean |
boolean |
UUID |
string |
DateTime |
Date |
Date |
Date |
Money |
Money |
int |
number |
float |
number |
decimal |
number |
Custom (e.g., OrderItem) |
PascalCase (e.g., OrderItem) |
Array (e.g., OrderItem[]) |
readonly OrderItem[] |
The emitter uses several safety functions to handle edge cases:
| Function | Purpose |
|---|---|
safePropertyKey(name) |
Wraps non-identifier property names in quotes |
safePathSegment(name) |
Sanitizes names for use in file paths |
sanitizeJsDoc(text) |
Escapes */ in JSDoc comments |
toPascalCase(name) |
Converts any string to PascalCase |
toKebabCase(name) |
Converts PascalCase to kebab-case for file names |
Generates Vitest test scaffold files from IR and topology.
import { TestScaffoldEmitter } from '@mmmnt/emit-ts';
const emitter = new TestScaffoldEmitter();
const output = emitter.emit(ir, topology);
for (const [path, content] of output.files) {
console.log(`${path}:`);
console.log(content);
}
console.log(`Spec files: ${output.result.specFilesWritten.length}`);
console.log(`Scenarios: ${output.result.scenariosGenerated}`);__tests__/
flows/
{flow-name}.spec.ts # Per-flow integration scaffolds
{context-name}/
{aggregate-name}.spec.ts # Per-aggregate unit scaffolds
import { describe, it, beforeEach, expect } from 'vitest';
describe('order-to-shipment', () => {
describe('Order placement', () => {
it('should verify OrderPlaced crosses from Ordering to Fulfillment', () => {
// Crossing: conn-ordering-fulfillment-orderplaced
// Assertion type: payload
// Expected fields:
// orderId: UUID (required)
// items: OrderItem[] (required)
// shippingAddress: Address (required)
// TODO: implement assertion
});
});
describe('Inventory check', () => {
// No crossings in this frame -- no assertions generated
});
describe('Inventory check outcome', () => {
it('should verify FulfillmentReady crosses from Fulfillment to Shipping', () => {
// Crossing: conn-fulfillment-shipping-fulfillmentready
// Assertion type: payload
// Expected fields:
// orderId: UUID (required)
// reservedItems: ReservedItem[] (required)
// TODO: implement assertion
});
});
});import { describe, it, expect } from 'vitest';
describe('Order', () => {
it('should handle PlaceOrder', () => {
// TODO: implement
});
it('should enforce invariant: Order must contain at least one item', () => {
// TODO: implement invariant violation test
});
it('should enforce invariant: Shipping address must be complete', () => {
// TODO: implement invariant violation test
});
});| Feature | Description |
|---|---|
| Flow scaffolds |
describe per flow, nested describe per frame, it per crossing assertion |
| Setup steps |
beforeEach blocks with setup comments when preconditions exist |
| Assertion details | Comments with crossing ID, assertion type, expected fields |
| Aggregate scaffolds |
it per command handler, it per invariant violation |
| String safety |
escapeStringLiteral for test descriptions, sanitizeComment for inline comments |
Every flow produces a .spec.ts file. This is the test scaffold counterpart of the Gherkin generator's GN-01 rule. Together, they enforce Design Principle #2.
This policy conforms to TopologyDerivedHook and invokes both emitters:
import { EmitTypeScriptOnTopologyDerived } from '@mmmnt/emit-ts';
const policy = new EmitTypeScriptOnTopologyDerived();
// As a TopologyDerivedHook callback (void return):
policy.handle(topology, ir);
// As a direct call (returns EmitTypeScriptResult):
const result = policy.execute(ir, topology);
console.log(`Types: ${result.typeScriptOutput.result.filesWritten.length} files`);
console.log(`Scaffolds: ${result.scaffoldOutput.result.specFilesWritten.length} files`);interface EmitTypeScriptResult {
readonly typeScriptOutput: TypeScriptEmitterOutput;
readonly scaffoldOutput: TestScaffoldEmitterOutput;
}The policy always emits at system scope (all contexts, all aggregates). For scoped emission, use TypeScriptEmitter directly.
- Pipeline -- how emission fits in the reactive pipeline
- @mmmnt/derive -- topology types consumed by emitters
- @mmmnt/generate -- the parallel generation path
- The Moment DSL -- the specification that drives emission