Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ jobs:
CGO_ENABLED=0 GOOS=$os go vet ./...
done

# The RPC and /events contract is api/, recorded in
# api/testdata/contract.golden. Its own step so a PR that moves it without
# the golden fails by name and shows what moved, rather than as one red
# test among the suite's.
- name: API contract
run: |
if ! go test -run '^(TestContractMatchesGolden|TestEveryRequestIsOneObject|TestWireShapesAreWhatMarshalJSONWrites)$' ./api; then
go test -run '^TestContractMatchesGolden$' ./api -update > /dev/null
echo "::error::the api contract moved without api/testdata/contract.golden: run go test ./api -update and commit the diff below"
git diff -- api/testdata/contract.golden
exit 1
fi

- name: Backend tests
run: bash .github/scripts/go-test-summary.sh linux

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ jobs:
for _ in $(seq 1 60); do sleep 1; grep -q 'native window attached' "$config/lich.log" && break; done
grep -q 'native window attached' "$config/lich.log" || fail "lich did not open its first window"
token=$(sed -n 's/.*"token":"\([^"]*\)".*/\1/p' "$config/runtime.json")
curl -fsS -X POST -H 'Content-Type: application/json' -d '["window.closeAction","","background"]' \
curl -fsS -X POST -H 'Content-Type: application/json' -d '[{"key":"window.closeAction","projectId":"","value":"background"}]' \
"http://127.0.0.1:$LICH_LISTEN_PORT/rpc/store.SetSetting?token=$token" > /dev/null || fail "storing Keep running failed"
"$app/lich.exe" quit || fail "the first lich quit exited $?"
for _ in $(seq 1 10); do tasklist | grep -q -e '^lich.exe' -e lich-native.exe || break; sleep 1; done
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ lives in the code, `docs/` and `CHANGELOG.md` — never restate any of it here.
into a session, goes through the catalogs and lands in **every** locale in the same PR (`lich-i18n` skill).
English is the source; tests stay English. Error text, `CHANGELOG.md`, MCP tool descriptions and CLI help
stay English by design; the What's new dialog reads a release's translations from `changelog/<locale>.md`.
- Service shapes are hand-owned in `native/lichclient`: touch a Go struct's JSON tags and its mirror there moves
in the same change. There is no codegen.
- An RPC method is declared once in `api` and exposed with `rpc.Handle` in `internal/routes`: the backend,
`lich` and `native/backend` compile against that one declaration, and `api/testdata/contract.golden` records
its shape (`go test ./api -update` after a change meant to move it). Its options are one struct, never
positional arguments. An `/events` event is declared there too, emitted with `events.Send` and read with
`backend.Decode`. There is no codegen.
- A fact more than one place shows (git status, a pull request, plan quota, a session's profile or status) has
one store per window, fed by one reader, and every screen reads that store. A change to the fact reaches the
store as an event on `/events`, emitted by the backend once the change is true (after the respawn, not before),
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ Then:
```bash
for os in linux darwin windows; do GOOS=$os go build ./... && GOOS=$os go vet ./...; done
```
- **Touched a Go struct's JSON tags?** `native/lichclient` mirrors them by
hand. There is no codegen, so it moves in the same change.
- **Touched an RPC method or an `/events` payload?** Every method and event is
declared in `api`, shared by the backend, `lich` and the window; run `go test
./api -update` and review the diff of `api/testdata/contract.golden`. There is
no codegen.

Commit messages follow [Conventional Commits](https://www.conventionalcommits.org)
(`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`, `ci:`).
Expand Down
34 changes: 34 additions & 0 deletions api/agentplugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package api

var (
AgentPluginStatus = define[None, []PluginStatus]("agentplugin.Status")
AgentPluginInstall = define[PluginProviderOptions, None]("agentplugin.Install")
AgentPluginUpdate = define[PluginProviderOptions, None]("agentplugin.Update")
)

// PluginProviderOptions names the provider whose lich plugin is installed or
// updated.
type PluginProviderOptions struct {
Provider string `json:"provider"`
}

// PluginStatus is one provider's plugin state, reported to the window.
type PluginStatus struct {
Provider string `json:"provider"`
Name string `json:"name"`
// Available is whether the provider's CLI is on PATH at all. A provider the
// machine does not have is still listed — with nothing to install — so the
// UI can say why it is not on offer.
Available bool `json:"available"`
Installed bool `json:"installed"`
InstalledVersion string `json:"installedVersion"`
// LatestVersion is the newest release this lich is compatible with, not the
// newest the plugin has published: it is the one an install or update writes.
LatestVersion string `json:"latestVersion"`
UpdateAvailable bool `json:"updateAvailable"`
// Compatible is false when the installed release is outside the range this
// lich speaks (PluginVersionFloor, PluginVersionCeiling). Its reports still
// land where they parse; UpdateAvailable then offers LatestVersion, a
// downgrade included.
Compatible bool `json:"compatible"`
}
62 changes: 62 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Package api is the contract between the lich backend and its clients: the
// window (native/backend), `lich` and `lich mcp`. Each RPC is declared here
// once, as a Method whose type parameters are what it takes and what it
// returns, so a client and the backend that disagree on either do not compile.
//
// On the wire a call is a JSON array holding its one options object, or empty
// for a method that takes nothing: the envelope internal/rpc has always read,
// which keeps a `lich` of another release talking to this backend. Fields are
// matched by name, never by position, so an option one side does not know is
// dropped and one it does not send is its zero value.
//
// The package is pure Go with no dependency beyond the standard library: the
// window's module imports it, and must not reach internal/.
// testdata/contract.golden records every method's shape; a change to it is a
// change to the contract, visible in review.
package api

import (
"fmt"
"reflect"
)

// Method names one RPC. Req is its options object (None for a method that
// takes nothing) and Resp its result (None for one that only fails or not).
type Method[Req, Resp any] struct {
Name string
}

// None is the request of a method that takes nothing and the result of one
// that returns only an error.
type None struct{}

// Args is req in the wire envelope.
func (m Method[Req, Resp]) Args(req Req) []any {
if _, empty := any(req).(None); empty {
return []any{}
}
return []any{req}
}

// Declared is one Method with its type parameters as values, for code that
// walks the whole contract (the golden test).
type Declared struct {
Name string
Request reflect.Type
Response reflect.Type
}

var declared = map[string]Declared{}

// Methods returns every Method this package declares.
func Methods() map[string]Declared {
return declared
}

func define[Req, Resp any](name string) Method[Req, Resp] {
if _, dup := declared[name]; dup {
panic(fmt.Sprintf("api: %s declared twice", name))
}
declared[name] = Declared{Name: name, Request: reflect.TypeFor[Req](), Response: reflect.TypeFor[Resp]()}
return Method[Req, Resp]{Name: name}
}
241 changes: 241 additions & 0 deletions api/api_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package api

import (
"encoding/json"
"flag"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"testing"
)

var update = flag.Bool("update", false, "rewrite testdata/contract.golden from the declared methods")

const goldenPath = "testdata/contract.golden"

var marshalerType = reflect.TypeFor[json.Marshaler]()

// wireShapes are the types that encode themselves, each with the struct its
// MarshalJSON writes, which the golden shows in its place.
var wireShapes = map[reflect.Type]reflect.Type{
reflect.TypeFor[RevertResult](): reflect.TypeFor[revertWire](),
}

// TestContractMatchesGolden makes every change to a method's name, options or
// result, and to an event's name or payload, show up as a diff of
// testdata/contract.golden. A renamed JSON field
// compiles on both sides and still breaks a client of another release; the
// golden is where review sees it. Run `go test ./api -update` after a change
// meant to move the contract.
func TestContractMatchesGolden(t *testing.T) {
got := render(Methods(), Events())
if *update {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatal(err)
}
// The repository carries no .gitattributes, so a Windows checkout may hand
// back CRLF for the golden committed with LF: the checkout's spelling, not
// drift.
if got != strings.ReplaceAll(string(want), "\r\n", "\n") {
t.Error("the contract moved; if that is meant, run `go test ./api -update` and review the diff of " + goldenPath)
}
}

// TestEveryRequestIsOneObject keeps positional arguments out of the contract:
// a request that is a bare string or bool is matched by position, and breaks
// the release its meaning changes.
func TestEveryRequestIsOneObject(t *testing.T) {
for name, m := range Methods() {
if m.Request.Kind() != reflect.Struct {
t.Errorf("%s takes %s; a request is one struct, or None", name, m.Request)
}
}
}

// TestWireShapesAreWhatMarshalJSONWrites keeps wireShapes honest: the golden
// shows the wire struct, so it has to be the keys MarshalJSON writes.
func TestWireShapesAreWhatMarshalJSONWrites(t *testing.T) {
for typ, wire := range wireShapes {
data, err := json.Marshal(reflect.New(typ).Elem().Interface())
if err != nil {
t.Fatal(err)
}
var written map[string]json.RawMessage
if err := json.Unmarshal(data, &written); err != nil {
t.Fatal(err)
}
var keys []string
for field := range wire.Fields() {
if name, _, wired := jsonName(field); wired {
keys = append(keys, name)
}
}
if got := slices.Sorted(maps.Keys(written)); !slices.Equal(got, slices.Sorted(slices.Values(keys))) {
t.Errorf("%s writes %v, its wire shape %s names %v", typ, got, wire, keys)
}
}
}

func TestArgsWrapsOneObjectAndLeavesNoneEmpty(t *testing.T) {
if got := ThemesSelect.Args(SelectThemeOptions{ID: "nord"}); !reflect.DeepEqual(got, []any{SelectThemeOptions{ID: "nord"}}) {
t.Errorf("Args(options) = %v, want the options alone", got)
}
if got := ThemesList.Args(None{}); len(got) != 0 || got == nil {
t.Errorf("Args(None) = %#v, want an empty, non-nil list (it encodes as [])", got)
}
}

func TestDefineRefusesADuplicateName(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("a second declaration of themes.List did not panic")
}
}()
define[None, None]("themes.List")
}

