GEN2: the seat generator emits each TypeScript contract as index.js with its index.d.ts

A seat's npm package holds index.js, index.d.ts and parts/<part>.js with .d.ts, with no TypeScript source and no
tsconfig.json, so a TypeScript module imports it with no build step and no tsc of the contract.

Builds: design 47 G §341

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 10:37:07 +01:00
parent 50677b204b
commit fe8c41928d
4 changed files with 167 additions and 89 deletions
@@ -5,6 +5,8 @@ import (
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
re "regexp"
"strings"
"testing"
@@ -79,7 +81,7 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
!(strings.Contains(src, "\t\"time\"\n") || strings.Contains(src, "import \"time\"")) {
t.Errorf("%s does not write a date-time as time.Time:\n%s", seat, src)
}
if ts := string(files["ts/seats/"+seat+"/src/parts/registry.ts"]); !strings.Contains(ts, `"pushed": string;`) || !strings.Contains(ts, `"at"?: string;`) {
if ts := string(files["ts/seats/"+seat+"/parts/registry.d.ts"]); !strings.Contains(ts, `"pushed": string;`) || !strings.Contains(ts, `"at"?: string;`) {
t.Errorf("%s's TypeScript does not write a date-time as a string:\n%s", seat, ts)
}
// A scalar typed ["T","null"] is a pointer in Go and `T | null` in TypeScript, so a declared zero survives with its type.
@@ -88,7 +90,7 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
t.Errorf("%s does not write a nullable scalar as a pointer (%s):\n%s", seat, w, src)
}
}
if ts := string(files["ts/seats/"+seat+"/src/parts/registry.ts"]); !strings.Contains(ts, `"floor"?: number | null;`) || !strings.Contains(ts, `"on"?: boolean | null;`) || !strings.Contains(ts, `"label"?: string | null;`) {
if ts := string(files["ts/seats/"+seat+"/parts/registry.d.ts"]); !strings.Contains(ts, `"floor"?: number | null;`) || !strings.Contains(ts, `"on"?: boolean | null;`) || !strings.Contains(ts, `"label"?: string | null;`) {
t.Errorf("%s's TypeScript does not write a nullable scalar as T | null:\n%s", seat, ts)
}
// A string's enum is a constant per value, so a caller names the definition's value (A3t: the removal reason).
@@ -139,8 +141,8 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
dir = d
}
}
if dir == "" || !strings.HasPrefix(p, dir+"src/") {
t.Errorf("%s is in no npm package's src", p)
if dir == "" || (p != dir+"index.d.ts" && !strings.HasPrefix(p, dir+"parts/")) {
t.Errorf("%s is in no npm package's files", p)
}
for _, line := range strings.Split(string(b), "\n") {
i := strings.Index(line, ` from "`)
@@ -172,8 +174,8 @@ func TestEachSeatDefinedWholeIsAGoModuleOfItsOwn(t *testing.T) {
}
}
for _, seat := range []string{"aregistry", "bregistry"} {
if !strings.Contains(string(files["ts/seats/"+seat+"/src/index.ts"]), "export type PublishedEvent = registry.PublishedEvent") ||
!strings.Contains(string(files["ts/seats/"+seat+"/src/parts/registry.ts"]), "export type PublishedEvent") {
if !strings.Contains(string(files["ts/seats/"+seat+"/index.d.ts"]), "export type PublishedEvent = registry.PublishedEvent") ||
!strings.Contains(string(files["ts/seats/"+seat+"/parts/registry.d.ts"]), "export type PublishedEvent") {
t.Errorf("the npm package of %s does not hold the part's types whole", seat)
}
}
@@ -232,3 +234,60 @@ func TestCallersAreTheFormsOfTheFormat(t *testing.T) {
}
}
}
// TestATypeScriptContractIsJavaScriptWithItsDeclarations is design 47 G §341's check: each seat's npm package holds the
// contract as index.js with its index.d.ts (and each part the same), no TypeScript source and no tsconfig, so a
// TypeScript module imports it with no build step and no tsc of the contract.
func TestATypeScriptContractIsJavaScriptWithItsDeclarations(t *testing.T) {
m, err := Load("testdata/contracts", "example.org/contracts/go", "@example/contracts")
if err != nil {
t.Fatal(err)
}
files, err := Generate(m)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"index.js", "index.d.ts", "parts/registry.js", "parts/registry.d.ts", "package.json", "package-lock.json"} {
if _, ok := files["ts/seats/aregistry/"+want]; !ok {
t.Errorf("the seat's npm package lacks %s", want)
}
}
for p, b := range files {
if !strings.HasPrefix(p, "ts/") {
continue
}
if (strings.HasSuffix(p, ".ts") && !strings.HasSuffix(p, ".d.ts")) || strings.HasSuffix(p, "tsconfig.json") {
t.Errorf("%s is TypeScript source or a compiler's setting: the contract needs no tsc", p)
}
if strings.HasSuffix(p, ".js") {
for _, typed := range []string{": string", "interface ", " as const", "export type ", "import type", "declare "} {
if strings.Contains(string(b), typed) {
t.Errorf("%s holds TypeScript syntax %q: it is JavaScript", p, typed)
}
}
if node, err := exec.LookPath("node"); err == nil {
path := filepath.Join(t.TempDir(), "check.mjs")
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatal(err)
}
if out, err := exec.Command(node, "--check", path).CombinedOutput(); err != nil {
t.Errorf("%s is no JavaScript module: %v\n%s", p, err, out)
}
}
}
}
var pkg struct {
Types string `json:"types"`
Exports map[string]map[string]string `json:"exports"`
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal(files["ts/seats/aregistry/package.json"], &pkg); err != nil {
t.Fatal(err)
}
if pkg.Types != "./index.d.ts" || pkg.Exports["."]["default"] != "./index.js" || pkg.Exports["."]["types"] != "./index.d.ts" || len(pkg.Scripts) != 0 {
t.Errorf("package.json says %+v", pkg)
}
if !strings.Contains(string(files["ts/seats/aregistry/index.d.ts"]), `import type * as registry from "./parts/registry.js";`) {
t.Error("the declarations do not import the part")
}
}
@@ -203,14 +203,14 @@ func goStatesImports(states []seatdef.State) string {
return out + ")\n\n"
}
// tsStates writes a seat's states for TypeScript: the local names, and for each key form its builder and parser (the
// value's type is declared with the seat's other types).
func tsStates(b *bytes.Buffer, states []seatdef.State) error {
// tsStates writes a seat's states for TypeScript: the local names, and for each key form its builder and parser, as
// JavaScript and as declarations (the value's type is declared with the seat's other types).
func tsStates(o *tsOut, states []seatdef.State) error {
names := make([]string, len(states))
for i, s := range states {
names[i] = s.Name
}
tsNames(b, "states", names)
tsNames(o, "states", names)
for _, s := range states {
for _, kf := range s.KeyForms {
ts, err := keyTokens(kf.Key)
@@ -218,11 +218,12 @@ func tsStates(b *bytes.Buffer, states []seatdef.State) error {
return fmt.Errorf("state %s: %w", s.Name, err)
}
fn := tsName(formIdent(s.Name, kf.Key, ts))
var args, expr, fields, got []string
var args, params, expr, fields, got []string
for _, t := range ts {
if t.part {
p := paramName(t.text)
args = append(args, p+": string")
params = append(params, p)
expr = append(expr, p)
fields = append(fields, p+": string")
got = append(got, p)
@@ -230,10 +231,17 @@ func tsStates(b *bytes.Buffer, states []seatdef.State) error {
expr = append(expr, strconv.Quote(t.text))
}
}
fmt.Fprintf(b, "/** The key of one value of the state %s under the form %s. */\nexport function %sKey(%s): string {\n return %s;\n}\n\n",
s.Name, strconv.Quote(kf.Key), fn, strings.Join(args, ", "), strings.Join(expr, " + "))
fmt.Fprintf(b, "/** The parts of a key of the form %s, or undefined for a key of another form. */\nexport function parse%sKey(_key: string): { %s } | undefined {\n let _rest = _key;\n",
strconv.Quote(kf.Key), ident(fn), strings.Join(fields, "; "))
doc := fmt.Sprintf("/** The key of one value of the state %s under the form %s. */\n", s.Name, strconv.Quote(kf.Key))
o.dts.WriteString(doc)
fmt.Fprintf(&o.dts, "export declare function %sKey(%s): string;\n\n", fn, strings.Join(args, ", "))
o.js.WriteString(doc)
fmt.Fprintf(&o.js, "export function %sKey(%s) {\n return %s;\n}\n\n", fn, strings.Join(params, ", "), strings.Join(expr, " + "))
doc = fmt.Sprintf("/** The parts of a key of the form %s, or undefined for a key of another form. */\n", strconv.Quote(kf.Key))
o.dts.WriteString(doc)
fmt.Fprintf(&o.dts, "export declare function parse%sKey(_key: string): { %s } | undefined;\n\n", ident(fn), strings.Join(fields, "; "))
b := &o.js
b.WriteString(doc)
fmt.Fprintf(b, "export function parse%sKey(_key) {\n let _rest = _key;\n", ident(fn))
for i := 0; i < len(ts); {
t := ts[i]
switch {
@@ -41,14 +41,20 @@ func TestAStateHasItsNamesKeyFormsAndStrictValues(t *testing.T) {
if strings.Contains(src, "Record") {
t.Errorf("the seat's package still names a record:\n%s", src)
}
ts := string(files["ts/seats/aregistry/src/index.ts"])
for _, want := range []string{"export const states = {", "export function asksByAskerIDKey(asker: string, id: string): string",
"export function parseAsksByAskerIDKey(_key: string)", "export interface AsksByAskerIDValue",
// a key form with a part named key: the parser's own parameter and locals are named apart from the parts
"export function parseAsksByNodeModuleKeyKey(_key: string): { node: string; module: string; key: string } | undefined",
"const key = _rest;"} {
if !strings.Contains(ts, want) {
t.Errorf("the seat's npm package lacks %q:\n%s", want, ts)
js := string(files["ts/seats/aregistry/index.js"])
dts := string(files["ts/seats/aregistry/index.d.ts"])
for file, wants := range map[string][]string{
js: {"export const states = {", "export function asksByAskerIDKey(asker, id) {", "export function parseAsksByAskerIDKey(_key) {",
"export function parseAsksByNodeModuleKeyKey(_key) {", "const key = _rest;"},
dts: {"export declare const states: {", "export declare function asksByAskerIDKey(asker: string, id: string): string;",
"export declare function parseAsksByAskerIDKey(_key: string)", "export interface AsksByAskerIDValue",
// a key form with a part named key: the parser's own parameter and locals are named apart from the parts
"export declare function parseAsksByNodeModuleKeyKey(_key: string): { node: string; module: string; key: string } | undefined;"},
} {
for _, want := range wants {
if !strings.Contains(file, want) {
t.Errorf("the seat's npm package lacks %q:\n%s", want, file)
}
}
}
@@ -19,31 +19,17 @@ func TSSeatDir(seat string) string { return "ts/seats/" + pkgName(seat) }
// TSSeatPackage is the npm package name of a seat defined whole, beside the seat set's package root.
func TSSeatPackage(root, seat string) string { return root + "-" + seat }
// tsConfig type-checks the package's sources and writes nothing: the package ships its TypeScript, which its importers
// compile or bundle, so it has no build script and needs no compiler of its own (F7: a generated package has no
// dependency, and its lock, which the build module requires of a package artifact, holds none).
const tsConfig = `{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
`
// tsPackage writes one npm package's package.json, its dependency-free package-lock.json and its tsconfig.json at dir.
// tsPackage writes one npm package's package.json and its dependency-free package-lock.json at dir. The package ships
// its contract as JavaScript with its declarations (design 47 G §341): its importers need no build step of its own and
// nothing runs tsc over it, so it has no tsconfig.json and no script (F7: a generated package has no dependency, and its
// lock, which the build module requires of a package artifact, holds none).
func tsPackage(out map[string][]byte, dir, name, description string) {
pkg := map[string]any{
"name": name, "version": "0.1.0", "type": "module",
"description": description,
"exports": map[string]any{".": "./src/index.ts"},
"types": "./src/index.ts",
"files": []any{"src"},
"exports": map[string]any{".": map[string]any{"types": "./index.d.ts", "default": "./index.js"}},
"types": "./index.d.ts",
"files": []any{"index.js", "index.d.ts", "parts"},
}
raw, _ := json.MarshalIndent(pkg, "", " ")
out[dir+"/package.json"] = append(raw, '\n')
@@ -53,13 +39,18 @@ func tsPackage(out map[string][]byte, dir, name, description string) {
}
raw, _ = json.MarshalIndent(lock, "", " ")
out[dir+"/package-lock.json"] = append(raw, '\n')
out[dir+"/tsconfig.json"] = []byte(tsConfig)
}
// tsFiles writes the npm side: one package per seat defined whole at TSSeatDir (ADR 0625 §1), holding the parts it includes whole and importing nothing outside it.
// tsOut is one contract file as its two forms: the JavaScript module a TypeScript module imports and the declarations
// that type it (design 47 G §341).
type tsOut struct{ js, dts bytes.Buffer }
// tsFiles writes the npm side: one package per seat defined whole at TSSeatDir (ADR 0625 §1), holding the parts it
// includes whole and importing nothing outside it. Each file is a JavaScript module with its declarations beside it
// (`index.js` and `index.d.ts`, `parts/<part>.js` and `parts/<part>.d.ts`; design 47 G §341).
func tsFiles(m *Module, out map[string][]byte) error {
partTypes := map[string]*typer{}
partFiles := map[string][]byte{}
partFiles := map[string]*tsOut{}
for _, name := range sortedKeys(m.Parts) {
p := m.Parts[name]
d := seatdef.Definition{Grain: seatdef.Whole, Verbs: p.Verbs, Events: p.Events, Defs: p.Defs}
@@ -68,13 +59,17 @@ func tsFiles(m *Module, out map[string][]byte) error {
return err
}
partTypes[name] = tp
var b bytes.Buffer
b.WriteString(tsHeader)
fmt.Fprintf(&b, "// The part %q several seats include whole: %s\n\n", p.Part, oneLine(p.Description))
tsNames(&b, "verbs", seatdef.VerbNames(p.Verbs))
tsNames(&b, "events", seatdef.MessageNames(p.Events))
tsTypes(&b, tp)
partFiles[name] = b.Bytes()
o := &tsOut{}
o.js.WriteString(tsHeader)
o.dts.WriteString(tsHeader)
note := fmt.Sprintf("// The part %q several seats include whole: %s\n\n", p.Part, oneLine(p.Description))
o.js.WriteString(note)
o.dts.WriteString(note)
tsNames(o, "verbs", seatdef.VerbNames(p.Verbs))
tsNames(o, "events", seatdef.MessageNames(p.Events))
o.js.WriteString("export {};\n")
tsTypes(&o.dts, tp)
partFiles[name] = o
}
for _, name := range m.Order {
d := m.Seats[name]
@@ -89,67 +84,74 @@ func tsFiles(m *Module, out map[string][]byte) error {
for _, t := range partTypes[inc].types {
fromPart[t.name] = inc
}
out[dir+"/src/parts/"+inc+".ts"] = partFiles[inc]
out[dir+"/parts/"+inc+".js"] = partFiles[inc].js.Bytes()
out[dir+"/parts/"+inc+".d.ts"] = partFiles[inc].dts.Bytes()
}
tp, err := declareAll(d, fromPart)
if err != nil {
return err
}
var b bytes.Buffer
b.WriteString(tsHeader)
fmt.Fprintf(&b, "// The contract of the seat %q, defined whole (seats/%s/seat.json): %s\n\n", d.Seat, d.Seat, oneLine(d.Description))
o := &tsOut{}
o.js.WriteString(tsHeader)
o.dts.WriteString(tsHeader)
note := fmt.Sprintf("// The contract of the seat %q, defined whole (seats/%s/seat.json): %s\n\n", d.Seat, d.Seat, oneLine(d.Description))
o.js.WriteString(note)
o.dts.WriteString(note)
for _, inc := range raw.Includes {
fmt.Fprintf(&b, "import type * as %s from \"./parts/%s.js\";\n\n", tsName(inc), inc)
fmt.Fprintf(&o.dts, "import type * as %s from \"./parts/%s.js\";\n\n", tsName(inc), inc)
}
fmt.Fprintf(&b, "export const seat = %q;\n\n", d.Seat)
tsNames(&b, "verbs", seatdef.VerbNames(d.Verbs))
tsNames(&b, "accepts", seatdef.MessageNames(d.Accepts))
tsNames(&b, "events", seatdef.MessageNames(d.Events))
fmt.Fprintf(&o.js, "export const seat = %q;\n\n", d.Seat)
fmt.Fprintf(&o.dts, "export declare const seat: %q;\n\n", d.Seat)
tsNames(o, "verbs", seatdef.VerbNames(d.Verbs))
tsNames(o, "accepts", seatdef.MessageNames(d.Accepts))
tsNames(o, "events", seatdef.MessageNames(d.Events))
if f := d.Family; f != nil {
fmt.Fprintf(&b, "export const family = { name: %q, maxAgeDays: %d", f.Name, f.MaxAgeDays)
b.WriteString(" } as const;\n\n")
fmt.Fprintf(&o.js, "export const family = { name: %q, maxAgeDays: %d };\n\n", f.Name, f.MaxAgeDays)
fmt.Fprintf(&o.dts, "export declare const family: { readonly name: %q; readonly maxAgeDays: %d };\n\n", f.Name, f.MaxAgeDays)
}
for _, e := range d.Events {
subj := seatdef.EventSubject(d.Seat, e.Name)
if e.ByCaller {
fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(e.Name), subj+".")
} else {
fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(e.Name), subj)
}
tsSubject(o, tsName(e.Name), seatdef.EventSubject(d.Seat, e.Name), e.ByCaller)
}
for _, a := range d.Accepts {
subj := seatdef.AcceptSubject(d.Seat, a.Name)
if a.ByCaller {
fmt.Fprintf(&b, "export function %sSubject(caller: string): string {\n return %q + caller;\n}\n\n", tsName(a.Name), subj+".")
} else {
fmt.Fprintf(&b, "export const %sSubject = %q;\n\n", tsName(a.Name), subj)
}
tsSubject(o, tsName(a.Name), seatdef.AcceptSubject(d.Seat, a.Name), a.ByCaller)
}
if len(d.HolderEvents) > 0 {
tsNames(&b, "holderEvents", holderNames(d.HolderEvents))
tsNames(o, "holderEvents", holderNames(d.HolderEvents))
for _, e := range d.HolderEvents {
before, after, _ := strings.Cut(e.Subject, "<holder>")
fmt.Fprintf(&b, "export function %sHolderSubject(holder: string): string {\n return %q + holder + %q;\n}\n\n",
tsName(e.Name), before, after)
fmt.Fprintf(&o.js, "export function %sHolderSubject(holder) {\n return %q + holder + %q;\n}\n\n", tsName(e.Name), before, after)
fmt.Fprintf(&o.dts, "export declare function %sHolderSubject(holder: string): string;\n\n", tsName(e.Name))
}
}
if len(d.States) > 0 {
if err := tsStates(&b, d.States); err != nil {
if err := tsStates(o, d.States); err != nil {
return err
}
}
for _, n := range sortedKeys(fromPart) {
fmt.Fprintf(&b, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n)
fmt.Fprintf(&o.dts, "export type %s = %s.%s;\n", n, tsName(fromPart[n]), n)
}
if len(fromPart) > 0 {
b.WriteString("\n")
o.dts.WriteString("\n")
}
tsTypes(&b, tp)
out[dir+"/src/index.ts"] = b.Bytes()
tsTypes(&o.dts, tp)
out[dir+"/index.js"] = o.js.Bytes()
out[dir+"/index.d.ts"] = o.dts.Bytes()
}
return nil
}
// tsSubject writes the constant, or for a subject ending in the caller's name the function, of one subject.
func tsSubject(o *tsOut, name, subj string, byCaller bool) {
if byCaller {
fmt.Fprintf(&o.js, "export function %sSubject(caller) {\n return %q + caller;\n}\n\n", name, subj+".")
fmt.Fprintf(&o.dts, "export declare function %sSubject(caller: string): string;\n\n", name)
return
}
fmt.Fprintf(&o.js, "export const %sSubject = %q;\n\n", name, subj)
fmt.Fprintf(&o.dts, "export declare const %sSubject: %q;\n\n", name, subj)
}
func holderNames(es []seatdef.HolderEvent) []string {
out := make([]string, len(es))
for i, e := range es {
@@ -158,15 +160,18 @@ func holderNames(es []seatdef.HolderEvent) []string {
return out
}
func tsNames(b *bytes.Buffer, what string, names []string) {
func tsNames(o *tsOut, what string, names []string) {
if len(names) == 0 {
return
}
fmt.Fprintf(b, "export const %s = {\n", what)
fmt.Fprintf(&o.js, "export const %s = {\n", what)
fmt.Fprintf(&o.dts, "export declare const %s: {\n", what)
for _, n := range names {
fmt.Fprintf(b, " %s: %q,\n", tsName(n), n)
fmt.Fprintf(&o.js, " %s: %q,\n", tsName(n), n)
fmt.Fprintf(&o.dts, " readonly %s: %q;\n", tsName(n), n)
}
b.WriteString("} as const;\n\n")
o.js.WriteString("};\n\n")
o.dts.WriteString("};\n\n")
}
func tsTypes(b *bytes.Buffer, tp *typer) {