diff --git a/modules/mesh-build-go/buildstep/seatgen/layout_test.go b/modules/mesh-build-go/buildstep/seatgen/layout_test.go index 77837dcee..4f097012c 100644 --- a/modules/mesh-build-go/buildstep/seatgen/layout_test.go +++ b/modules/mesh-build-go/buildstep/seatgen/layout_test.go @@ -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") + } +} diff --git a/modules/mesh-build-go/buildstep/seatgen/states.go b/modules/mesh-build-go/buildstep/seatgen/states.go index 71bb54dd0..d1c40c315 100644 --- a/modules/mesh-build-go/buildstep/seatgen/states.go +++ b/modules/mesh-build-go/buildstep/seatgen/states.go @@ -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 { diff --git a/modules/mesh-build-go/buildstep/seatgen/states_test.go b/modules/mesh-build-go/buildstep/seatgen/states_test.go index be565d7fd..01c6b3d72 100644 --- a/modules/mesh-build-go/buildstep/seatgen/states_test.go +++ b/modules/mesh-build-go/buildstep/seatgen/states_test.go @@ -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) + } } } diff --git a/modules/mesh-build-go/buildstep/seatgen/tsgen.go b/modules/mesh-build-go/buildstep/seatgen/tsgen.go index 17e58563a..a4476b22c 100644 --- a/modules/mesh-build-go/buildstep/seatgen/tsgen.go +++ b/modules/mesh-build-go/buildstep/seatgen/tsgen.go @@ -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/.js` and `parts/.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, "") - 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) {