func TestDefineEventRefusesADuplicateName(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("a second declaration of session-status did not panic")
}
}()
defineEvent[None]("session-status")
}

func TestEventFamilyNamesOneEventPerSession(t *testing.T) {
if got := TerminalExitEvents.Of("s1").Name; got != "terminal:exit:s1" {
t.Errorf("Of(s1) = %q", got)
}
if id, ok := TerminalExitEvents.ID("terminal:exit:s1"); !ok || id != "s1" {
t.Errorf("ID = %q, %v; want s1", id, ok)
}
if _, ok := TerminalExitEvents.ID("terminal:data:s1"); ok {
t.Error("another family's event read as this one's")
}
}

// render prints each method as `name(Request) Response` and each event as
// `event name Payload`, sorted, followed by every named type they reach, each
// once.
func render(methods map[string]Declared, events map[string]DeclaredEvent) string {
r := &renderer{seen: map[reflect.Type]bool{}}
var b strings.Builder
for _, name := range slices.Sorted(maps.Keys(methods)) {
m := methods[name]
fmt.Fprintf(&b, "%s(%s) %s\n", name, r.ref(m.Request), r.ref(m.Response))
}
b.WriteString("\n")
for _, name := range slices.Sorted(maps.Keys(events)) {
fmt.Fprintf(&b, "event %s %s\n", name, r.ref(events[name].Payload))
}
for len(r.queue) > 0 {
next := r.queue[0]
r.queue = r.queue[1:]
b.WriteString("\n")
b.WriteString(r.define(next))
}
return b.String()
}

