Skip to content

Add CML rich type system + faithful TypeScript codegen#1

Open
tarasmitran-stripe wants to merge 10 commits into
qooleot:mainfrom
tarasmitran-stripe:rich-types-and-ts-codegen
Open

Add CML rich type system + faithful TypeScript codegen#1
tarasmitran-stripe wants to merge 10 commits into
qooleot:mainfrom
tarasmitran-stripe:rich-types-and-ts-codegen

Conversation

@tarasmitran-stripe

Copy link
Copy Markdown

Extends lattice toward replacing Stripe billing's CML across three slices.

Slice 1 — rich TypeRef in the AST: adds the carried kinds (optional, map, generic, union, carrier) alongside the solved core. Establishes the "carried vs solved" split — carried types are represented for faithful codegen but dropped from derivation/solving (the derivation fold folds through optional transparently and to nothing for carried containers). Round-trips through the .lat serializer; all TypeRef consumers made total.

Slice 2 — grammar + parser: makes the rich types writable in .lat source. LatType is layered (union | loosest, then List/Optional/Map/generic/ ref/named); adds a builtin declaration for opaque carriers. The parser flattens unions, resolves bare names prim→value→owner→carrier→enum, and normalizes a head Optional<T> to the ? flag so it routes through the existing optional validation uniformly. validateModel recurses into every container so nested unresolved refs/enums/carriers are still reported.

Slice 3 — faithful full-model TypeScript emitter: new codegen CLI command (--spec/--session, --lang ts) that renders the entire DomainModel to TS types, handling every TypeRef kind and never throwing on rich types. Kept separate from the solver-scoped SQLite reference service. Owned collections embed the child interface; carriers get a top-of-file alias block; head-Optional → optional property; generics/unions rendered faithfully. Reuses the existing loaders, inheriting the parse and derived-name-collision gates.

Full suite green (118 files / 1122 tests) + typecheck clean. Docs updated (field-types.md, naming-conventions.md).

Committed-By-Agent: claude

tarasmitran-stripe and others added 10 commits July 17, 2026 15:02
Extends lattice toward replacing Stripe billing's CML across three slices.

Slice 1 — rich TypeRef in the AST: adds the carried kinds (optional, map,
generic, union, carrier) alongside the solved core. Establishes the
"carried vs solved" split — carried types are represented for faithful
codegen but dropped from derivation/solving (the derivation fold folds
through `optional` transparently and to nothing for carried containers).
Round-trips through the .lat serializer; all TypeRef consumers made total.

Slice 2 — grammar + parser: makes the rich types writable in .lat source.
`LatType` is layered (union `|` loosest, then List/Optional/Map/generic/
ref/named); adds a `builtin` declaration for opaque carriers. The parser
flattens unions, resolves bare names prim→value→owner→carrier→enum, and
normalizes a head `Optional<T>` to the `?` flag so it routes through the
existing optional validation uniformly. validateModel recurses into every
container so nested unresolved refs/enums/carriers are still reported.

Slice 3 — faithful full-model TypeScript emitter: new `codegen` CLI command
(`--spec`/`--session`, `--lang ts`) that renders the entire DomainModel to
TS types, handling every TypeRef kind and never throwing on rich types.
Kept separate from the solver-scoped SQLite reference service. Owned
collections embed the child interface; carriers get a top-of-file alias
block; head-Optional → optional property; generics/unions rendered
faithfully. Reuses the existing loaders, inheriting the parse and
derived-name-collision gates.

Full suite green (118 files / 1122 tests) + typecheck clean. Docs updated
(field-types.md, naming-conventions.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
… enums, Boolean, external refs)

Closes the five language gaps found in the CML gap analysis so the codegen
backends have a complete source model. New constructs adopt TypeScript-familiar
syntax; existing containers (List<T>/Map<K,V>/T?) already read as TS generics
and stay as-is.

- Boolean prim — solver-dropped (like Text/Id), codegen maps it to `boolean`.
- External-ref builtins — `builtin Name = "Opus::…::Amount"`; DomainModel.builtins
  is now BuiltinDef[] ({name, ref?}). A backend imports the ref instead of emitting.
- TS `type` construct — `type X = T` aliases (resolved/inlined at parse like CML,
  retained for round-trip, cycle-guarded) and `type X = { … }` free-form carried
  records (no solver restrictions, unlike `value`).
- Sum-type / payload enums — `enum X { monetary(Amount), none }`; codegen lowers to
  a TS discriminated union `{ kind; value }`.

Key simplification: a field referencing a record reuses the existing `carrier`
TypeRef (records and builtins are both named/carried/not-solver-encoded), so no new
TypeRef kind and none of the exhaustive TypeRef switches change. mapType resolution
is prim → alias → value → owner → carrier(builtin∪record) → enum.

Verified end-to-end via `codegen --spec … --out …`. Full suite green (118 files /
1141 tests) + typecheck clean; docs (field-types.md, enum.md, naming-conventions.md)
updated and their `lat` blocks parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
Foundations for doc-faithful / public-surface codegen ahead of the Ruby backend.

- Field-level `///` docs: FieldDecl gains a doc slot (shared rule, so value/entity/
  aggregate/event/`type`-record fields all get docs); Field.doc; parsed, round-trips
  through astToCode, and emitted as a JSDoc block by the faithful TS codegen.
- Visibility rides the existing tag system (no grammar/model change): internal is the
  default (CML has no @internal), `@public` opts a field into the public API surface,
  `@hookOnly` narrows exposure to hooks. TS codegen renders `@public` /
  `@public (hook-only)` in the field JSDoc.

