From ba276c6a0ea169799e3c90269127cde9e7bea58a Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Thu, 11 Jun 2026 20:09:42 -0400 Subject: [PATCH 1/3] vista: driver-backed engine + `m vista` (VSL T0.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add VistaEngine — m-cli's engine that reaches a live VistA via the neutral engine-driver contract instead of an in-process yottadb/iris invocation. m-cli stays vendor-neutral (driver-contract §1/§11: subprocess + JSON envelope, "zero changes to m-cli" to add an engine); the driver owns the wire (m-ydb local/docker/SSH, m-iris Atelier REST). - internal/driver: a Client that runs `m- --output json` and parses the clikit envelope (reads stdout, falls back to stderr for the current Fail-path envelope); engineError surfaced as data, not a Go error. Locate() resolves the driver binary per §4 ($M__BIN → next to m → sibling dist/ → PATH). Injectable CmdRunner/LocateDeps seams; table tests. - internal/engine/vista.go: VistaEngine implements engine.Engine (RunXCmd→eval, RunRoutine→run, EnsureLoaded→load) + Probe() = lifecycle status (the portable reachability + version gate; the cross-engine W $ZV equivalent, since IRIS exec doesn't capture device output). Additive — in-process engines untouched (D3 delete stays conformance-gated). - main.go + vista_cmd.go: `m vista status|exec --engine ydb|iris --transport …`. - go.mod: pin m-driver-sdk v0.2.0. Live-proven: `m vista status --engine iris` → m-iris driver → live m-test-iris returned the real IRIS 2026.1 version banner. YDB path is code-identical + unit-tested; live (FOIA vehu over docker/SSH) is the user's to run. Gates: gofmt, go vet, go test -race ./... all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 1 + go.sum | 2 + internal/driver/client.go | 183 +++++++++++++++++++++++++++++++++ internal/driver/client_test.go | 125 ++++++++++++++++++++++ internal/driver/locate.go | 64 ++++++++++++ internal/driver/locate_test.go | 56 ++++++++++ internal/engine/vista.go | 91 ++++++++++++++++ internal/engine/vista_test.go | 79 ++++++++++++++ main.go | 1 + vista_cmd.go | 97 +++++++++++++++++ 10 files changed, 699 insertions(+) create mode 100644 internal/driver/client.go create mode 100644 internal/driver/client_test.go create mode 100644 internal/driver/locate.go create mode 100644 internal/driver/locate_test.go create mode 100644 internal/engine/vista.go create mode 100644 internal/engine/vista_test.go create mode 100644 vista_cmd.go diff --git a/go.mod b/go.mod index 0babe23..7043189 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/vista-cloud-dev/m-driver-sdk v0.2.0 github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 diff --git a/go.sum b/go.sum index 05cf702..cc7f160 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/vista-cloud-dev/m-driver-sdk v0.2.0 h1:YcwmP0os9kG/TIpGAvEktnGiWs7oR0whToKV4su49P0= +github.com/vista-cloud-dev/m-driver-sdk v0.2.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db h1:e0x+stGSyYA/W2Zn13Y0E85B4vD9Y46rgV79XKzuODE= github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db/go.mod h1:XAG984cMiUq5ST14W3YvlZP9iMIURPWond2TjvEsDIY= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= diff --git a/internal/driver/client.go b/internal/driver/client.go new file mode 100644 index 0000000..57c84d5 --- /dev/null +++ b/internal/driver/client.go @@ -0,0 +1,183 @@ +// Package driver is m-cli's client for the m engine-driver contract: it reaches +// a live VistA (or any engine) by invoking an m- driver binary over the +// contract's subprocess + JSON-envelope seam (driver-contract.md §2) — +// `m- [args] --transport [conn] --output json` — and +// parsing the one JSON envelope it writes. m-cli speaks only the neutral +// contract (§1, §11: "zero changes to m-cli" to add an engine); all vendor +// detail lives behind the binary, which the engine package's VistaEngine wraps. +// +// The drivers reach the engine themselves: m-ydb via local/docker/SSH, m-iris +// via Atelier REST — so this client never knows the wire, only the contract. +package driver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// CmdRunner runs the driver binary and returns its stdout, stderr, and process +// exit. Only a launch failure (binary missing, etc.) is a Go error; a non-zero +// engine/driver exit is a result. Injected so the client is testable without a +// real driver binary. +type CmdRunner func(ctx context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) + +// Client drives one m- binary for one transport+connection. +type Client struct { + Bin string // path to the m- binary + Engine string // "ydb" | "iris" (for error messages) + Transport string // local | docker | remote + ConnArgs []string // extra connection flags (e.g. --container, --base-url); usually empty (driver reads M__* env) + run CmdRunner +} + +// NewClient builds a driver client. A nil run uses the real subprocess runner. +func NewClient(bin, engine, transport string, connArgs []string, run CmdRunner) *Client { + if run == nil { + run = ExecRunner + } + return &Client{Bin: bin, Engine: engine, Transport: transport, ConnArgs: connArgs, run: run} +} + +// envelope is the wire shape the driver writes (driver-contract.md §2). engineError +// is a sibling of data, set on a §7 fault. +type envelope struct { + SchemaVersion string `json:"schemaVersion"` + Command string `json:"command"` + OK bool `json:"ok"` + Exit int `json:"exit"` + Data json.RawMessage `json:"data"` + Error *envError `json:"error,omitempty"` + EngineError *mdriver.EngineError `json:"engineError,omitempty"` +} + +type envError struct { + Code string `json:"code"` + Exit int `json:"exit"` + Message string `json:"message"` + Hint string `json:"hint"` +} + +// call invokes a verb and returns the parsed envelope, decoding data into out +// (when non-nil). It reads the envelope from stdout, falling back to stderr — +// the drivers' success envelopes go to stdout (cc.Result), but a Fail-path +// envelope currently lands on stderr (a clikit deviation from §2's "one JSON +// envelope to stdout"; tolerated here, flagged for a clikit/conformance-v2 fix). +func (c *Client) call(ctx context.Context, out any, args ...string) (*envelope, error) { + full := append([]string{}, args...) + full = append(full, "--transport", c.Transport) + full = append(full, c.ConnArgs...) + full = append(full, "--output", "json") + + stdout, stderr, _, err := c.run(ctx, c.Bin, full) + if err != nil { + return nil, fmt.Errorf("driver %s: launch %s: %w", c.Engine, c.Bin, err) + } + + env, perr := parseEnvelope(stdout) + if perr != nil { + // Fall back to stderr (Fail-path envelope) before giving up. + if env2, perr2 := parseEnvelope(stderr); perr2 == nil { + env = env2 + } else { + return nil, fmt.Errorf("driver %s: no JSON envelope on stdout or stderr: %w", c.Engine, perr) + } + } + if out != nil && len(env.Data) > 0 { + if jerr := json.Unmarshal(env.Data, out); jerr != nil { + return nil, fmt.Errorf("driver %s: decode %s data: %w", c.Engine, env.Command, jerr) + } + } + return env, nil +} + +func parseEnvelope(b []byte) (*envelope, error) { + if len(bytes.TrimSpace(b)) == 0 { + return nil, errors.New("empty output") + } + var env envelope + if err := json.Unmarshal(b, &env); err != nil { + return nil, err + } + if env.SchemaVersion == "" { + return nil, errors.New("not a clikit envelope (no schemaVersion)") + } + return &env, nil +} + +// Status runs `lifecycle status` — the reachability + identity probe (running, +// healthy, version). This is the engine-neutral way to prove a VistA is live and +// learn its version banner (the portable replacement for `W $ZV`, which only +// captures device output on YottaDB). +func (c *Client) Status(ctx context.Context) (mdriver.Status, error) { + var s mdriver.Status + _, err := c.call(ctx, &s, "lifecycle", "status") + return s, err +} + +// Caps fetches the driver's capability document. +func (c *Client) Caps(ctx context.Context) (mdriver.Caps, error) { + var caps mdriver.Caps + _, err := c.call(ctx, &caps, "meta", "caps") + return caps, err +} + +// ExecEval evaluates a single M command. A §7 engine fault is returned in +// ExecResult.EngineError (data), not as a Go error — only a transport/launch +// failure is a Go error. +func (c *Client) ExecEval(ctx context.Context, command string) (mdriver.ExecResult, error) { + return c.exec(ctx, "eval", []string{command}) +} + +// ExecRun runs an entryref (args become $ZCMDLINE / the formallist). +func (c *Client) ExecRun(ctx context.Context, entryref string, args []string) (mdriver.ExecResult, error) { + return c.exec(ctx, "run", append([]string{entryref}, args...)) +} + +func (c *Client) exec(ctx context.Context, verb string, rest []string) (mdriver.ExecResult, error) { + var r mdriver.ExecResult + env, err := c.call(ctx, &r, append([]string{"exec", verb}, rest...)...) + if err != nil { + return mdriver.ExecResult{}, err + } + if env.EngineError != nil { + r.EngineError = env.EngineError + } + return r, nil +} + +// Load stages + compiles routine source (exec load). +func (c *Client) Load(ctx context.Context, paths []string) (mdriver.LoadResult, error) { + var r mdriver.LoadResult + env, err := c.call(ctx, &r, append([]string{"exec", "load"}, paths...)...) + if err != nil { + return mdriver.LoadResult{}, err + } + if env.EngineError != nil { + r.EngineError = env.EngineError + } + return r, nil +} + +// ExecRunner is the production CmdRunner: it runs the driver binary, capturing +// stdout and stderr separately. A non-zero exit is a result; only a launch +// failure is an error. +func ExecRunner(ctx context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) { + cmd := exec.CommandContext(ctx, name, args...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + runErr := cmd.Run() + var ee *exec.ExitError + if errors.As(runErr, &ee) { + return out.Bytes(), errb.Bytes(), ee.ExitCode(), nil + } + if runErr != nil { + return out.Bytes(), errb.Bytes(), 0, runErr + } + return out.Bytes(), errb.Bytes(), 0, nil +} diff --git a/internal/driver/client_test.go b/internal/driver/client_test.go new file mode 100644 index 0000000..2a56520 --- /dev/null +++ b/internal/driver/client_test.go @@ -0,0 +1,125 @@ +package driver + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// fakeCmd records the last invocation and returns canned streams. +type fakeCmd struct { + name string + args []string + stdout []byte + stderr []byte + exit int + err error +} + +func (f *fakeCmd) run(_ context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) { + f.name, f.args = name, args + return f.stdout, f.stderr, f.exit, f.err +} + +// envBytes builds a clikit-style JSON envelope (the wire shape the client parses). +func envBytes(t *testing.T, command string, ok bool, exit int, data any, eng *mdriver.EngineError) []byte { + t.Helper() + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal data: %v", err) + } + env := map[string]any{ + "schemaVersion": "1.0", "command": command, "ok": ok, "exit": exit, + "data": json.RawMessage(raw), + } + if eng != nil { + env["engineError"] = eng + } + b, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal env: %v", err) + } + return b +} + +func TestClient_Status_ArgvAndParse(t *testing.T) { + st := mdriver.Status{Transport: "remote", Running: true, Healthy: true, Version: "IRIS for UNIX 2026.1"} + f := &fakeCmd{stdout: envBytes(t, "lifecycle status", true, 0, st, nil)} + c := NewClient("/bin/m-iris", "iris", "remote", []string{"--base-url", "X"}, f.run) + + got, err := c.Status(context.Background()) + if err != nil { + t.Fatalf("Status: %v", err) + } + if !got.Healthy || got.Version != st.Version { + t.Errorf("status = %+v, want healthy + version", got) + } + if f.name != "/bin/m-iris" { + t.Errorf("bin = %q", f.name) + } + want := []string{"lifecycle", "status", "--transport", "remote", "--base-url", "X", "--output", "json"} + if !reflect.DeepEqual(f.args, want) { + t.Errorf("argv = %v\nwant %v", f.args, want) + } +} + +func TestClient_ExecEval_StdoutSuccess(t *testing.T) { + r := mdriver.ExecResult{Stdout: "HI", Status: 0} + f := &fakeCmd{stdout: envBytes(t, "exec eval", true, 0, r, nil)} + c := NewClient("m-ydb", "ydb", "local", nil, f.run) + + got, err := c.ExecEval(context.Background(), `w "HI"`) + if err != nil { + t.Fatalf("ExecEval: %v", err) + } + if got.Stdout != "HI" { + t.Errorf("stdout = %q", got.Stdout) + } + if got.EngineError != nil { + t.Errorf("unexpected engineError: %+v", got.EngineError) + } + want := []string{"exec", "eval", `w "HI"`, "--transport", "local", "--output", "json"} + if !reflect.DeepEqual(f.args, want) { + t.Errorf("argv = %v\nwant %v", f.args, want) + } +} + +// On an engine fault the driver emits the envelope (with engineError) to STDERR +// and exits non-zero; the client must read stderr and surface engineError as +// DATA (not a Go error) so callers can render a RED-with-cause result. +func TestClient_ExecEval_EngineErrorFromStderr(t *testing.T) { + eng := &mdriver.EngineError{Mnemonic: "", Text: "no such routine", Line: 12, Routine: "ZZZ"} + f := &fakeCmd{ + stderr: envBytes(t, "exec eval", false, 5, mdriver.ExecResult{Status: 5}, eng), + exit: 5, + } + c := NewClient("m-ydb", "ydb", "local", nil, f.run) + + got, err := c.ExecEval(context.Background(), "do ^ZZZ") + if err != nil { + t.Fatalf("engine fault must be data, not a Go error: %v", err) + } + if got.EngineError == nil || got.EngineError.Mnemonic != "" { + t.Fatalf("engineError = %+v, want ", got.EngineError) + } +} + +func TestClient_LaunchError(t *testing.T) { + f := &fakeCmd{err: errors.New("exec: \"m-ydb\": executable file not found")} + c := NewClient("m-ydb", "ydb", "local", nil, f.run) + if _, err := c.Status(context.Background()); err == nil { + t.Error("a launch failure must be a Go error") + } +} + +func TestClient_BadEnvelope(t *testing.T) { + f := &fakeCmd{stdout: []byte("not json"), exit: 0} + c := NewClient("m-ydb", "ydb", "local", nil, f.run) + if _, err := c.Status(context.Background()); err == nil { + t.Error("non-JSON output must be a Go error") + } +} diff --git a/internal/driver/locate.go b/internal/driver/locate.go new file mode 100644 index 0000000..8c00234 --- /dev/null +++ b/internal/driver/locate.go @@ -0,0 +1,64 @@ +package driver + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// LocateDeps are the injectable lookups used to find a driver binary (so the +// resolution order is unit-testable without a real filesystem/PATH). +type LocateDeps struct { + Getenv func(string) string // environment lookup + LookPath func(string) (string, error) // PATH resolution + ExeDir func() (string, error) // directory of the running `m` executable + IsFile func(string) bool // path exists and is an executable file +} + +// Locate finds the m- driver binary, in the contract's resolution order +// (driver-contract.md §4): $M__BIN → next to the `m` executable → the +// sibling source checkout's dist/ (…/m-/dist/m-) → $PATH. +func Locate(engine string, d LocateDeps) (string, error) { + bin := "m-" + engine + if v := d.Getenv("M_" + strings.ToUpper(engine) + "_BIN"); v != "" { + return v, nil + } + if d.ExeDir != nil { + if dir, err := d.ExeDir(); err == nil { + if cand := filepath.Join(dir, bin); d.IsFile(cand) { + return cand, nil + } + // sibling source checkout: `m` at /m-cli/dist/m → the driver at + // /m-/dist/m- (two levels up from dist/). + if sib := filepath.Join(dir, "..", "..", bin, "dist", bin); d.IsFile(sib) { + return sib, nil + } + } + } + if p, err := d.LookPath(bin); err == nil { + return p, nil + } + return "", fmt.Errorf("driver binary %q not found — set M_%s_BIN, put it next to `m`, build it in ../%s/dist/, or add it to PATH", + bin, strings.ToUpper(engine), bin) +} + +// DefaultLocateDeps binds Locate to the real environment. +func DefaultLocateDeps() LocateDeps { + return LocateDeps{ + Getenv: os.Getenv, + LookPath: exec.LookPath, + ExeDir: func() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(exe), nil + }, + IsFile: func(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() + }, + } +} diff --git a/internal/driver/locate_test.go b/internal/driver/locate_test.go new file mode 100644 index 0000000..e741ab8 --- /dev/null +++ b/internal/driver/locate_test.go @@ -0,0 +1,56 @@ +package driver + +import ( + "errors" + "testing" +) + +func deps(env map[string]string, files map[string]bool, exeDir string, path map[string]string) LocateDeps { + return LocateDeps{ + Getenv: func(k string) string { return env[k] }, + IsFile: func(p string) bool { return files[p] }, + ExeDir: func() (string, error) { return exeDir, nil }, + LookPath: func(b string) (string, error) { + if p, ok := path[b]; ok { + return p, nil + } + return "", errors.New("not on PATH") + }, + } +} + +func TestLocate_EnvWins(t *testing.T) { + got, err := Locate("iris", deps(map[string]string{"M_IRIS_BIN": "/custom/m-iris"}, nil, "/x", nil)) + if err != nil || got != "/custom/m-iris" { + t.Fatalf("got %q, %v; want /custom/m-iris", got, err) + } +} + +func TestLocate_NextToExe(t *testing.T) { + got, err := Locate("ydb", deps(nil, map[string]bool{"/opt/bin/m-ydb": true}, "/opt/bin", nil)) + if err != nil || got != "/opt/bin/m-ydb" { + t.Fatalf("got %q, %v; want /opt/bin/m-ydb", got, err) + } +} + +func TestLocate_SiblingDist(t *testing.T) { + // `m` at /ws/m-cli/dist/m → sibling driver at /ws/m-ydb/dist/m-ydb (the + // ../.. is cleaned by filepath.Join). + got, err := Locate("ydb", deps(nil, map[string]bool{"/ws/m-ydb/dist/m-ydb": true}, "/ws/m-cli/dist", nil)) + if err != nil || got != "/ws/m-ydb/dist/m-ydb" { + t.Fatalf("got %q, %v; want /ws/m-ydb/dist/m-ydb", got, err) + } +} + +func TestLocate_PathFallback(t *testing.T) { + got, err := Locate("iris", deps(nil, nil, "/x", map[string]string{"m-iris": "/usr/bin/m-iris"})) + if err != nil || got != "/usr/bin/m-iris" { + t.Fatalf("got %q, %v; want /usr/bin/m-iris", got, err) + } +} + +func TestLocate_NotFound(t *testing.T) { + if _, err := Locate("ydb", deps(nil, nil, "/x", nil)); err == nil { + t.Error("want an error when the driver binary is nowhere") + } +} diff --git a/internal/engine/vista.go b/internal/engine/vista.go new file mode 100644 index 0000000..7afa8a8 --- /dev/null +++ b/internal/engine/vista.go @@ -0,0 +1,91 @@ +package engine + +import ( + "context" + "fmt" + + "github.com/vista-cloud-dev/m-cli/internal/driver" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// VistaEngine is the driver-backed engine: it satisfies Engine by delegating +// every verb to an m- driver binary over the neutral contract +// (internal/driver), instead of building a yottadb/iris argv for an in-process +// Runner. This is how the m-cli runner reaches a live FOIA VistA on either engine +// behind one contract — the driver owns the wire (m-ydb local/docker/SSH, m-iris +// Atelier REST), so m-cli stays vendor-neutral (driver-contract §1, §11). +// +// It sits beside the in-process YdbEngine/IrisEngine (additive); the D3 cutover +// that retires those is a separate, conformance-gated step. +type VistaEngine struct { + kind Kind + client *driver.Client +} + +var _ Engine = (*VistaEngine)(nil) + +// NewVista builds a driver-backed engine of kind (ydb|iris) over client. +func NewVista(kind Kind, client *driver.Client) *VistaEngine { + return &VistaEngine{kind: kind, client: client} +} + +// Kind reports the engine the driver targets (ydb or iris). +func (e *VistaEngine) Kind() Kind { return e.kind } + +// EnsureLoaded stages + compiles a routine via `exec load`. +func (e *VistaEngine) EnsureLoaded(ctx context.Context, path string) error { + r, err := e.client.Load(ctx, []string{path}) + if err != nil { + return err + } + if r.EngineError != nil { + return fmt.Errorf("load %s: %s %s", path, r.EngineError.Mnemonic, r.EngineError.Text) + } + return nil +} + +// RunRoutine runs an entryref via `exec run`; args become $ZCMDLINE. +func (e *VistaEngine) RunRoutine(ctx context.Context, entryref string, args ...string) (Result, error) { + r, err := e.client.ExecRun(ctx, entryref, args) + if err != nil { + return Result{}, err + } + return toResult(r), nil +} + +// RunXCmd evaluates one M command via `exec eval`. +func (e *VistaEngine) RunXCmd(ctx context.Context, mcmd string) (Result, error) { + r, err := e.client.ExecEval(ctx, mcmd) + if err != nil { + return Result{}, err + } + return toResult(r), nil +} + +// RunScript is not available over the driver transport: the exec axis has no +// multi-line direct-mode verb (load/run/eval/abort only). Compound work runs as +// a staged routine + RunRoutine, not a stdin script. +func (e *VistaEngine) RunScript(ctx context.Context, script string) (Result, error) { + return Result{}, fmt.Errorf("engine: RunScript is not supported over the VistA driver transport (stage a routine with EnsureLoaded, then RunRoutine)") +} + +// Probe is the reachability + identity gate (T0.1): `lifecycle status`, carrying +// running/healthy/version. It is the portable cross-engine equivalent of running +// `W $ZV` and reading the banner — IRIS exec does not capture device output, so +// status (not eval) is the uniform way to confirm a VistA is live and identify it. +func (e *VistaEngine) Probe(ctx context.Context) (mdriver.Status, error) { + return e.client.Status(ctx) +} + +// toResult maps a contract ExecResult onto the engine Result; an engineError is +// folded into Stderr so existing callers see a non-empty failure detail. +func toResult(r mdriver.ExecResult) Result { + res := Result{Stdout: r.Stdout, ExitCode: r.Status} + if r.EngineError != nil { + res.Stderr = r.EngineError.Mnemonic + if r.EngineError.Text != "" { + res.Stderr += " " + r.EngineError.Text + } + } + return res +} diff --git a/internal/engine/vista_test.go b/internal/engine/vista_test.go new file mode 100644 index 0000000..0752dc7 --- /dev/null +++ b/internal/engine/vista_test.go @@ -0,0 +1,79 @@ +package engine + +import ( + "context" + "encoding/json" + "testing" + + "github.com/vista-cloud-dev/m-cli/internal/driver" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" +) + +// vistaWith builds a VistaEngine over a driver.Client whose subprocess runner is +// faked: it returns the canned envelope for whatever verb is invoked. +func vistaWith(t *testing.T, kind Kind, command string, ok bool, exit int, data any, eng *mdriver.EngineError, toStderr bool) *VistaEngine { + t.Helper() + raw, _ := json.Marshal(data) + env := map[string]any{"schemaVersion": "1.0", "command": command, "ok": ok, "exit": exit, "data": json.RawMessage(raw)} + if eng != nil { + env["engineError"] = eng + } + b, _ := json.Marshal(env) + run := func(_ context.Context, _ string, _ []string) (stdout, stderr []byte, code int, err error) { + if toStderr { + return nil, b, exit, nil + } + return b, nil, exit, nil + } + cl := driver.NewClient("m-"+string(kind), string(kind), "remote", nil, run) + return NewVista(kind, cl) +} + +func TestVista_SatisfiesEngine(t *testing.T) { + var _ Engine = (*VistaEngine)(nil) +} + +func TestVista_Probe_ReturnsVersion(t *testing.T) { + st := mdriver.Status{Running: true, Healthy: true, Version: "IRIS for UNIX 2026.1"} + e := vistaWith(t, IRIS, "lifecycle status", true, 0, st, nil, false) + got, err := e.Probe(context.Background()) + if err != nil { + t.Fatalf("probe: %v", err) + } + if !got.Healthy || got.Version == "" { + t.Errorf("probe = %+v, want healthy + version", got) + } + if e.Kind() != IRIS { + t.Errorf("kind = %q, want iris", e.Kind()) + } +} + +func TestVista_RunXCmd_Stdout(t *testing.T) { + e := vistaWith(t, YDB, "exec eval", true, 0, mdriver.ExecResult{Stdout: "YottaDB r2.02", Status: 0}, nil, false) + res, err := e.RunXCmd(context.Background(), "w $zv") + if err != nil { + t.Fatalf("runxcmd: %v", err) + } + if res.Stdout != "YottaDB r2.02" || res.ExitCode != 0 { + t.Errorf("result = %+v", res) + } +} + +func TestVista_RunXCmd_EngineErrorToStderr(t *testing.T) { + eng := &mdriver.EngineError{Mnemonic: "%YDB-E-LVUNDEF", Text: "undefined"} + e := vistaWith(t, YDB, "exec eval", false, 5, mdriver.ExecResult{Status: 5}, eng, true) + res, err := e.RunXCmd(context.Background(), "w undef") + if err != nil { + t.Fatalf("engine fault must be a result, not a Go error: %v", err) + } + if res.ExitCode != 5 || res.Stderr == "" { + t.Errorf("result = %+v, want exit 5 + stderr cause", res) + } +} + +func TestVista_RunScript_Unsupported(t *testing.T) { + e := vistaWith(t, YDB, "exec eval", true, 0, mdriver.ExecResult{}, nil, false) + if _, err := e.RunScript(context.Background(), "set x=1\nwrite x"); err == nil { + t.Error("RunScript must report unsupported over the driver transport") + } +} diff --git a/main.go b/main.go index cf12899..892ed6a 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,7 @@ type CLI struct { Test testCmd `cmd:"" help:"Run *TST.m suites through the engine (^STDASSERT)."` Coverage coverageCmd `cmd:"" help:"Line coverage over the engine (YDB view \"TRACE\" → LCOV)."` Watch watchCmd `cmd:"" help:"Re-run lint/fmt (and, with --run, tests) on M files as they change."` + Vista vistaCmd `cmd:"" help:"Reach a live VistA via its m- driver (status / exec) — the driver-backed engine transport."` // Dispatched namespaces (spec §2.2): each forwards to a sibling binary. // irissync owns the IRIS source axis; kids-vc owns the KIDS round-trip. diff --git a/vista_cmd.go b/vista_cmd.go new file mode 100644 index 0000000..126df42 --- /dev/null +++ b/vista_cmd.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/vista-cloud-dev/m-cli/clikit" + "github.com/vista-cloud-dev/m-cli/internal/driver" + "github.com/vista-cloud-dev/m-cli/internal/engine" +) + +// vistaCmd is the driver-backed engine surface: it reaches a live VistA (or any +// engine) by invoking the m- driver binary over the neutral contract, +// rather than the in-process YDB/IRIS engines. This is the m-cli side of VSL +// T0.1 — "the runner opens a session on each engine and runs W $ZV (both +// reachable)" — realized via the conformance-gated drivers (m-ydb, m-iris). +type vistaCmd struct { + Status vistaStatusCmd `cmd:"" help:"Probe a live VistA via its driver: running / healthy / version (reachability + W $ZV gate)."` + Exec vistaExecCmd `cmd:"" help:"Evaluate one M command on a live VistA via its driver."` +} + +// vistaConn selects which engine driver to drive and over which transport. The +// connection itself (host/container/base-url, credentials) is read by the driver +// from its M__* environment, so it never appears here. +type vistaConn struct { + Engine string `help:"Engine to reach: ydb or iris." enum:"ydb,iris" required:""` + Transport string `help:"Driver transport: local | docker | remote." enum:"local,docker,remote" default:"remote"` +} + +// build resolves the driver binary (driver-contract §4) and returns the +// driver-backed engine. +func (v vistaConn) build() (*engine.VistaEngine, error) { + bin, err := driver.Locate(v.Engine, driver.DefaultLocateDeps()) + if err != nil { + return nil, err + } + cl := driver.NewClient(bin, v.Engine, v.Transport, nil, nil) + return engine.NewVista(engine.Kind(v.Engine), cl), nil +} + +type vistaStatusCmd struct { + vistaConn +} + +func (c *vistaStatusCmd) Run(cc *clikit.Context) error { + eng, err := c.build() + if err != nil { + return clikit.Fail(clikit.ExitRefused, "NO_DRIVER", err.Error(), + "build the m-"+c.Engine+" driver (make build) or set M_"+strings.ToUpper(c.Engine)+"_BIN") + } + st, err := eng.Probe(context.Background()) + if err != nil { + return clikit.Fail(clikit.ExitRefused, "UNREACHABLE", err.Error(), + "check the driver connection (M_"+strings.ToUpper(c.Engine)+"_* env)") + } + return cc.Result(st, func() { + cc.Title(fmt.Sprintf("vista %s — %s", c.Engine, c.Transport)) + cc.KV( + [2]string{"running", fmt.Sprint(st.Running)}, + [2]string{"healthy", fmt.Sprint(st.Healthy)}, + [2]string{"version", st.Version}, + ) + }) +} + +type vistaExecCmd struct { + vistaConn + Command []string `arg:"" help:"M command to evaluate (quote it as one shell arg)."` +} + +type vistaExecResult struct { + Stdout string `json:"stdout"` + Status int `json:"status"` + Stderr string `json:"stderr,omitempty"` +} + +func (c *vistaExecCmd) Run(cc *clikit.Context) error { + eng, err := c.build() + if err != nil { + return clikit.Fail(clikit.ExitRefused, "NO_DRIVER", err.Error(), + "build the m-"+c.Engine+" driver (make build) or set M_"+strings.ToUpper(c.Engine)+"_BIN") + } + res, err := eng.RunXCmd(context.Background(), strings.Join(c.Command, " ")) + if err != nil { + return clikit.Fail(clikit.ExitRuntime, "EXEC", err.Error(), "") + } + return cc.Result(vistaExecResult{Stdout: res.Stdout, Status: res.ExitCode, Stderr: res.Stderr}, func() { + if res.Stdout != "" { + fmt.Fprintln(cc.Stdout, res.Stdout) + } + if res.Stderr != "" { + fmt.Fprintln(cc.Stdout, cc.Faint(res.Stderr)) + } + fmt.Fprintln(cc.Stdout, cc.Faint(fmt.Sprintf("status %d", res.ExitCode))) + }) +} From 91c9fba53047d3a7fafb5943275fd28fe0bd8f2c Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Fri, 12 Jun 2026 08:57:44 -0400 Subject: [PATCH 2/3] deps: repin SDK v0.3.0; drop internal/driver for mdriver.Client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete step 4 of the m-driver-sdk v0.3.0 ceremony (the seam's transport monopoly, waterline rule 3): m-cli now imports the SDK's reference engine Client instead of carrying its own copy. - go.mod: m-driver-sdk v0.2.0 -> v0.3.0 (default online proxy, sumdb-verified go.sum). - delete internal/driver/{client,locate}.go + tests — the byte-identical originals now live in the SDK (pkg mdriver) as of v0.3.0. - consumers switched to mdriver: vista_cmd.go build() -> mdriver.Locate / DefaultLocateDeps / NewClient; internal/engine/vista.go field + NewVista -> *mdriver.Client; vista_test.go -> mdriver.NewClient. - cleared two pre-existing revive unused-parameter findings in the touched engine files (RunScript ctx/script, TestVista_SatisfiesEngine t). make all green: lint clean, race tests pass, build OK. No duplicate engine client remains (waterline G3 transport-monopoly). Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 +- internal/driver/client.go | 183 --------------------------------- internal/driver/client_test.go | 125 ---------------------- internal/driver/locate.go | 64 ------------ internal/driver/locate_test.go | 56 ---------- internal/engine/vista.go | 11 +- internal/engine/vista_test.go | 7 +- vista_cmd.go | 6 +- 9 files changed, 14 insertions(+), 444 deletions(-) delete mode 100644 internal/driver/client.go delete mode 100644 internal/driver/client_test.go delete mode 100644 internal/driver/locate.go delete mode 100644 internal/driver/locate_test.go diff --git a/go.mod b/go.mod index 7043189..d7a18d5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/kong v1.15.0 github.com/charmbracelet/lipgloss v1.1.0 - github.com/vista-cloud-dev/m-driver-sdk v0.2.0 + github.com/vista-cloud-dev/m-driver-sdk v0.3.0 github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db github.com/willabides/kongplete v0.4.0 golang.org/x/term v0.43.0 diff --git a/go.sum b/go.sum index cc7f160..051fa1d 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= -github.com/vista-cloud-dev/m-driver-sdk v0.2.0 h1:YcwmP0os9kG/TIpGAvEktnGiWs7oR0whToKV4su49P0= -github.com/vista-cloud-dev/m-driver-sdk v0.2.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= +github.com/vista-cloud-dev/m-driver-sdk v0.3.0 h1:RudBmVTutjVPur0mF9sFxv7tBpCa2L78DD5GZuB8KGI= +github.com/vista-cloud-dev/m-driver-sdk v0.3.0/go.mod h1:0Qkz38Qhgyr5nYQeqgthkMHt4zVJMN3j79Kfr+THtpw= github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db h1:e0x+stGSyYA/W2Zn13Y0E85B4vD9Y46rgV79XKzuODE= github.com/vista-cloud-dev/m-parse v0.0.0-20260529163350-9509c68573db/go.mod h1:XAG984cMiUq5ST14W3YvlZP9iMIURPWond2TjvEsDIY= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= diff --git a/internal/driver/client.go b/internal/driver/client.go deleted file mode 100644 index 57c84d5..0000000 --- a/internal/driver/client.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package driver is m-cli's client for the m engine-driver contract: it reaches -// a live VistA (or any engine) by invoking an m- driver binary over the -// contract's subprocess + JSON-envelope seam (driver-contract.md §2) — -// `m- [args] --transport [conn] --output json` — and -// parsing the one JSON envelope it writes. m-cli speaks only the neutral -// contract (§1, §11: "zero changes to m-cli" to add an engine); all vendor -// detail lives behind the binary, which the engine package's VistaEngine wraps. -// -// The drivers reach the engine themselves: m-ydb via local/docker/SSH, m-iris -// via Atelier REST — so this client never knows the wire, only the contract. -package driver - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os/exec" - - mdriver "github.com/vista-cloud-dev/m-driver-sdk" -) - -// CmdRunner runs the driver binary and returns its stdout, stderr, and process -// exit. Only a launch failure (binary missing, etc.) is a Go error; a non-zero -// engine/driver exit is a result. Injected so the client is testable without a -// real driver binary. -type CmdRunner func(ctx context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) - -// Client drives one m- binary for one transport+connection. -type Client struct { - Bin string // path to the m- binary - Engine string // "ydb" | "iris" (for error messages) - Transport string // local | docker | remote - ConnArgs []string // extra connection flags (e.g. --container, --base-url); usually empty (driver reads M__* env) - run CmdRunner -} - -// NewClient builds a driver client. A nil run uses the real subprocess runner. -func NewClient(bin, engine, transport string, connArgs []string, run CmdRunner) *Client { - if run == nil { - run = ExecRunner - } - return &Client{Bin: bin, Engine: engine, Transport: transport, ConnArgs: connArgs, run: run} -} - -// envelope is the wire shape the driver writes (driver-contract.md §2). engineError -// is a sibling of data, set on a §7 fault. -type envelope struct { - SchemaVersion string `json:"schemaVersion"` - Command string `json:"command"` - OK bool `json:"ok"` - Exit int `json:"exit"` - Data json.RawMessage `json:"data"` - Error *envError `json:"error,omitempty"` - EngineError *mdriver.EngineError `json:"engineError,omitempty"` -} - -type envError struct { - Code string `json:"code"` - Exit int `json:"exit"` - Message string `json:"message"` - Hint string `json:"hint"` -} - -// call invokes a verb and returns the parsed envelope, decoding data into out -// (when non-nil). It reads the envelope from stdout, falling back to stderr — -// the drivers' success envelopes go to stdout (cc.Result), but a Fail-path -// envelope currently lands on stderr (a clikit deviation from §2's "one JSON -// envelope to stdout"; tolerated here, flagged for a clikit/conformance-v2 fix). -func (c *Client) call(ctx context.Context, out any, args ...string) (*envelope, error) { - full := append([]string{}, args...) - full = append(full, "--transport", c.Transport) - full = append(full, c.ConnArgs...) - full = append(full, "--output", "json") - - stdout, stderr, _, err := c.run(ctx, c.Bin, full) - if err != nil { - return nil, fmt.Errorf("driver %s: launch %s: %w", c.Engine, c.Bin, err) - } - - env, perr := parseEnvelope(stdout) - if perr != nil { - // Fall back to stderr (Fail-path envelope) before giving up. - if env2, perr2 := parseEnvelope(stderr); perr2 == nil { - env = env2 - } else { - return nil, fmt.Errorf("driver %s: no JSON envelope on stdout or stderr: %w", c.Engine, perr) - } - } - if out != nil && len(env.Data) > 0 { - if jerr := json.Unmarshal(env.Data, out); jerr != nil { - return nil, fmt.Errorf("driver %s: decode %s data: %w", c.Engine, env.Command, jerr) - } - } - return env, nil -} - -func parseEnvelope(b []byte) (*envelope, error) { - if len(bytes.TrimSpace(b)) == 0 { - return nil, errors.New("empty output") - } - var env envelope - if err := json.Unmarshal(b, &env); err != nil { - return nil, err - } - if env.SchemaVersion == "" { - return nil, errors.New("not a clikit envelope (no schemaVersion)") - } - return &env, nil -} - -// Status runs `lifecycle status` — the reachability + identity probe (running, -// healthy, version). This is the engine-neutral way to prove a VistA is live and -// learn its version banner (the portable replacement for `W $ZV`, which only -// captures device output on YottaDB). -func (c *Client) Status(ctx context.Context) (mdriver.Status, error) { - var s mdriver.Status - _, err := c.call(ctx, &s, "lifecycle", "status") - return s, err -} - -// Caps fetches the driver's capability document. -func (c *Client) Caps(ctx context.Context) (mdriver.Caps, error) { - var caps mdriver.Caps - _, err := c.call(ctx, &caps, "meta", "caps") - return caps, err -} - -// ExecEval evaluates a single M command. A §7 engine fault is returned in -// ExecResult.EngineError (data), not as a Go error — only a transport/launch -// failure is a Go error. -func (c *Client) ExecEval(ctx context.Context, command string) (mdriver.ExecResult, error) { - return c.exec(ctx, "eval", []string{command}) -} - -// ExecRun runs an entryref (args become $ZCMDLINE / the formallist). -func (c *Client) ExecRun(ctx context.Context, entryref string, args []string) (mdriver.ExecResult, error) { - return c.exec(ctx, "run", append([]string{entryref}, args...)) -} - -func (c *Client) exec(ctx context.Context, verb string, rest []string) (mdriver.ExecResult, error) { - var r mdriver.ExecResult - env, err := c.call(ctx, &r, append([]string{"exec", verb}, rest...)...) - if err != nil { - return mdriver.ExecResult{}, err - } - if env.EngineError != nil { - r.EngineError = env.EngineError - } - return r, nil -} - -// Load stages + compiles routine source (exec load). -func (c *Client) Load(ctx context.Context, paths []string) (mdriver.LoadResult, error) { - var r mdriver.LoadResult - env, err := c.call(ctx, &r, append([]string{"exec", "load"}, paths...)...) - if err != nil { - return mdriver.LoadResult{}, err - } - if env.EngineError != nil { - r.EngineError = env.EngineError - } - return r, nil -} - -// ExecRunner is the production CmdRunner: it runs the driver binary, capturing -// stdout and stderr separately. A non-zero exit is a result; only a launch -// failure is an error. -func ExecRunner(ctx context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) { - cmd := exec.CommandContext(ctx, name, args...) - var out, errb bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errb - runErr := cmd.Run() - var ee *exec.ExitError - if errors.As(runErr, &ee) { - return out.Bytes(), errb.Bytes(), ee.ExitCode(), nil - } - if runErr != nil { - return out.Bytes(), errb.Bytes(), 0, runErr - } - return out.Bytes(), errb.Bytes(), 0, nil -} diff --git a/internal/driver/client_test.go b/internal/driver/client_test.go deleted file mode 100644 index 2a56520..0000000 --- a/internal/driver/client_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package driver - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "testing" - - mdriver "github.com/vista-cloud-dev/m-driver-sdk" -) - -// fakeCmd records the last invocation and returns canned streams. -type fakeCmd struct { - name string - args []string - stdout []byte - stderr []byte - exit int - err error -} - -func (f *fakeCmd) run(_ context.Context, name string, args []string) (stdout, stderr []byte, exit int, err error) { - f.name, f.args = name, args - return f.stdout, f.stderr, f.exit, f.err -} - -// envBytes builds a clikit-style JSON envelope (the wire shape the client parses). -func envBytes(t *testing.T, command string, ok bool, exit int, data any, eng *mdriver.EngineError) []byte { - t.Helper() - raw, err := json.Marshal(data) - if err != nil { - t.Fatalf("marshal data: %v", err) - } - env := map[string]any{ - "schemaVersion": "1.0", "command": command, "ok": ok, "exit": exit, - "data": json.RawMessage(raw), - } - if eng != nil { - env["engineError"] = eng - } - b, err := json.Marshal(env) - if err != nil { - t.Fatalf("marshal env: %v", err) - } - return b -} - -func TestClient_Status_ArgvAndParse(t *testing.T) { - st := mdriver.Status{Transport: "remote", Running: true, Healthy: true, Version: "IRIS for UNIX 2026.1"} - f := &fakeCmd{stdout: envBytes(t, "lifecycle status", true, 0, st, nil)} - c := NewClient("/bin/m-iris", "iris", "remote", []string{"--base-url", "X"}, f.run) - - got, err := c.Status(context.Background()) - if err != nil { - t.Fatalf("Status: %v", err) - } - if !got.Healthy || got.Version != st.Version { - t.Errorf("status = %+v, want healthy + version", got) - } - if f.name != "/bin/m-iris" { - t.Errorf("bin = %q", f.name) - } - want := []string{"lifecycle", "status", "--transport", "remote", "--base-url", "X", "--output", "json"} - if !reflect.DeepEqual(f.args, want) { - t.Errorf("argv = %v\nwant %v", f.args, want) - } -} - -func TestClient_ExecEval_StdoutSuccess(t *testing.T) { - r := mdriver.ExecResult{Stdout: "HI", Status: 0} - f := &fakeCmd{stdout: envBytes(t, "exec eval", true, 0, r, nil)} - c := NewClient("m-ydb", "ydb", "local", nil, f.run) - - got, err := c.ExecEval(context.Background(), `w "HI"`) - if err != nil { - t.Fatalf("ExecEval: %v", err) - } - if got.Stdout != "HI" { - t.Errorf("stdout = %q", got.Stdout) - } - if got.EngineError != nil { - t.Errorf("unexpected engineError: %+v", got.EngineError) - } - want := []string{"exec", "eval", `w "HI"`, "--transport", "local", "--output", "json"} - if !reflect.DeepEqual(f.args, want) { - t.Errorf("argv = %v\nwant %v", f.args, want) - } -} - -// On an engine fault the driver emits the envelope (with engineError) to STDERR -// and exits non-zero; the client must read stderr and surface engineError as -// DATA (not a Go error) so callers can render a RED-with-cause result. -func TestClient_ExecEval_EngineErrorFromStderr(t *testing.T) { - eng := &mdriver.EngineError{Mnemonic: "", Text: "no such routine", Line: 12, Routine: "ZZZ"} - f := &fakeCmd{ - stderr: envBytes(t, "exec eval", false, 5, mdriver.ExecResult{Status: 5}, eng), - exit: 5, - } - c := NewClient("m-ydb", "ydb", "local", nil, f.run) - - got, err := c.ExecEval(context.Background(), "do ^ZZZ") - if err != nil { - t.Fatalf("engine fault must be data, not a Go error: %v", err) - } - if got.EngineError == nil || got.EngineError.Mnemonic != "" { - t.Fatalf("engineError = %+v, want ", got.EngineError) - } -} - -func TestClient_LaunchError(t *testing.T) { - f := &fakeCmd{err: errors.New("exec: \"m-ydb\": executable file not found")} - c := NewClient("m-ydb", "ydb", "local", nil, f.run) - if _, err := c.Status(context.Background()); err == nil { - t.Error("a launch failure must be a Go error") - } -} - -func TestClient_BadEnvelope(t *testing.T) { - f := &fakeCmd{stdout: []byte("not json"), exit: 0} - c := NewClient("m-ydb", "ydb", "local", nil, f.run) - if _, err := c.Status(context.Background()); err == nil { - t.Error("non-JSON output must be a Go error") - } -} diff --git a/internal/driver/locate.go b/internal/driver/locate.go deleted file mode 100644 index 8c00234..0000000 --- a/internal/driver/locate.go +++ /dev/null @@ -1,64 +0,0 @@ -package driver - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -// LocateDeps are the injectable lookups used to find a driver binary (so the -// resolution order is unit-testable without a real filesystem/PATH). -type LocateDeps struct { - Getenv func(string) string // environment lookup - LookPath func(string) (string, error) // PATH resolution - ExeDir func() (string, error) // directory of the running `m` executable - IsFile func(string) bool // path exists and is an executable file -} - -// Locate finds the m- driver binary, in the contract's resolution order -// (driver-contract.md §4): $M__BIN → next to the `m` executable → the -// sibling source checkout's dist/ (…/m-/dist/m-) → $PATH. -func Locate(engine string, d LocateDeps) (string, error) { - bin := "m-" + engine - if v := d.Getenv("M_" + strings.ToUpper(engine) + "_BIN"); v != "" { - return v, nil - } - if d.ExeDir != nil { - if dir, err := d.ExeDir(); err == nil { - if cand := filepath.Join(dir, bin); d.IsFile(cand) { - return cand, nil - } - // sibling source checkout: `m` at /m-cli/dist/m → the driver at - // /m-/dist/m- (two levels up from dist/). - if sib := filepath.Join(dir, "..", "..", bin, "dist", bin); d.IsFile(sib) { - return sib, nil - } - } - } - if p, err := d.LookPath(bin); err == nil { - return p, nil - } - return "", fmt.Errorf("driver binary %q not found — set M_%s_BIN, put it next to `m`, build it in ../%s/dist/, or add it to PATH", - bin, strings.ToUpper(engine), bin) -} - -// DefaultLocateDeps binds Locate to the real environment. -func DefaultLocateDeps() LocateDeps { - return LocateDeps{ - Getenv: os.Getenv, - LookPath: exec.LookPath, - ExeDir: func() (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - return filepath.Dir(exe), nil - }, - IsFile: func(p string) bool { - info, err := os.Stat(p) - return err == nil && !info.IsDir() - }, - } -} diff --git a/internal/driver/locate_test.go b/internal/driver/locate_test.go deleted file mode 100644 index e741ab8..0000000 --- a/internal/driver/locate_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package driver - -import ( - "errors" - "testing" -) - -func deps(env map[string]string, files map[string]bool, exeDir string, path map[string]string) LocateDeps { - return LocateDeps{ - Getenv: func(k string) string { return env[k] }, - IsFile: func(p string) bool { return files[p] }, - ExeDir: func() (string, error) { return exeDir, nil }, - LookPath: func(b string) (string, error) { - if p, ok := path[b]; ok { - return p, nil - } - return "", errors.New("not on PATH") - }, - } -} - -func TestLocate_EnvWins(t *testing.T) { - got, err := Locate("iris", deps(map[string]string{"M_IRIS_BIN": "/custom/m-iris"}, nil, "/x", nil)) - if err != nil || got != "/custom/m-iris" { - t.Fatalf("got %q, %v; want /custom/m-iris", got, err) - } -} - -func TestLocate_NextToExe(t *testing.T) { - got, err := Locate("ydb", deps(nil, map[string]bool{"/opt/bin/m-ydb": true}, "/opt/bin", nil)) - if err != nil || got != "/opt/bin/m-ydb" { - t.Fatalf("got %q, %v; want /opt/bin/m-ydb", got, err) - } -} - -func TestLocate_SiblingDist(t *testing.T) { - // `m` at /ws/m-cli/dist/m → sibling driver at /ws/m-ydb/dist/m-ydb (the - // ../.. is cleaned by filepath.Join). - got, err := Locate("ydb", deps(nil, map[string]bool{"/ws/m-ydb/dist/m-ydb": true}, "/ws/m-cli/dist", nil)) - if err != nil || got != "/ws/m-ydb/dist/m-ydb" { - t.Fatalf("got %q, %v; want /ws/m-ydb/dist/m-ydb", got, err) - } -} - -func TestLocate_PathFallback(t *testing.T) { - got, err := Locate("iris", deps(nil, nil, "/x", map[string]string{"m-iris": "/usr/bin/m-iris"})) - if err != nil || got != "/usr/bin/m-iris" { - t.Fatalf("got %q, %v; want /usr/bin/m-iris", got, err) - } -} - -func TestLocate_NotFound(t *testing.T) { - if _, err := Locate("ydb", deps(nil, nil, "/x", nil)); err == nil { - t.Error("want an error when the driver binary is nowhere") - } -} diff --git a/internal/engine/vista.go b/internal/engine/vista.go index 7afa8a8..42fa3fc 100644 --- a/internal/engine/vista.go +++ b/internal/engine/vista.go @@ -4,13 +4,12 @@ import ( "context" "fmt" - "github.com/vista-cloud-dev/m-cli/internal/driver" mdriver "github.com/vista-cloud-dev/m-driver-sdk" ) // VistaEngine is the driver-backed engine: it satisfies Engine by delegating -// every verb to an m- driver binary over the neutral contract -// (internal/driver), instead of building a yottadb/iris argv for an in-process +// every verb to an m- driver binary over the neutral contract (the +// m-driver-sdk reference Client), instead of building a yottadb/iris argv for an in-process // Runner. This is how the m-cli runner reaches a live FOIA VistA on either engine // behind one contract — the driver owns the wire (m-ydb local/docker/SSH, m-iris // Atelier REST), so m-cli stays vendor-neutral (driver-contract §1, §11). @@ -19,13 +18,13 @@ import ( // that retires those is a separate, conformance-gated step. type VistaEngine struct { kind Kind - client *driver.Client + client *mdriver.Client } var _ Engine = (*VistaEngine)(nil) // NewVista builds a driver-backed engine of kind (ydb|iris) over client. -func NewVista(kind Kind, client *driver.Client) *VistaEngine { +func NewVista(kind Kind, client *mdriver.Client) *VistaEngine { return &VistaEngine{kind: kind, client: client} } @@ -65,7 +64,7 @@ func (e *VistaEngine) RunXCmd(ctx context.Context, mcmd string) (Result, error) // RunScript is not available over the driver transport: the exec axis has no // multi-line direct-mode verb (load/run/eval/abort only). Compound work runs as // a staged routine + RunRoutine, not a stdin script. -func (e *VistaEngine) RunScript(ctx context.Context, script string) (Result, error) { +func (e *VistaEngine) RunScript(_ context.Context, _ string) (Result, error) { return Result{}, fmt.Errorf("engine: RunScript is not supported over the VistA driver transport (stage a routine with EnsureLoaded, then RunRoutine)") } diff --git a/internal/engine/vista_test.go b/internal/engine/vista_test.go index 0752dc7..91a6176 100644 --- a/internal/engine/vista_test.go +++ b/internal/engine/vista_test.go @@ -5,11 +5,10 @@ import ( "encoding/json" "testing" - "github.com/vista-cloud-dev/m-cli/internal/driver" mdriver "github.com/vista-cloud-dev/m-driver-sdk" ) -// vistaWith builds a VistaEngine over a driver.Client whose subprocess runner is +// vistaWith builds a VistaEngine over an mdriver.Client whose subprocess runner is // faked: it returns the canned envelope for whatever verb is invoked. func vistaWith(t *testing.T, kind Kind, command string, ok bool, exit int, data any, eng *mdriver.EngineError, toStderr bool) *VistaEngine { t.Helper() @@ -25,11 +24,11 @@ func vistaWith(t *testing.T, kind Kind, command string, ok bool, exit int, data } return b, nil, exit, nil } - cl := driver.NewClient("m-"+string(kind), string(kind), "remote", nil, run) + cl := mdriver.NewClient("m-"+string(kind), string(kind), "remote", nil, run) return NewVista(kind, cl) } -func TestVista_SatisfiesEngine(t *testing.T) { +func TestVista_SatisfiesEngine(_ *testing.T) { var _ Engine = (*VistaEngine)(nil) } diff --git a/vista_cmd.go b/vista_cmd.go index 126df42..4318ef5 100644 --- a/vista_cmd.go +++ b/vista_cmd.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/vista-cloud-dev/m-cli/clikit" - "github.com/vista-cloud-dev/m-cli/internal/driver" "github.com/vista-cloud-dev/m-cli/internal/engine" + mdriver "github.com/vista-cloud-dev/m-driver-sdk" ) // vistaCmd is the driver-backed engine surface: it reaches a live VistA (or any @@ -31,11 +31,11 @@ type vistaConn struct { // build resolves the driver binary (driver-contract §4) and returns the // driver-backed engine. func (v vistaConn) build() (*engine.VistaEngine, error) { - bin, err := driver.Locate(v.Engine, driver.DefaultLocateDeps()) + bin, err := mdriver.Locate(v.Engine, mdriver.DefaultLocateDeps()) if err != nil { return nil, err } - cl := driver.NewClient(bin, v.Engine, v.Transport, nil, nil) + cl := mdriver.NewClient(bin, v.Engine, v.Transport, nil, nil) return engine.NewVista(engine.Kind(v.Engine), cl), nil } From a1a2861a315b90bbf120559d5e4201916649ebea Mon Sep 17 00:00:00 2001 From: Rafael Richards Date: Sat, 13 Jun 2026 21:27:06 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(arch):=20m=20arch=20check=20=E2=80=94?= =?UTF-8?q?=20the=20m/v=20waterline=20G1=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the first waterline gate (m-v-waterline-adr.md §3.2 G1, dependency-direction): dependency flows one way, v → m, never the reverse. The lowest-effort/highest-value gate the ADR §5.4 says to land first. - internal/arch: ResolveLayer reads the repo's declared layer from a committed meta artifact (dist/repo.meta.json, dist/v-contract.json, or root repo.meta.json) or a --layer override. Check runs two arms for an m-layer repo: the Go dependency closure (go list -deps -json → fail on any vista-cloud-dev/v-* module) and the M source (scan .m for VSL* references). A v-layer repo passes G1 trivially (v → m allowed). - m arch check command (clikit): exit 3 + violation list on any m → v edge; clean otherwise. Text and JSON output. - repo.meta.json: m-cli self-declares layer m (its dist/ is gitignored, so the tag lives at root) and self-gates in `make all` (new arch target). TDD: internal/arch 85.7% cover. Proven live — m-cli passes clean; v-cli checked as (false) layer m flags v-pkg + v-cli, exit 3. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 9 +- internal/arch/arch.go | 232 +++++++++++++++++++++++++++++++++ internal/arch/arch_test.go | 260 +++++++++++++++++++++++++++++++++++++ main.go | 67 ++++++++++ repo.meta.json | 9 ++ 5 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 internal/arch/arch.go create mode 100644 internal/arch/arch_test.go create mode 100644 repo.meta.json diff --git a/Makefile b/Makefile index 44fe39f..247838f 100644 --- a/Makefile +++ b/Makefile @@ -16,13 +16,18 @@ export CGO_ENABLED := 0 PLATFORMS := linux/amd64 linux/arm64 darwin/arm64 windows/amd64 -.PHONY: all build run lint test tidy schema dist clean +.PHONY: all build run lint test tidy schema dist clean arch -all: lint test build +all: lint test build arch build: go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o dist/$(BIN) . +# m/v waterline G1 gate (dependency-direction). This repo is layer m +# (repo.meta.json); the gate fails the build on any m → v dependency. +arch: build + ./dist/$(BIN) arch check . + run: build ./dist/$(BIN) $(ARGS) diff --git a/internal/arch/arch.go b/internal/arch/arch.go new file mode 100644 index 0000000..33f6181 --- /dev/null +++ b/internal/arch/arch.go @@ -0,0 +1,232 @@ +// Package arch implements the m/v waterline gates — the machine-checkable +// boundary between the engine-neutral `m` layer and the VistA-specific `v` +// layer (see docs/background/m-v-waterline-adr.md in the org `docs` repo). +// +// This stage ships G1 — dependency-direction — the core invariant: dependency +// flows one way, v → m, never the reverse. A repo declares its layer in a +// committed meta artifact ("layer": "m"|"v"); the gate then asserts that an +// `m`-layer repo's Go dependency closure contains no `vista-cloud-dev/v-*` +// module, and that its M source references no `VSL*` (v-layer) routine. A +// `v`-layer repo passes G1 trivially (v → m is allowed). +package arch + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// Layer is a repo's side of the waterline. +type Layer string + +const ( + // LayerM is the engine-neutral layer (runs on a bare M engine, no VistA). + LayerM Layer = "m" + // LayerV is the VistA-specific layer (needs Kernel/FileMan/KIDS). + LayerV Layer = "v" +) + +// vModulePrefix is the import-path prefix every VistA-specific Go module +// shares (v-pkg, v-cli, v-stdlib, …). An m-layer closure must not contain it. +const vModulePrefix = "github.com/vista-cloud-dev/v-" + +// vRoutineRef matches a reference to a v-layer (VSL*) M routine in any call +// form — ^VSLCFG, $$tag^VSLCFG, do x^VSLCFG — since all contain "^VSL". +var vRoutineRef = regexp.MustCompile(`\^VSL[A-Z0-9]*`) + +// Violation is one G1 finding — a dependency that crosses the waterline the +// wrong way (m → v). +type Violation struct { + Gate string `json:"gate"` // "G1" + Kind string `json:"kind"` // "go-dep" | "m-ref" + Source string `json:"source"` // offending module path or file:line + Detail string `json:"detail"` // human-readable explanation +} + +// Report is the full G1 result for one repo. +type Report struct { + Layer Layer `json:"layer"` + CheckedGo bool `json:"checkedGo"` + CheckedM bool `json:"checkedM"` + Violations []Violation `json:"violations"` +} + +// metaCandidates are the committed meta artifacts, in priority order, that may +// carry the repo's "layer" declaration (ADR §3.1). +var metaCandidates = []string{ + filepath.Join("dist", "repo.meta.json"), + filepath.Join("dist", "v-contract.json"), + "repo.meta.json", // repos whose dist/ is gitignored (e.g. m-cli) +} + +// ResolveLayer determines the repo's declared layer. An explicit override +// ("m"/"v") wins; otherwise the top-level "layer" field of a known committed +// meta artifact is read (dist/repo.meta.json, then dist/v-contract.json). +func ResolveLayer(root, override string) (Layer, error) { + if override != "" { + switch Layer(override) { + case LayerM, LayerV: + return Layer(override), nil + default: + return "", fmt.Errorf("invalid layer override %q (want m or v)", override) + } + } + for _, rel := range metaCandidates { + body, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + continue + } + var meta struct { + Layer string `json:"layer"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return "", fmt.Errorf("%s: %w", rel, err) + } + if meta.Layer == "" { + continue + } + switch Layer(meta.Layer) { + case LayerM, LayerV: + return Layer(meta.Layer), nil + default: + return "", fmt.Errorf(`%s: invalid "layer" %q (want m or v)`, rel, meta.Layer) + } + } + return "", fmt.Errorf(`no "layer" declared — add it to dist/repo.meta.json or dist/v-contract.json, or pass --layer`) +} + +// parseGoListDeps extracts the distinct module import paths from the streamed +// JSON objects emitted by `go list -deps -json ./...`. +func parseGoListDeps(stream []byte) ([]string, error) { + dec := json.NewDecoder(bytes.NewReader(stream)) + seen := map[string]bool{} + var mods []string + for { + var pkg struct { + Module *struct { + Path string `json:"Path"` + } `json:"Module"` + } + if err := dec.Decode(&pkg); err == io.EOF { + break + } else if err != nil { + return nil, err + } + if pkg.Module == nil || pkg.Module.Path == "" || seen[pkg.Module.Path] { + continue + } + seen[pkg.Module.Path] = true + mods = append(mods, pkg.Module.Path) + } + return mods, nil +} + +// vViolations flags any vista-cloud-dev/v-* module appearing in an m-layer +// dependency closure (the m → v G1 violation). +func vViolations(modulePaths []string) []Violation { + var vs []Violation + for _, p := range modulePaths { + if strings.HasPrefix(p, vModulePrefix) { + vs = append(vs, Violation{ + Gate: "G1", Kind: "go-dep", Source: p, + Detail: "m-layer module depends on a v-layer module (v → m only)", + }) + } + } + return vs +} + +// goListModules runs `go list -deps -json ./...` in root and returns the +// distinct module paths in the dependency closure. +func goListModules(root string) ([]string, error) { + cmd := exec.Command("go", "list", "-deps", "-json", "./...") + cmd.Dir = root + var out, errBuf bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errBuf + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("go list: %w: %s", err, strings.TrimSpace(errBuf.String())) + } + return parseGoListDeps(out.Bytes()) +} + +// CheckMRefs scans the .m source under root for references to v-layer (VSL*) +// routines — the M-side m → v G1 violation. Generated/vendored trees are +// skipped (dist, vendor, .git, node_modules). +func CheckMRefs(root string) ([]Violation, error) { + var vs []Violation + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + switch d.Name() { + case ".git", "dist", "vendor", "node_modules": + return filepath.SkipDir + } + return nil + } + if strings.ToLower(filepath.Ext(path)) != ".m" { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + rel = path + } + for i, line := range strings.Split(string(body), "\n") { + if m := vRoutineRef.FindString(line); m != "" { + vs = append(vs, Violation{ + Gate: "G1", Kind: "m-ref", + Source: fmt.Sprintf("%s:%d", rel, i+1), + Detail: fmt.Sprintf("m-layer routine references v-layer routine %s", m), + }) + } + } + return nil + }) + if err != nil { + return nil, err + } + return vs, nil +} + +// Check resolves the repo layer and runs the applicable G1 checks. A v-layer +// repo passes trivially (v → m is allowed); an m-layer repo is checked on both +// the Go dependency closure (when a go.mod is present) and its M source. +func Check(root, override string) (Report, error) { + layer, err := ResolveLayer(root, override) + if err != nil { + return Report{}, err + } + rep := Report{Layer: layer} + if layer == LayerV { + return rep, nil + } + // Go dependency-direction (only when the repo is a Go module). + if _, statErr := os.Stat(filepath.Join(root, "go.mod")); statErr == nil { + mods, err := goListModules(root) + if err != nil { + return rep, err + } + rep.CheckedGo = true + rep.Violations = append(rep.Violations, vViolations(mods)...) + } + // M-side dependency-direction (STD* → VSL*). + mvs, err := CheckMRefs(root) + if err != nil { + return rep, err + } + rep.CheckedM = true + rep.Violations = append(rep.Violations, mvs...) + return rep, nil +} diff --git a/internal/arch/arch_test.go b/internal/arch/arch_test.go new file mode 100644 index 0000000..ff6a9a9 --- /dev/null +++ b/internal/arch/arch_test.go @@ -0,0 +1,260 @@ +package arch + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// --- ResolveLayer ------------------------------------------------------------ + +func TestResolveLayerOverrideWins(t *testing.T) { + dir := t.TempDir() + // A repo.meta.json declaring "m" must be overridden by an explicit "v". + writeFile(t, filepath.Join(dir, "dist", "repo.meta.json"), `{"layer":"m"}`) + got, err := ResolveLayer(dir, "v") + if err != nil { + t.Fatalf("ResolveLayer: %v", err) + } + if got != LayerV { + t.Errorf("override: got %q, want v", got) + } +} + +func TestResolveLayerFromRepoMeta(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "repo.meta.json"), `{"id":"tool:x","layer":"m"}`) + got, err := ResolveLayer(dir, "") + if err != nil { + t.Fatalf("ResolveLayer: %v", err) + } + if got != LayerM { + t.Errorf("repo.meta: got %q, want m", got) + } +} + +func TestResolveLayerFromVContract(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "v-contract.json"), `{"domain":"pkg","layer":"v"}`) + got, err := ResolveLayer(dir, "") + if err != nil { + t.Fatalf("ResolveLayer: %v", err) + } + if got != LayerV { + t.Errorf("v-contract: got %q, want v", got) + } +} + +func TestResolveLayerFromRootMeta(t *testing.T) { + // A repo whose dist/ is gitignored (e.g. m-cli) declares layer in a + // root-level repo.meta.json instead. + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "repo.meta.json"), `{"id":"tool:m-cli","layer":"m"}`) + got, err := ResolveLayer(dir, "") + if err != nil { + t.Fatalf("ResolveLayer: %v", err) + } + if got != LayerM { + t.Errorf("root repo.meta: got %q, want m", got) + } +} + +func TestResolveLayerMissingIsError(t *testing.T) { + dir := t.TempDir() + if _, err := ResolveLayer(dir, ""); err == nil { + t.Error("expected an error when no layer is declared, got nil") + } +} + +func TestResolveLayerBadOverride(t *testing.T) { + dir := t.TempDir() + if _, err := ResolveLayer(dir, "x"); err == nil { + t.Error("expected an error for an invalid override, got nil") + } +} + +// --- parseGoListDeps --------------------------------------------------------- + +func TestParseGoListDeps(t *testing.T) { + // `go list -deps -json` emits a stream of concatenated package objects; + // some packages (stdlib) carry no Module. + stream := []byte(`{"ImportPath":"fmt"} +{"ImportPath":"github.com/vista-cloud-dev/m-cli/clikit","Module":{"Path":"github.com/vista-cloud-dev/m-cli"}} +{"ImportPath":"github.com/vista-cloud-dev/v-pkg/pkgcli","Module":{"Path":"github.com/vista-cloud-dev/v-pkg"}} +{"ImportPath":"github.com/vista-cloud-dev/m-cli/internal/arch","Module":{"Path":"github.com/vista-cloud-dev/m-cli"}}`) + mods, err := parseGoListDeps(stream) + if err != nil { + t.Fatalf("parseGoListDeps: %v", err) + } + // Distinct module paths only (the two m-cli packages collapse to one). + if !contains(mods, "github.com/vista-cloud-dev/m-cli") || + !contains(mods, "github.com/vista-cloud-dev/v-pkg") { + t.Errorf("expected both module paths, got %v", mods) + } + if n := count(mods, "github.com/vista-cloud-dev/m-cli"); n != 1 { + t.Errorf("expected distinct modules, m-cli appeared %d times", n) + } +} + +// --- vViolations ------------------------------------------------------------- + +func TestVViolationsFlagsVModules(t *testing.T) { + mods := []string{ + "github.com/vista-cloud-dev/m-cli", + "github.com/vista-cloud-dev/m-driver-sdk", + "github.com/vista-cloud-dev/v-pkg", + "github.com/alecthomas/kong", + } + vs := vViolations(mods) + if len(vs) != 1 { + t.Fatalf("expected 1 violation, got %d: %v", len(vs), vs) + } + if vs[0].Gate != "G1" || vs[0].Kind != "go-dep" || vs[0].Source != "github.com/vista-cloud-dev/v-pkg" { + t.Errorf("unexpected violation: %+v", vs[0]) + } +} + +func TestVViolationsCleanClosure(t *testing.T) { + mods := []string{ + "github.com/vista-cloud-dev/m-cli", + "github.com/vista-cloud-dev/m-driver-sdk", + "github.com/alecthomas/kong", + } + if vs := vViolations(mods); len(vs) != 0 { + t.Errorf("expected no violations for a clean m closure, got %v", vs) + } +} + +// --- CheckMRefs -------------------------------------------------------------- + +func TestCheckMRefsFlagsVSLCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "STDX.m"), + "STDX ;\n clean() ;\n set x=$$cfg^VSLCFG(\"a\")\n quit\n") + vs, err := CheckMRefs(dir) + if err != nil { + t.Fatalf("CheckMRefs: %v", err) + } + if len(vs) != 1 { + t.Fatalf("expected 1 m-ref violation, got %d: %v", len(vs), vs) + } + if vs[0].Gate != "G1" || vs[0].Kind != "m-ref" { + t.Errorf("unexpected violation: %+v", vs[0]) + } +} + +func TestCheckMRefsCleanSource(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "src", "STDX.m"), + "STDX ;\n set x=$$fmt^STDFMT(1)\n quit\n") + vs, err := CheckMRefs(dir) + if err != nil { + t.Fatalf("CheckMRefs: %v", err) + } + if len(vs) != 0 { + t.Errorf("expected no violations, got %v", vs) + } +} + +func TestCheckMRefsSkipsDist(t *testing.T) { + dir := t.TempDir() + // A generated artifact under dist/ that mentions ^VSL must not be scanned. + writeFile(t, filepath.Join(dir, "dist", "bundle.m"), " do x^VSLCFG\n") + vs, err := CheckMRefs(dir) + if err != nil { + t.Fatalf("CheckMRefs: %v", err) + } + if len(vs) != 0 { + t.Errorf("dist/ must be skipped, got %v", vs) + } +} + +// --- Check (integration of layer + checks) ---------------------------------- + +func TestCheckVLayerPassesTrivially(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "v-contract.json"), `{"layer":"v"}`) + // Even with a VSL ref present, a v-layer repo passes G1 (v → m allowed). + writeFile(t, filepath.Join(dir, "src", "VSLX.m"), " do y^VSLCFG\n") + rep, err := Check(dir, "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if rep.Layer != LayerV { + t.Errorf("layer: got %q, want v", rep.Layer) + } + if len(rep.Violations) != 0 { + t.Errorf("v-layer must pass G1, got %v", rep.Violations) + } +} + +func TestCheckMLayerScansM(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "dist", "repo.meta.json"), `{"layer":"m"}`) + writeFile(t, filepath.Join(dir, "src", "STDX.m"), " set x=$$cfg^VSLCFG(1)\n") + rep, err := Check(dir, "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if !rep.CheckedM { + t.Error("expected CheckedM=true") + } + if len(rep.Violations) != 1 { + t.Errorf("expected 1 violation, got %v", rep.Violations) + } +} + +// --- Check, Go arm (live `go list`, stdlib-only temp module) ---------------- + +func TestCheckMLayerGoArmClean(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go toolchain not on PATH") + } + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "go.mod"), "module example.com/clean\n\ngo 1.26\n") + writeFile(t, filepath.Join(dir, "main.go"), + "package main\n\nimport \"fmt\"\n\nfunc main() { fmt.Println(\"hi\") }\n") + writeFile(t, filepath.Join(dir, "dist", "repo.meta.json"), `{"layer":"m"}`) + rep, err := Check(dir, "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if !rep.CheckedGo { + t.Error("expected CheckedGo=true when go.mod is present") + } + if len(rep.Violations) != 0 { + t.Errorf("stdlib-only module must be clean, got %v", rep.Violations) + } +} + +// --- helpers ----------------------------------------------------------------- + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if x == want { + return true + } + } + return false +} + +func count(xs []string, want string) int { + n := 0 + for _, x := range xs { + if x == want { + n++ + } + } + return n +} diff --git a/main.go b/main.go index 892ed6a..fe8f6ba 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ import ( "github.com/willabides/kongplete" "github.com/vista-cloud-dev/m-cli/clikit" + "github.com/vista-cloud-dev/m-cli/internal/arch" "github.com/vista-cloud-dev/m-cli/internal/config" "github.com/vista-cloud-dev/m-cli/internal/dispatch" "github.com/vista-cloud-dev/m-cli/internal/engine" @@ -52,6 +53,7 @@ type CLI struct { Coverage coverageCmd `cmd:"" help:"Line coverage over the engine (YDB view \"TRACE\" → LCOV)."` Watch watchCmd `cmd:"" help:"Re-run lint/fmt (and, with --run, tests) on M files as they change."` Vista vistaCmd `cmd:"" help:"Reach a live VistA via its m- driver (status / exec) — the driver-backed engine transport."` + Arch archCmd `cmd:"" help:"Check the m/v waterline — engine-neutral vs VistA-specific layer boundary."` // Dispatched namespaces (spec §2.2): each forwards to a sibling binary. // irissync owns the IRIS source axis; kids-vc owns the KIDS round-trip. @@ -960,6 +962,71 @@ func (lspCmd) Run(_ *clikit.Context) error { return nil } +// --- arch (the m/v waterline gate) ------------------------------------------- +// +// G1 — dependency-direction: dependency flows one way, v → m, never the +// reverse (docs/background/m-v-waterline-adr.md). The repo declares its layer +// in a committed meta artifact; `m arch check` asserts an m-layer repo's Go +// dependency closure carries no vista-cloud-dev/v-* module and its M source +// references no VSL* (v-layer) routine. A v-layer repo passes trivially. + +type archCmd struct { + Check archCheckCmd `cmd:"" help:"Run the G1 dependency-direction gate for this repo."` +} + +type archCheckCmd struct { + Root string `arg:"" optional:"" type:"path" help:"Repo root to check (default: .)."` + Layer string `help:"Override the declared layer (m|v); else read from dist/repo.meta.json or dist/v-contract.json."` +} + +func (c *archCheckCmd) Run(cc *clikit.Context) error { + root := c.Root + if root == "" { + root = "." + } + rep, err := arch.Check(root, c.Layer) + if err != nil { + return clikit.Fail(clikit.ExitUsage, "ARCH_LAYER", err.Error(), + `declare "layer": "m"|"v" in the repo meta, or pass --layer`) + } + + if err := cc.Result(rep, func() { + cc.Title("arch check") + var checks []string + if rep.CheckedGo { + checks = append(checks, "go-deps") + } + if rep.CheckedM { + checks = append(checks, "m-source") + } + if len(checks) == 0 { + checks = append(checks, "none (v-layer)") + } + cc.KV( + [2]string{"layer", cc.Accent(string(rep.Layer))}, + [2]string{"gate", "G1 dependency-direction"}, + [2]string{"checked", strings.Join(checks, ", ")}, + [2]string{"violations", fmt.Sprintf("%d", len(rep.Violations))}, + ) + for _, v := range rep.Violations { + fmt.Fprintf(cc.Stdout, " %s %s %s %s\n", + cc.Severity("error"), cc.Accent(v.Gate), v.Source, cc.Faint(v.Detail)) + } + if len(rep.Violations) == 0 { + fmt.Fprintln(cc.Stdout, cc.Success("waterline clean — no m → v dependency")) + } + }); err != nil { + return err + } + + if len(rep.Violations) > 0 { + return clikit.Fail(clikit.ExitCheck, "WATERLINE_VIOLATION", + fmt.Sprintf("%d m → v dependency violation(s)", len(rep.Violations)), + "the m layer must not depend on the v layer (v → m only)") + } + return nil +} + // --- version ----------------------------------------------------------------- type versionCmd struct{} diff --git a/repo.meta.json b/repo.meta.json new file mode 100644 index 0000000..ed975c8 --- /dev/null +++ b/repo.meta.json @@ -0,0 +1,9 @@ +{ + "id": "tool:m-cli", + "repo": "https://github.com/vista-cloud-dev/m-cli", + "role": "Cross-engine M toolchain — the `m` busybox (fmt/lint/lsp/test/coverage/watch/vista/arch)", + "language": ["go"], + "layer": "m", + "license": "AGPL-3.0", + "verification_commands": ["make lint", "make test", "make build", "./dist/m arch check ."] +}