type renderer struct {
seen map[reflect.Type]bool
queue []reflect.Type
}

// ref names t as a field or a signature shows it, queueing the named types
// of this package for their own definition.
func (r *renderer) ref(t reflect.Type) string {
if t.Name() != "" && t.PkgPath() == reflect.TypeFor[None]().PkgPath() {
if !r.seen[t] {
r.seen[t] = true
r.queue = append(r.queue, t)
}
return t.Name()
}
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
return "base64"
}
switch t.Kind() {
case reflect.Pointer:
return "*" + r.ref(t.Elem())
case reflect.Slice:
return "[]" + r.ref(t.Elem())
case reflect.Map:
return "map[" + r.ref(t.Key()) + "]" + r.ref(t.Elem())
}
return t.String()
}

func (r *renderer) define(t reflect.Type) string {
if t.Implements(marshalerType) {
wire, ok := wireShapes[t]
if !ok {
panic(fmt.Sprintf("api: %s encodes itself; add the struct its MarshalJSON writes to wireShapes", t.Name()))
}
return r.defineStruct(t.Name()+" (MarshalJSON)", wire)
}
if t.Kind() != reflect.Struct {
return fmt.Sprintf("%s = %s\n", t.Name(), r.ref(reflectUnderlying(t)))
}
return r.defineStruct(t.Name(), t)
}

func (r *renderer) defineStruct(name string, t reflect.Type) string {
var b strings.Builder
fmt.Fprintf(&b, "%s {\n", name)
for field := range t.Fields() {
name, omitempty, wired := jsonName(field)
if !wired {
continue
}
if field.Anonymous && field.Tag.Get("json") == "" {
fmt.Fprintf(&b, "\t...%s\n", r.ref(field.Type))
continue
}
mark := ""
if omitempty {
mark = ",omitempty"
}
fmt.Fprintf(&b, "\t%s%s %s\n", name, mark, r.ref(field.Type))
}
b.WriteString("}\n")
return b.String()
}

// reflectUnderlying is t without its name: Syntax's map type.
func reflectUnderlying(t reflect.Type) reflect.Type {
switch t.Kind() {
case reflect.Map:
return reflect.MapOf(t.Key(), t.Elem())
case reflect.Slice:
return reflect.SliceOf(t.Elem())
}
panic(fmt.Sprintf("api: the golden does not render named %s types (%s)", t.Kind(), t.Name()))
}

// jsonName is the key encoding/json writes field under, and whether it
// writes the field at all.
func jsonName(field reflect.StructField) (name string, omitempty, wired bool) {
if !field.IsExported() {
return "", false, false
}
tag := field.Tag.Get("json")
if tag == "-" {
return "", false, false
}
name, opts, _ := strings.Cut(tag, ",")
if name == "" {
name = field.Name
}
return name, strings.Contains(opts, "omitempty"), true
}
Loading
Loading