Docs updated (field-types.md, tags.md, doc-comments.md). Full suite green
(1152 pass) + typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
Add a `module Name { … }` grouping construct inside a context so lattice can
represent CML's Module blocks (which become Ruby module namespaces). Foundation
for the Ruby backend's namespace nesting.

- Grammar: ContextItem splits into ModuleItem | TicksDecl | InvariantDecl |
  ModuleDecl; a module contains the namespaceable declarations (no nested modules;
  ticks/invariants stay top-level).
- Module is a grouping LABEL, not a name scope: names stay context-globally unique,
  refs resolve as before, and a module name may coincide with a declaration name
  (its own namespace). The model stays flat — each namespaceable def gains an
  optional `module?`.
- Parser flattens module-nested decls (tagging each with its module); module-name
  validation (duplicate-module, reserved/invalid, PascalCase warning) with CST
  positions. Serializer round-trips `module X { … }` blocks. TS codegen groups a
  module's types under a `// ── module: X ──` banner (flat; real nesting is the
  Ruby backend's job).

New docs/language/module.md; `module` reserved word added. Full suite green
(1164 pass) + typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
Empirical audit (fidelity harness) of how well lattice represents the real CML
corpus, before building the Ruby backend. Inventoried construct/type/annotation
usage across the corpus and classified each against lattice's current
capabilities, producing a prioritized gap list.

Findings: the data-model layer is fully covered (context/module/aggregate/entity/
value/enum incl. sum-type/type records+aliases/builtins+external refs/generics/
unions/field docs+visibility/Boolean). Remaining gaps, prioritized by real
prevalence: qualified intra-aggregate `Agg::Type` refs (pervasive), service tier
annotations (pervasive), multi-file `import` translation (pervasive), enum-level
docs (common), inline type-body docs (common), and the hooks/extension system
(rare — one block — but architecturally significant).

Abstract only: order-of-magnitude buckets, construct vocabulary, and the lattice
mapping — no real domain names or spec content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
Closes the small, corpus-confirmed CML gaps ahead of the Ruby backend.

- Enum-level `///` docs: EnumDecl gains a doc slot; EnumDef.doc round-trips and
  the TS codegen emits it as a JSDoc block. Removes the old enum-doc-unsupported
  friendly error (enum docs are now grammatical).
- Service tier annotation: `service X { tier = appPublic | appPrivate | domain … }`
  (uses `tier` since `type` is a keyword; carried metadata for codegen, validated
  with `unknown-service-tier`). Default appPublic when omitted.
- docs/cml-coverage.md: corrected the Agg::Type overcount (the audit double-counted
  Ruby FQN segments inside @external strings; real usage is ~dozens, concentrated,
  a namespacing convention with no genuine collisions) and reclassified it as a
  translation convention; added a Translation conventions section (Agg::Type hoisting,
  inline type-body docs, multi-file import).

Full suite green (1172 pass) + typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Committed-By-Agent: claude
Add src/ir/schema.ts: a versioned IR envelope (irVersion "1") in front of
DomainModel — the stable contract external Ruby/Java code generators consume.
toIR() deep-clones the model, normalizes the optional builtins/typeAliases/
records collections to [], and runs a compile-time exhaustive drift guard over
every TypeRef kind.

Add the `emit-ir` CLI command, mirroring `codegen`'s input handling (same
--spec/--session pair and parse/collision gates) but with no --lang, since the
IR is language-neutral by construction.

Tests: a completeness unit test over a hand-built model exercising all 10
TypeRef kinds and every Def collection; a CLI e2e test; and an abstract golden
fixture (test/fixtures/ir/abstract.{lat,ir.json}) as a drift guard. docs/ir.md
documents the contract and version policy. All fixtures abstract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Add docs/writing-a-consumer.md — the plugin-writer guide distilling the
four-role consumer architecture (IR Load → LayoutStrategy → Emitter →
Generator) that turns lattice's emitted IR into code in any target language
and folder/architecture convention. Covers the layout-vs-emission split, the
reuse-existing-templates principle, the deterministic-target faithfulness
caveat, the common gotchas (template-root coupling, formatter fidelity,
head-optional normalization, type-map completeness), a worked abstract
example, and three validating case studies (layered DDD, hexagonal, JVM).

Complements docs/ir.md (the contract). Add pointers from getting-started.md
and the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Field and param names previously could not be any .lat keyword, since the
grammar lexes them as keywords and validateModel's checkName rejects every
RESERVED_WORDS member. Common real field names (state, type, count, from, to)
were unusable.

Add a `FieldName` grammar rule permitting a small safe subset of keywords
(they never start a sibling construct in a field body and aren't FieldDecl
flags, so they lex unambiguously in `name :` position), used by FieldDecl and
ParamDecl. Carve the same subset (FIELD_NAME_KEYWORDS) out of checkName for
field/param kinds. `state` stays rejected on a machine-bearing aggregate
(where <Region>.state collides) via checkReservedField, but is now allowed on
records/values/entities/events/machineless aggregates.

Tests: keyword field names parse + round-trip; `state` gated by machine
presence; a non-carve-out reserved word (entity) still rejected as a field name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Cross-context type references use an explicit fully-qualified path, never
foreign-layout inference. Document the idiomatic form (an external builtin,
`builtin X = "FQN"` → a carrier emitted verbatim) in ir.md, and the consumer
rule in writing-a-consumer.md: a dotted `ref Context.Type` must be resolved via
an explicit Context.Type→FQN mapping and fail loud on an unmapped one — a silent
fallback (T.untyped, or the raw dotted string as a type) is a correctness bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Committed-By-Agent: claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant