diff --git a/changes/unreleased/repl-repository-commands.added.md b/changes/unreleased/repl-repository-commands.added.md
new file mode 100644
index 0000000000..f609238d59
--- /dev/null
+++ b/changes/unreleased/repl-repository-commands.added.md
@@ -0,0 +1,2 @@
+- **The REPL and the Jupyter kernel read and write a SysML v2 API repository with the OMG pilot kernel's four commands.** `%repo [ ]` shows or sets the server (seeded from `FLEXO_SYSMLV2_URL`, the token from `FLEXO_INTEROP_TOKEN`, never printed); `%projects` lists every project, paged to the end; `%load [--id=] [--name=] [--branch=] []` reads a project's model at a branch head into the session as notation and remembers where it came from; `%publish [-d] [--project=] [--branch=] ` writes the elements rooted in a session element as a new project or as a commit of what changed on the branch, reporting the commit id and the counts created, updated and deleted; `-d` includes derived properties. Any server speaking the standard API serves — the pilot's `SysML-v2-API-Services` without a token, Flexo MMS with one — and the kernel answers argument problems as `UsageError`.
+- **The standard SysML v2 API client is its own package.** `internal/translate/interop/sysmlapi` drives projects, branches, commits with `change` payloads, elements and roots with optional bearer authentication and follows a server's `Link: rel="next"` paging to the end; `internal/translate/interop/flexo` keeps Flexo's extras — Layer 1 Turtle, organizations, ETag pushes — over it, and `internal/translate/interop/modelsync` holds what `sysml -sync` and the REPL share.
diff --git a/cmd/sysml/repository.go b/cmd/sysml/repository.go
new file mode 100644
index 0000000000..026155fb77
--- /dev/null
+++ b/cmd/sysml/repository.go
@@ -0,0 +1,7 @@
+//go:build !sysml_prod && !sysml_nosync
+
+package main
+
+import (
+ _ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext/repository" // registers the repository REPL commands
+)
diff --git a/docs/guide/12-jupyter.md b/docs/guide/12-jupyter.md
index 8b0217c61d..e62ef0591a 100644
--- a/docs/guide/12-jupyter.md
+++ b/docs/guide/12-jupyter.md
@@ -249,6 +249,50 @@ The bounds the REPL takes from the environment (`OPENSYSML_MAX_STEPS`,
kernel, read when it starts: set them in the environment of the notebook server. See
[environment variables](../reference/environment.md).
+## Working with a repository
+
+The kernel round-trips models with a SysML v2 API server through the same four commands the
+OMG pilot's kernel has: `%repo` shows or sets the server, `%projects` lists its projects,
+`%load` reads a project's model into the session and `%publish` writes elements back as a new
+project or a commit. Any server speaking the standard API serves: the pilot's
+`SysML-v2-API-Services` wants no token and the default URL is its `http://localhost:9000`
+when `%repo` names it; Flexo MMS wants a bearer token and an organization. Set, in the
+environment of the notebook server:
+
+| Variable | For the pilot API server | For Flexo MMS |
+|---|---|---|
+| `FLEXO_SYSMLV2_URL` | `http://localhost:9000` | the SysML v2 API endpoint, by default `http://localhost:8083` |
+| `FLEXO_INTEROP_TOKEN` | unset | the bearer token; never put it in a cell |
+| `FLEXO_SYSMLV2_ORG` | unset | the organization, by default `sysmlv2` |
+| `FLEXO_ALLOW_PLAIN_HTTP` | unset on this machine | `1` to allow a plaintext `http://` server on another |
+
+Then, as the pilot's notebooks do:
+
+```text
+%repo http://localhost:9000
+%projects
+%load --name=Vehicles --branch=main
+part def Truck :> Vehicles::Car;
+%publish --project=Vehicles Truck
+%publish -d --project="Trucks only" --branch=main Truck
+```
+
+`%load` takes the project by name, by `--id` or by `--name`, the branch by name or id or the
+default when absent, and submits the model as a document, so the cells after it refer to the
+loaded names; the session remembers which project, branch and commit, and a `%publish` of
+what it loaded is a commit on that branch, not a second project. `%publish ` names the
+project after the element's own name unless `--project` says otherwise; a project of that name
+receives a commit of what changed against the branch head, reported as the commit id with the
+counts created, updated and deleted, and no project of that name is created. The commit stays
+within its root: what the branch holds under other roots is left in place, and elements are
+deleted only from a branch the session loaded or published, since only then has it seen them;
+what was left alone is reported. `-d` sends every
+derived property the exporter computes, as the pilot's `-d` does. An argument problem is a
+`UsageError`; a server that cannot be reached, a missing project or branch, or a refused commit a
+`CommandError` with the status and the server's message. A project name two projects share is
+refused naming both ids: use `--id`. The [REPL commands reference](../reference/repl-commands.md#working-with-a-repository)
+has each command's grammar.
+
## Troubleshooting
- **The kernel is not listed.** `jupyter kernelspec list` shows what the server sees; the
diff --git a/docs/reference/jupyter-kernel.md b/docs/reference/jupyter-kernel.md
index 300bb7b3f7..8073d19aab 100644
--- a/docs/reference/jupyter-kernel.md
+++ b/docs/reference/jupyter-kernel.md
@@ -144,6 +144,16 @@ kernel language is not `sysml` is refused. See
A `%quit` in a cell is noted — the notebook's shutdown ends the kernel — and an unknown
`%` command is an error (`UnknownCommand`), where the prompt would print guidance.
+A `%` command given wrong arguments is a `UsageError` whose traceback is the command's usage;
+a command that fails — a repository server that cannot be reached, a project that does not exist
+— is a `CommandError` with the message the prompt would print, the HTTP status and the server's
+message included.
+
+The repository commands `%repo`, `%projects`, `%load` and `%publish` address the SysML v2 API
+server `FLEXO_SYSMLV2_URL` names, with the bearer token `FLEXO_INTEROP_TOKEN` when the server wants
+one and Flexo's organization `FLEXO_SYSMLV2_ORG`, all read from the kernel's environment; a token
+is never echoed and no state is written. See [Working with a repository](../guide/12-jupyter.md#working-with-a-repository).
+
## Rich output
Output with a richer form than plain text is a `display_data` carrying that form beside
diff --git a/docs/reference/repl-commands.md b/docs/reference/repl-commands.md
index 60af868689..db4fe047c2 100644
--- a/docs/reference/repl-commands.md
+++ b/docs/reference/repl-commands.md
@@ -44,6 +44,7 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`).
| `%list` | List all declarations in current session |
| `%clear` | Clear session (reset all declarations) |
| `%load [--cells ] ...` | Submit the contents of files, directories or globs. A `.ipynb` notebook loads its code cells in order, each a source named ` cell `; the cells' `%` command lines and expressions are skipped and counted in the report, a cell tagged `skip-load` is skipped, and `--cells 1,3-5` or `--cells tag:` picks cells by position among the code cells or by tag ([Reusing another notebook](../guide/12-jupyter.md#reusing-another-notebook)) |
+| `%load [--id=] [--name=] [--branch=] []` | Load a repository project's model into the session: the project named, or given by `--id` or `--name`, at the branch named by `--branch` (name or id) or its default branch. The elements at the branch head are read as SysML v2 API JSON and submitted as notation, so later input refers to the loaded names; the session remembers the project, branch and commit, and a `%publish` of the loaded element commits on that branch. A name two projects share is refused naming both ids. A lone name that is a path on disk — a separator, a `.sysml`/`.kerml`/`.ipynb` extension, a wildcard, or a file that exists — loads the files; a project whose name reads as one is loaded by `--name` ([Working with a repository](#working-with-a-repository)) |
| `%print [name]` | Print the session model as SysML notation at the prompt, or only the named element and its body (`%print 'My Pkg'::Car`). Comments are kept, since the same writer `%save` writes notation with is used, and what is printed can be typed back in. Notation only: nothing about RDF is reported. Reading the model materializes nothing and leaves a debugging session running |
| `%save ` | Write the session model to a file: `.sysml` notation (comments preserved), `.ttl` RDF or `.json` the API's element form, which are [experimental](rdf-mapping.md#status-experimental) and reported as such on each save |
| `%import [as values] [map ] [format csv\|tsv\|json\|jsonl] [dry-run]` | Set the feature values a CSV, TSV, JSON or JSON Lines file assigns, one row per element: an `element` column names the element and each other column a feature, with a unit as `mass [kg]`. A declared value is replaced and an inherited one redefined; an empty cell leaves a value as it was. The file is checked whole first, and a row, value or unit the model refuses imports nothing. `map` names a JSON mapping file, `format` overrides the extension, and `dry-run` prints the values without changing the model. Same import as the CLI's [`-import`](cli.md#importing-data); see [Importing data into a model](../manual/importing-data.md) |
@@ -118,7 +119,15 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`).
| `%advance ` | Advance the runtime's simulation clock by `` seconds (`SI::s`), running every state event, action token, change-condition poll and do behavior that comes due, in due order. The clock is the session's, not one debugger's: an `%action` parked at `accept after` and a `%state` machine both move, and the report covers each. A state's do behavior whose action body waits (`do action poll { action wait accept after 3 [SI::s]; then … }`) is parked on the clock too, listed under `Waiting on the clock` and resumed when its instant comes; a transition that leaves the state first ends it, its wait leaving the clock. Two executors due at the same instant run in the order the scheduling policy picks — the one started last first under the default `reverse` — and the pick is a choice point, as is which of two regions' do behaviors due together acts first in a round ([Choice points](../guide/06-behavior.md)). Under `%engine check` there is no run to advance: `%advance` searches the behavior last checked again, up to that instant as its horizon |
| **Control** | |
| `%quit` | Exit the REPL |
-| `Tab` | Complete meta commands, symbol names (after `%print`, `%instantiate`, `%features` …; a name that needs quoting is offered in quotes, `Q::'the ra` completing to `Q::'the rack'`), object references where a command takes one (`#` offers the ids there are; `car.` offers the object-holding features of `car` — the same ones a path may pass through — a multi-valued one as `car.wheels[1]`, `car.wheels[2]` …; completing reads and materializes nothing, so a part no command has reached yet is offered by type, and only the elements reading it would hold: those the features subsetting it contribute, then anonymous ones up to its lower bound — so an optional part (`spare : Wheel[0..1]`) or an abstract one, which hold only what subsets them, is offered only once something does), the form after `%render ` and the palette after `%render dot`, and file paths after `%load` and `%save` |
+
+**Repository** — the SysML v2 API server the session talks to ([Working with a repository](#working-with-a-repository)):
+
+| Command | Description |
+|---------|-------------|
+| `%repo [ ]` | Show or set the base URL of the SysML v2 API server the repository commands address, seeded from `FLEXO_SYSMLV2_URL` (default `http://localhost:8083`). A bearer token, when the server wants one, is read from `FLEXO_INTEROP_TOKEN`; it is never printed, and nothing is persisted. A plaintext `http://` URL off this machine is refused unless `FLEXO_ALLOW_PLAIN_HTTP=1` |
+| `%projects` | List the repository's projects, ` ()` one per line, every page of them; `no projects` for an empty repository |
+| `%publish [-d] [--project=] [--branch=] ` | Publish the elements rooted in `` — a qualified name resolved in the session — as SysML v2 API JSON: when no project is named after it (or after `--project`) a project is created and its first commit made, otherwise a commit on the branch `--branch` names, or the default branch, holding what differs from the branch head under that root — elements under other roots are left in place, and elements are deleted only from a branch the session loaded or published: the commit id and the counts of elements created, updated and deleted are reported, with what was left in place, `nothing to publish` when none differ. An option given twice is a usage error. `-d` sends every derived property the exporter computes; without it the ones the reader recomputes are left out. A project with the same name twice is refused naming both ids |
+| `Tab` | Complete meta commands, symbol names (after `%print`, `%instantiate`, `%features` …; a name that needs quoting is offered in quotes, `Q::'the ra` completing to `Q::'the rack'`), object references where a command takes one (`#` offers the ids there are; `car.` offers the object-holding features of `car` — the same ones a path may pass through — a multi-valued one as `car.wheels[1]`, `car.wheels[2]` …; completing reads and materializes nothing, so a part no command has reached yet is offered by type, and only the elements reading it would hold: those the features subsetting it contribute, then anonymous ones up to its lower bound — so an optional part (`spare : Wheel[0..1]`) or an abstract one, which hold only what subsets them, is offered only once something does), the form after `%render ` and the palette after `%render dot`, file paths after `%load` and `%save`, and the flags of `%load` and `%publish` with the repository's project names after `--name=`, `--id=` and `--project=` and after `%load` |
| `Ctrl-C` | Discard the line being typed, and any continuation buffered before it; at an empty `sysml>` prompt, exit REPL |
| `Ctrl-D` | Exit REPL |
@@ -625,3 +634,28 @@ pins no positions either, so `DiagramLayout` geometry rides along as `#` comment
([the D2 form](../project/view-rendering-forms.md#d2)).
To render a view outside the prompt, use [`sysml -render`](cli.md#rendering-a-view).
+
+## Working with a repository
+
+`%repo`, `%projects`, `%load` and `%publish` address a SysML v2 API server — the OMG pilot's
+`SysML-v2-API-Services` or Flexo MMS — through the standard `/projects`, `/branches`, `/commits`
+and `/elements` resources, as the CLI's [`-sync`](cli.md#reading-and-pushing-a-repository-branch)
+does. The server is `FLEXO_SYSMLV2_URL` until `%repo ` sets another for the session; a
+bearer token comes from `FLEXO_INTEROP_TOKEN` and is never shown, and Flexo's organization from
+`FLEXO_SYSMLV2_ORG` where a server has one. Nothing is written to disk.
+
+```text
+sysml> %repo http://localhost:9000
+API base path: http://localhost:9000
+sysml> %projects
+Vehicles (9c0e2b3a-…)
+sysml> %load Vehicles
+loaded project Vehicles (9c0e2b3a-…) at branch main (5d1f…), commit 7a2c…: 13 elements
+sysml> part def Truck :> Vehicles::Car;
+sysml> %publish --project=Vehicles Truck
+commit 0b4e… on branch main (5d1f…) of Vehicles (9c0e2b3a-…): 3 created, 0 updated, 0 deleted
+```
+
+A server that cannot be reached, a project or branch that does not exist, and a commit the server
+refuses are reported with the status and the server's message; a syntax or argument problem with
+the command's usage.
diff --git a/internal/frontend/jupyter/replengine.go b/internal/frontend/jupyter/replengine.go
index 9e69869f9c..80c27b9edf 100644
--- a/internal/frontend/jupyter/replengine.go
+++ b/internal/frontend/jupyter/replengine.go
@@ -123,6 +123,10 @@ func (e *REPLEngine) meta(line string, out Output) error {
}
lines, quit, err := e.session.RunMeta(line)
if err != nil {
+ var usage *repl.UsageError
+ if errors.As(err, &usage) {
+ return execError("UsageError", usage.Error(), usage.Lines)
+ }
name := "CommandError"
if errors.Is(err, runtime.ErrInterrupted) {
name = "KeyboardInterrupt"
diff --git a/internal/frontend/jupyter/repository_test.go b/internal/frontend/jupyter/repository_test.go
new file mode 100644
index 0000000000..b8ed822f55
--- /dev/null
+++ b/internal/frontend/jupyter/repository_test.go
@@ -0,0 +1,48 @@
+package jupyter
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// TestRepositoryCommandsRunThroughTheKernel drives %repo and %projects against
+// a fake SysML v2 API server: the listing streams as text, an argument problem
+// is a UsageError, and a server failure a CommandError naming the status.
+func TestRepositoryCommandsRunThroughTheKernel(t *testing.T) {
+ var projects []map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/projects" {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":"the database is away"}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(projects)
+ }))
+ defer server.Close()
+ t.Setenv("FLEXO_SYSMLV2_URL", server.URL)
+ t.Setenv("FLEXO_INTEROP_TOKEN", "")
+
+ e := newEngine(t)
+ if got := mustRun(t, e, "%repo").text(); got != "stdout: API base path: "+server.URL+"\n" {
+ t.Errorf("%%repo streamed %q", got)
+ }
+ if got := mustRun(t, e, "%projects").text(); got != "stdout: no projects\n" {
+ t.Errorf("%%projects over an empty repository streamed %q", got)
+ }
+ projects = []map[string]any{{"@id": "p-1", "name": "Rover", "defaultBranch": map[string]any{"@id": "b-1"}}}
+ if got := mustRun(t, e, "%projects").text(); got != "stdout: Rover (p-1)\n" {
+ t.Errorf("%%projects streamed %q", got)
+ }
+
+ if failure := mustFail(t, e, "%projects now"); failure.Name != "UsageError" || !strings.HasPrefix(failure.Value, "usage: %projects") {
+ t.Errorf("%%projects now: %s: %s", failure.Name, failure.Value)
+ }
+ failure := mustFail(t, e, "%load --id=p-1")
+ if failure.Name != "CommandError" || !strings.Contains(failure.Value, "Internal Server Error") || !strings.Contains(failure.Value, "the database is away") {
+ t.Errorf("a server failure: %s: %s", failure.Name, failure.Value)
+ }
+}
diff --git a/internal/frontend/repl/complete.go b/internal/frontend/repl/complete.go
index 0169ae1e1e..17952da087 100644
--- a/internal/frontend/repl/complete.go
+++ b/internal/frontend/repl/complete.go
@@ -48,6 +48,11 @@ func (s *Session) Complete(line string, pos int) Completion {
if word := lastField(head); strings.HasPrefix(word, "%") && word == command {
return completion(word, matchingPrefix(metaCommands(), word))
}
+ if command == "%load" || command == "%publish" {
+ if candidates, ok := s.repositoryCompletions(command, lastField(head)); ok {
+ return completion(lastField(head), candidates)
+ }
+ }
if command == "%load" || command == "%save" {
word := lastField(head)
return completion(word, pathCompletions(word))
diff --git a/internal/frontend/repl/fakeapi_test.go b/internal/frontend/repl/fakeapi_test.go
new file mode 100644
index 0000000000..9ad64b7225
--- /dev/null
+++ b/internal/frontend/repl/fakeapi_test.go
@@ -0,0 +1,331 @@
+package repl
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// fakeAPI is an in-memory SysML v2 API server speaking the standard resources
+// the pilot's does: projects, branches, commits with change payloads, elements
+// and roots, paged by a Link rel="next" header. No token, no Layer 1.
+type fakeAPI struct {
+ mu sync.Mutex
+ server *httptest.Server
+ projects map[string]*fakeProject
+ order []string // project ids in creation order
+ pageSize int // elements and projects per page
+ nextID int
+ // conflict, when set, answers the next commit with 409 and this message.
+ conflict string
+ requests []string
+}
+
+type fakeProject struct {
+ id, name string
+ branches map[string]*fakeBranch
+ commits map[string]map[string]json.RawMessage // commit id → element id → element
+ defaults string
+}
+
+type fakeBranch struct {
+ id, name, head string
+}
+
+func newFakeAPI() *fakeAPI {
+ f := &fakeAPI{projects: map[string]*fakeProject{}, pageSize: 2}
+ f.server = httptest.NewServer(http.HandlerFunc(f.serve))
+ return f
+}
+
+func (f *fakeAPI) Close() { f.server.Close() }
+
+func (f *fakeAPI) URL() string { return f.server.URL }
+
+func (f *fakeAPI) id(kind string) string {
+ f.nextID++
+ return fmt.Sprintf("%s-%04d", kind, f.nextID)
+}
+
+// addProject creates an empty project with a default branch, as the API's POST
+// /projects does, and returns it.
+func (f *fakeAPI) addProject(name string) *fakeProject {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.createProject(name, "main")
+}
+
+func (f *fakeAPI) createProject(name, branch string) *fakeProject {
+ p := &fakeProject{id: f.id("project"), name: name, branches: map[string]*fakeBranch{}, commits: map[string]map[string]json.RawMessage{}}
+ b := &fakeBranch{id: f.id("branch"), name: branch}
+ p.branches[b.id] = b
+ p.defaults = b.id
+ f.projects[p.id] = p
+ f.order = append(f.order, p.id)
+ return p
+}
+
+// addBranch gives a project a branch at the head of its default branch.
+func (f *fakeAPI) addBranch(project, name string) *fakeBranch {
+ p := f.projects[project]
+ b := &fakeBranch{id: f.id("branch"), name: name, head: p.branches[p.defaults].head}
+ p.branches[b.id] = b
+ return b
+}
+
+func (f *fakeAPI) projectJSON(p *fakeProject) map[string]any {
+ return map[string]any{"@id": p.id, "@type": "Project", "name": p.name, "defaultBranch": map[string]any{"@id": p.defaults}}
+}
+
+func (f *fakeAPI) branchJSON(b *fakeBranch) map[string]any {
+ out := map[string]any{"@id": b.id, "@type": "Branch", "name": b.name, "head": nil}
+ if b.head != "" {
+ out["head"] = map[string]any{"@id": b.head, "@type": "Commit"}
+ }
+ return out
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+func (f *fakeAPI) fail(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]any{"error": msg})
+}
+
+// page answers one page of items, linking to the next when there is one.
+func (f *fakeAPI) page(w http.ResponseWriter, r *http.Request, items []any, idOf func(any) string) {
+ after := r.URL.Query().Get("page[after]")
+ if after == "" {
+ after = r.URL.Query().Get("pageAfter")
+ }
+ start := 0
+ if after != "" {
+ for i, item := range items {
+ if idOf(item) == after {
+ start = i + 1
+ }
+ }
+ }
+ end := start + f.pageSize
+ if end > len(items) {
+ end = len(items)
+ }
+ if end < len(items) {
+ next := *r.URL
+ q := next.Query()
+ q.Set("page[after]", idOf(items[end-1]))
+ q.Set("page[size]", strconv.Itoa(f.pageSize))
+ next.RawQuery = q.Encode()
+ w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, next.String()))
+ }
+ writeJSON(w, http.StatusOK, items[start:end])
+}
+
+func (f *fakeAPI) serve(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.requests = append(f.requests, r.Method+" "+r.URL.Path)
+ if r.Header.Get("Authorization") != "" {
+ f.fail(w, http.StatusBadRequest, "this server takes no token")
+ return
+ }
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ if parts[0] != "projects" {
+ f.fail(w, http.StatusNotFound, "no such resource")
+ return
+ }
+ if len(parts) == 1 {
+ switch r.Method {
+ case http.MethodGet:
+ items := make([]any, 0, len(f.order))
+ for _, id := range f.order {
+ items = append(items, f.projectJSON(f.projects[id]))
+ }
+ f.page(w, r, items, func(v any) string { return v.(map[string]any)["@id"].(string) })
+ case http.MethodPost:
+ var req struct {
+ Name string `json:"name"`
+ DefaultBranch *struct {
+ Name string `json:"name"`
+ } `json:"defaultBranch"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
+ f.fail(w, http.StatusBadRequest, "a project needs a name")
+ return
+ }
+ branch := "main"
+ if req.DefaultBranch != nil && req.DefaultBranch.Name != "" {
+ branch = req.DefaultBranch.Name
+ }
+ writeJSON(w, http.StatusOK, f.projectJSON(f.createProject(req.Name, branch)))
+ default:
+ f.fail(w, http.StatusMethodNotAllowed, r.Method)
+ }
+ return
+ }
+ p, ok := f.projects[parts[1]]
+ if !ok {
+ f.fail(w, http.StatusNotFound, "project "+parts[1]+" not found")
+ return
+ }
+ if len(parts) == 2 {
+ writeJSON(w, http.StatusOK, f.projectJSON(p))
+ return
+ }
+ switch parts[2] {
+ case "branches":
+ if len(parts) == 3 {
+ ids := make([]string, 0, len(p.branches))
+ for id := range p.branches {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ items := make([]any, 0, len(ids))
+ for _, id := range ids {
+ items = append(items, f.branchJSON(p.branches[id]))
+ }
+ f.page(w, r, items, func(v any) string { return v.(map[string]any)["@id"].(string) })
+ return
+ }
+ b, ok := p.branches[parts[3]]
+ if !ok {
+ f.fail(w, http.StatusNotFound, "branch "+parts[3]+" not found")
+ return
+ }
+ writeJSON(w, http.StatusOK, f.branchJSON(b))
+ case "commits":
+ f.serveCommits(w, r, p, parts[3:])
+ default:
+ f.fail(w, http.StatusNotFound, "no such resource")
+ }
+}
+
+func (f *fakeAPI) serveCommits(w http.ResponseWriter, r *http.Request, p *fakeProject, rest []string) {
+ if len(rest) == 0 {
+ if r.Method != http.MethodPost {
+ f.fail(w, http.StatusMethodNotAllowed, r.Method)
+ return
+ }
+ if f.conflict != "" {
+ msg := f.conflict
+ f.conflict = ""
+ f.fail(w, http.StatusConflict, msg)
+ return
+ }
+ b, ok := p.branches[r.URL.Query().Get("branchId")]
+ if !ok {
+ b = p.branches[p.defaults]
+ }
+ var req struct {
+ Change []struct {
+ Identity struct {
+ ID string `json:"@id"`
+ } `json:"identity"`
+ Payload json.RawMessage `json:"payload"`
+ } `json:"change"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ f.fail(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ elements := map[string]json.RawMessage{}
+ for id, element := range p.commits[b.head] {
+ elements[id] = element
+ }
+ for _, change := range req.Change {
+ if string(change.Payload) == "null" || len(change.Payload) == 0 {
+ delete(elements, change.Identity.ID)
+ continue
+ }
+ elements[change.Identity.ID] = change.Payload
+ }
+ commit := f.id("commit")
+ p.commits[commit] = elements
+ b.head = commit
+ writeJSON(w, http.StatusOK, map[string]any{"@id": commit, "@type": "Commit"})
+ return
+ }
+ elements, ok := p.commits[rest[0]]
+ if !ok {
+ f.fail(w, http.StatusNotFound, "commit "+rest[0]+" not found")
+ return
+ }
+ ids := make([]string, 0, len(elements))
+ for id := range elements {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ if len(rest) < 2 {
+ f.fail(w, http.StatusNotFound, "no such resource")
+ return
+ }
+ switch rest[1] {
+ case "elements":
+ if len(rest) == 3 {
+ element, ok := elements[rest[2]]
+ if !ok {
+ f.fail(w, http.StatusNotFound, "element "+rest[2]+" not found")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write(element)
+ return
+ }
+ items := make([]any, 0, len(ids))
+ for _, id := range ids {
+ items = append(items, elements[id])
+ }
+ f.page(w, r, items, func(v any) string {
+ var e struct {
+ ID string `json:"@id"`
+ }
+ _ = json.Unmarshal(v.(json.RawMessage), &e)
+ return e.ID
+ })
+ case "roots":
+ var roots []json.RawMessage
+ for _, id := range ids {
+ var e struct {
+ Owner *struct {
+ ID string `json:"@id"`
+ } `json:"owner"`
+ }
+ _ = json.Unmarshal(elements[id], &e)
+ if e.Owner == nil {
+ roots = append(roots, elements[id])
+ }
+ }
+ writeJSON(w, http.StatusOK, roots)
+ default:
+ f.fail(w, http.StatusNotFound, "no such resource")
+ }
+}
+
+// elementCount is how many elements a project's branch head holds.
+func (f *fakeAPI) elementCount(project, branch string) int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ p := f.projects[project]
+ return len(p.commits[p.branches[branch].head])
+}
+
+// heldBytes is how many elements a project's branch head holds, and how many
+// bytes of JSON they amount to.
+func (f *fakeAPI) heldBytes(project, branch string) (count, size int) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ p := f.projects[project]
+ for _, element := range p.commits[p.branches[branch].head] {
+ count++
+ size += len(element)
+ }
+ return count, size
+}
diff --git a/internal/frontend/repl/meta.go b/internal/frontend/repl/meta.go
index 40d9dcf835..a524bc9b87 100644
--- a/internal/frontend/repl/meta.go
+++ b/internal/frontend/repl/meta.go
@@ -138,6 +138,7 @@ const (
timeLabel = " Time: "
groupSession = "Session:"
+ groupRepository = "Repository:"
groupSettings = "Settings:"
groupEngines = "Analysis engines:"
groupChecks = "Checking every schedule:"
@@ -186,13 +187,18 @@ var metaCommandTable = []metaCommand{
{name: "%documents", group: groupSession, desc: "list the model's documents by the names %render-document reads"},
{name: "%views", group: groupSession, args: "[diagrams] [...]", desc: "list the model's views and their kinds by the names %render reads; diagrams keeps the graph-shaped ones"},
{name: "%clear", group: groupSession, desc: "reset the session"},
- {name: "%load", group: groupSession, args: "[--cells ] ...", desc: "submit the contents of files, directories or globs; a .ipynb notebook's code cells declare, its % commands and expressions skipped, --cells 1,3-5 or tag: picking cells"},
+ {name: "%load", group: groupSession, args: "[--cells ] ... | [--id=] [--name=] [--branch=] []", desc: "submit the contents of files, directories or globs (a .ipynb notebook's code cells declare, --cells 1,3-5 or tag: picking cells), or load a repository project's model: named, by --id or by --name, at a branch or its default; a name that is a path on disk loads the files",
+ part: repositoryLinked, partArgs: "[--cells ] ...", partDesc: "submit the contents of files, directories or globs; a .ipynb notebook's code cells declare, its % commands and expressions skipped, --cells 1,3-5 or tag: picking cells"},
{name: "%print", group: groupSession, args: "[name]", desc: "print the session model as SysML notation, or just the named element", linked: notationLinked},
{name: "%save", group: groupSession, args: "", desc: "write the session model to a file (.sysml notation, or .ttl/.json RDF — experimental)", linked: notationLinked},
{name: cmdQuery, group: groupSession, args: "", desc: "identify model elements using OSLC Query text", linked: positionalLinked},
{name: "%quit", group: groupSession, desc: "exit the REPL (also %exit)"},
{name: "%exit", group: groupSession, desc: "exit the REPL", alias: true},
+ {name: "%repo", group: groupRepository, args: "[ ]", desc: "show or set the base URL of the SysML v2 API server the repository commands address, seeded from FLEXO_SYSMLV2_URL; a bearer token, when the server wants one, is read from FLEXO_INTEROP_TOKEN", linked: repositoryLinked},
+ {name: "%projects", group: groupRepository, desc: "list the repository's projects, name and id", linked: repositoryLinked},
+ {name: "%publish", group: groupRepository, args: "[-d] [--project=] [--branch=] ", desc: "publish the elements rooted in to the repository: a new project named after it (or --project) when none is, else a new commit on the branch holding what changed; -d includes the derived properties", linked: repositoryLinked},
+
{name: "%verbosity", group: groupSettings, args: "[level]", desc: "show or set output level: quiet, normal or debug"},
{name: "%trace", group: groupSettings, args: "[on|off]", desc: "show or set execution tracing (evaluation, calc, action and state steps)"},
{name: "%strict", group: groupSettings, args: "[on|off]", desc: "show or set strict conformance: report notation no SysML v2 production admits as an error"},
@@ -343,7 +349,7 @@ func (s *Session) runMeta(line string) (out []string, quit bool, err error) {
return []string{unknownCommandLine(fields[0])}, false, nil
}
for _, run := range []func([]string, string) (metaResult, bool){
- s.metaSessionCommand, s.metaModelCommand, s.metaDebugCommand,
+ s.metaRepositoryCommand, s.metaSessionCommand, s.metaModelCommand, s.metaDebugCommand,
} {
if res, ok := run(fields, line); ok {
return res.out, res.quit, res.err
@@ -391,7 +397,7 @@ func (s *Session) metaSessionCommand(fields []string, line string) (metaResult,
return metaOut(nil, false, perr), true
}
if len(paths) == 0 {
- return metaOut([]string{"usage: %load [--cells >] ..."}, false, nil), true
+ return metaOut([]string{usageLoadPath}, false, nil), true
}
lines, lerr := s.loadPaths(paths, opts)
if lerr != nil {
diff --git a/internal/frontend/repl/notebook.go b/internal/frontend/repl/notebook.go
index 89e35f3cd3..8a2b42d557 100644
--- a/internal/frontend/repl/notebook.go
+++ b/internal/frontend/repl/notebook.go
@@ -33,7 +33,11 @@ func parseLoadArgs(args []string) ([]string, loadOptions, error) {
}
name, value, inline := strings.Cut(arg, "=")
if name != "--cells" {
- return nil, loadOptions{}, fmt.Errorf("unknown option %s; %%load takes --cells >", name)
+ lines := []string{usageLoadPath}
+ if repositoryLinked() {
+ lines = append(lines, usageLoadRepo)
+ }
+ return nil, loadOptions{}, &UsageError{Lines: append(lines, fmt.Sprintf("unknown option %s; %%load takes --cells >", name))}
}
if cells {
return nil, loadOptions{}, errors.New("--cells given twice; one selection picks the cells of every notebook named")
diff --git a/internal/frontend/repl/notebook_test.go b/internal/frontend/repl/notebook_test.go
index addd143931..a908397575 100644
--- a/internal/frontend/repl/notebook_test.go
+++ b/internal/frontend/repl/notebook_test.go
@@ -2,6 +2,7 @@ package repl
import (
"encoding/json"
+ "errors"
"os"
"path/filepath"
"strings"
@@ -167,7 +168,7 @@ func TestLoadRefusesWhatItCannotRead(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := NewSession()
_, _, err := s.RunMeta(tt.line)
- if err == nil || !strings.Contains(err.Error(), tt.want) {
+ if err == nil || !strings.Contains(errorText(err), tt.want) {
t.Errorf("%s: err = %v, want %q", tt.line, err, tt.want)
}
if len(s.List()) != 0 {
@@ -415,3 +416,12 @@ func TestANotebookInTheWorkingDirectoryLoadsByName(t *testing.T) {
t.Errorf("report:\n%s", out)
}
}
+
+// errorText is an error's message, every line of a usage error's.
+func errorText(err error) string {
+ var usage *UsageError
+ if errors.As(err, &usage) {
+ return strings.Join(usage.Lines, "\n")
+ }
+ return err.Error()
+}
diff --git a/internal/frontend/repl/replext/all/all.go b/internal/frontend/repl/replext/all/all.go
index ac0a127abc..9690c27474 100644
--- a/internal/frontend/repl/replext/all/all.go
+++ b/internal/frontend/repl/replext/all/all.go
@@ -7,4 +7,5 @@ import (
_ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext/instancegraph" // registers the instance-graph REPL commands
_ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext/notation" // registers the notation REPL commands
_ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext/positional" // registers the positional REPL commands
+ _ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext/repository" // registers the repository REPL commands
)
diff --git a/internal/frontend/repl/replext/replext.go b/internal/frontend/repl/replext/replext.go
index 79a67701bf..8bc024294c 100644
--- a/internal/frontend/repl/replext/replext.go
+++ b/internal/frontend/repl/replext/replext.go
@@ -3,6 +3,8 @@
package replext
import (
+ "context"
+
"github.com/Open-MBEE/OpenSysML/internal/doc/docrender"
"github.com/Open-MBEE/OpenSysML/internal/exec/runtime"
"github.com/Open-MBEE/OpenSysML/internal/semantic/symbols"
@@ -95,3 +97,91 @@ func Positional() PositionalNamer { return positional }
// Drawer is the registered diagram drawer, nil when none is linked.
func Drawer() docrender.DiagramDrawer { return drawer }
+
+// ProjectInfo names one project of a repository.
+type ProjectInfo struct {
+ ID string
+ Name string
+}
+
+// ProjectState is the branch a session's model was loaded from or published
+// to: what a later publish commits on top of. LastSeenCommit is the head the
+// session last read or wrote.
+type ProjectState struct {
+ Base string // the server the state was read from
+ ProjectID string
+ ProjectName string
+ Branch string
+ BranchName string
+ LastSeenCommit string
+}
+
+// LoadRequest picks the project and branch %load reads: a project by id or
+// else by name, a branch by name or id or else the project's default.
+type LoadRequest struct {
+ ProjectID string
+ Name string
+ Branch string
+}
+
+// LoadResult is a loaded branch: its notation, ready to submit, and where it
+// came from.
+type LoadResult struct {
+ State ProjectState
+ Notation []byte
+ Warnings []string
+}
+
+// PublishRequest publishes the elements rooted in one element of the session
+// model: Root is its qualified name, Project the project name (the element's
+// own name when empty), Branch the branch name (the default when empty), State
+// the branch the session loaded the project from, if it did.
+type PublishRequest struct {
+ Origin string
+ Source []byte
+ Root string
+ Project string
+ Branch string
+ Derived bool
+ State *ProjectState
+}
+
+// PublishResult is what a publish did: the project and branch it wrote, the
+// commit it made (empty when the branch already agreed), and how many elements
+// it created, updated and deleted. Created reports a project made anew.
+type PublishResult struct {
+ State ProjectState
+ Commit string
+ Created, Updated, Deleted int
+ NewProject bool
+ Notes []string
+}
+
+// Repository is what %repo, %projects, %load and %publish drive: a SysML v2 API
+// server addressed by base URL. Commands that reach the network take the base
+// URL every time, as %repo may change it between them.
+type Repository interface {
+ // DefaultURL is the base URL a session starts with, from the environment.
+ DefaultURL() string
+ // CheckURL refuses a base URL the transport policy does not allow.
+ CheckURL(base string) error
+ // Projects lists every project, paged through completely.
+ Projects(ctx context.Context, base string) ([]ProjectInfo, error)
+ // Load reads a branch head as notation.
+ Load(ctx context.Context, base string, req LoadRequest) (*LoadResult, error)
+ // Publish writes the elements rooted in one element as a project or a commit.
+ Publish(ctx context.Context, base string, req PublishRequest) (*PublishResult, error)
+}
+
+var repository Repository
+
+// RegisterRepository installs the repository the %repo family of commands drives.
+func RegisterRepository(r Repository) {
+ if repository != nil {
+ panic("replext: repository registered twice")
+ }
+ repository = r
+}
+
+// Repo returns the registered repository, nil when no binary linked one.
+func Repo() Repository { return repository }
diff --git a/internal/frontend/repl/replext/repository/confine_test.go b/internal/frontend/repl/replext/repository/confine_test.go
new file mode 100644
index 0000000000..7d278df035
--- /dev/null
+++ b/internal/frontend/repl/replext/repository/confine_test.go
@@ -0,0 +1,78 @@
+package repository
+
+import (
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+func element(prefix, id string) rdf.Term { return rdf.IRI(rdf.Element + prefix + id) }
+
+func owned(graph *rdf.Graph, prefix, owner, id string) {
+ graph.AddTriple(rdf.Triple{Subject: element(prefix, id), Predicate: rdf.IRI(rdf.SysML + "owner"), Object: element(prefix, owner)})
+}
+
+func named(graph *rdf.Graph, prefix, id string) {
+ graph.AddTriple(rdf.Triple{Subject: element(prefix, id), Predicate: rdf.IRI(rdf.SysML + "name"), Object: rdf.String(id)})
+}
+
+// TestConfineFindsTheRootUnderAnyQualifier: a branch whose ids carry a scope
+// qualifier still has its root found, so what was removed under it is
+// deleted, and what lies under other roots is left alone — on a tracked
+// branch; on an unloaded one, nothing is deleted at all.
+func TestConfineFindsTheRootUnderAnyQualifier(t *testing.T) {
+ for _, qualifier := range []string{"", "org.project:"} {
+ cut := rdf.NewGraph()
+ named(cut, "", "Vehicles")
+ owned(cut, "", "Vehicles", "Wheel")
+ remote := rdf.NewGraph()
+ named(remote, qualifier, "Vehicles")
+ owned(remote, qualifier, "Vehicles", "Wheel")
+ owned(remote, qualifier, "Vehicles", "Car")
+ owned(remote, qualifier, "Car", "wheels")
+ named(remote, qualifier, "Spare")
+ owned(remote, qualifier, "Spare", "Boat")
+ changes := func() *reposync.ChangeSet {
+ return &reposync.ChangeSet{Changes: []reposync.Change{
+ {Kind: reposync.KindUpdate, ID: "Wheel"},
+ {Kind: reposync.KindDelete, ID: "Car"},
+ {Kind: reposync.KindDelete, ID: "wheels"},
+ {Kind: reposync.KindDelete, ID: "Spare"},
+ {Kind: reposync.KindConflict, ID: "Boat", Conflict: reposync.ConflictRepositoryChanged},
+ }}
+ }
+
+ tracked := changes()
+ result := &replext.PublishResult{}
+ confine(tracked, cut, remote, element("", "Vehicles"), true, "Vehicles", result)
+ if ids := idsOf(tracked); ids != "Wheel Car wheels" {
+ t.Errorf("%q tracked: kept %q, want Wheel Car wheels", qualifier, ids)
+ }
+ if len(result.Notes) != 1 {
+ t.Errorf("%q tracked: notes %q", qualifier, result.Notes)
+ }
+
+ unseen := changes()
+ result = &replext.PublishResult{}
+ confine(unseen, cut, remote, element("", "Vehicles"), false, "Vehicles", result)
+ if ids := idsOf(unseen); ids != "Wheel" {
+ t.Errorf("%q untracked: kept %q, want Wheel", qualifier, ids)
+ }
+ if len(result.Notes) != 2 {
+ t.Errorf("%q untracked: notes %q", qualifier, result.Notes)
+ }
+ }
+}
+
+func idsOf(set *reposync.ChangeSet) string {
+ out := ""
+ for i, change := range set.Changes {
+ if i > 0 {
+ out += " "
+ }
+ out += change.ID
+ }
+ return out
+}
diff --git a/internal/frontend/repl/replext/repository/repository.go b/internal/frontend/repl/replext/repository/repository.go
new file mode 100644
index 0000000000..6f26aa9e1a
--- /dev/null
+++ b/internal/frontend/repl/replext/repository/repository.go
@@ -0,0 +1,309 @@
+// Package repository links the %repo, %projects, %load and %publish commands to
+// a SysML v2 API server: any conformant one through the standard surface, with
+// a bearer token from the environment when the server wants one.
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/convert"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/modelsync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/sysmlapi"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+func init() { replext.RegisterRepository(repository{}) }
+
+type repository struct{}
+
+// NotFoundError is a project or branch the server does not have.
+type NotFoundError struct {
+ What string // "project" or "branch"
+ Name string
+}
+
+func (e *NotFoundError) Error() string { return fmt.Sprintf("%s %s doesn't exist", e.What, e.Name) }
+
+// NoHeadError is a branch no commit has reached, which has no elements to load.
+type NoHeadError struct {
+ Branch string
+}
+
+func (e *NoHeadError) Error() string { return fmt.Sprintf("branch %s has no head commit", e.Branch) }
+
+func (repository) DefaultURL() string { return sysmlapi.ConfigFromEnv().BaseURL }
+
+func (repository) CheckURL(base string) error { return sysmlapi.CheckURL(base) }
+
+func client(base string) *sysmlapi.Client {
+ cfg := sysmlapi.ConfigFromEnv()
+ cfg.BaseURL = base
+ return sysmlapi.New(cfg)
+}
+
+func (repository) Projects(ctx context.Context, base string) ([]replext.ProjectInfo, error) {
+ projects, err := client(base).Projects(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]replext.ProjectInfo, 0, len(projects))
+ for _, p := range projects {
+ out = append(out, replext.ProjectInfo{ID: p.ID, Name: p.Name})
+ }
+ return out, nil
+}
+
+// findProject resolves a project by id, else by name; a name several projects
+// share is refused naming them all.
+func findProject(ctx context.Context, c *sysmlapi.Client, id, name string) (sysmlapi.Project, error) {
+ if id != "" {
+ project, err := c.Project(ctx, id)
+ if sysmlapi.Status(err) == http.StatusNotFound {
+ return sysmlapi.Project{}, &NotFoundError{What: "project", Name: id}
+ }
+ return project, err
+ }
+ projects, err := c.Projects(ctx)
+ if err != nil {
+ return sysmlapi.Project{}, err
+ }
+ var matches []sysmlapi.Project
+ for _, p := range projects {
+ if p.Name == name {
+ matches = append(matches, p)
+ }
+ }
+ switch len(matches) {
+ case 0:
+ return sysmlapi.Project{}, &NotFoundError{What: "project", Name: name}
+ case 1:
+ return matches[0], nil
+ }
+ ids := make([]string, 0, len(matches))
+ for _, p := range matches {
+ ids = append(ids, p.ID)
+ }
+ return sysmlapi.Project{}, &modelsync.AmbiguousNameError{Name: name, IDs: ids}
+}
+
+// findBranch resolves a branch by name or id, or the project's default branch
+// when none is named.
+func findBranch(ctx context.Context, c *sysmlapi.Client, project sysmlapi.Project, nameOrID string) (sysmlapi.Branch, error) {
+ branches, err := c.Branches(ctx, project.ID)
+ if err != nil {
+ return sysmlapi.Branch{}, err
+ }
+ want := nameOrID
+ if want == "" {
+ want = project.DefaultBranch.ID
+ }
+ for _, b := range branches {
+ if b.ID == want {
+ return b, nil
+ }
+ }
+ for _, b := range branches {
+ if nameOrID != "" && b.Name == nameOrID {
+ return b, nil
+ }
+ }
+ if nameOrID == "" {
+ if want == "" && len(branches) == 1 {
+ return branches[0], nil
+ }
+ return sysmlapi.Branch{}, &NotFoundError{What: "default branch", Name: want}
+ }
+ return sysmlapi.Branch{}, &NotFoundError{What: "branch", Name: nameOrID}
+}
+
+func stateOf(base string, project sysmlapi.Project, branch sysmlapi.Branch) replext.ProjectState {
+ return replext.ProjectState{
+ Base: base,
+ ProjectID: project.ID, ProjectName: project.Name,
+ Branch: branch.ID, BranchName: branch.Name,
+ LastSeenCommit: branch.Head.ID,
+ }
+}
+
+func (repository) Load(ctx context.Context, base string, req replext.LoadRequest) (*replext.LoadResult, error) {
+ c := client(base)
+ project, err := findProject(ctx, c, req.ProjectID, req.Name)
+ if err != nil {
+ return nil, err
+ }
+ branch, err := findBranch(ctx, c, project, req.Branch)
+ if err != nil {
+ return nil, err
+ }
+ if branch.Head.ID == "" {
+ return nil, &NoHeadError{Branch: branch.Name}
+ }
+ repo := c.Repository(project.ID, branch.ID)
+ graph, err := repo.GraphAt(ctx, branch.Head.ID)
+ if err != nil {
+ return nil, err
+ }
+ result := &replext.LoadResult{State: stateOf(base, project, branch)}
+ scoped := modelsync.Scoped(graph, reposync.Scope{ProjectID: project.ID, Branch: branch.ID})
+ result.Notation, err = modelsync.Notation(scoped, func(w string) { result.Warnings = append(result.Warnings, w) })
+ if err != nil {
+ return nil, fmt.Errorf("the elements of %s could not be written as notation: %w", project.Name, err)
+ }
+ return result, nil
+}
+
+// simpleName is the name a root element publishes under when no project is
+// named: its declared name, else the last segment of its qualified name.
+func simpleName(graph *rdf.Graph, root rdf.Term, qualified string) string {
+ if name, ok := graph.Lexical(root, rdf.SysML+"declaredName"); ok && name != "" {
+ return name
+ }
+ if i := strings.LastIndex(qualified, "::"); i >= 0 {
+ return qualified[i+2:]
+ }
+ return qualified
+}
+
+func (repository) Publish(ctx context.Context, base string, req replext.PublishRequest) (*replext.PublishResult, error) {
+ whole, err := convert.SysMLToRDF(req.Origin, req.Source)
+ if err != nil {
+ return nil, err
+ }
+ cut, err := modelsync.Rooted(whole, req.Root)
+ if err != nil {
+ return nil, err
+ }
+ roots := modelsync.Roots(cut)
+ if len(roots) != 1 {
+ return nil, fmt.Errorf("%s does not cut to one root element", req.Root)
+ }
+ if !req.Derived {
+ cut = modelsync.WithoutDerived(cut)
+ }
+ name := req.Project
+ if name == "" {
+ name = simpleName(cut, roots[0], req.Root)
+ }
+
+ c := client(base)
+ result := &replext.PublishResult{}
+ // State from another server is no history of this one, whatever ids match.
+ known := req.State != nil && req.State.Base == base
+ project, err := findProject(ctx, c, "", name)
+ var missing *NotFoundError
+ switch {
+ case errors.As(err, &missing):
+ // The model has to diff cleanly before anything is created on the server.
+ if _, err := reposync.Diff(cut, rdf.NewGraph(), reposync.Options{Representation: sysmlapi.Representation{}}); err != nil {
+ return nil, err
+ }
+ if project, err = c.CreateProject(ctx, "", name, req.Branch); err != nil {
+ return nil, err
+ }
+ // The server fills in what the create left to it: the default branch.
+ if created, err := c.Project(ctx, project.ID); err == nil {
+ project = created
+ }
+ result.NewProject = true
+ case err != nil:
+ return nil, err
+ }
+ fail := func(err error) (*replext.PublishResult, error) {
+ if result.NewProject {
+ return nil, fmt.Errorf("project %s was created but holds no commit yet; publishing again commits into it: %w", projectLine(project), err)
+ }
+ return nil, err
+ }
+ want := req.Branch
+ if want == "" && known && req.State.ProjectID == project.ID {
+ want = req.State.Branch
+ }
+ branch, err := findBranch(ctx, c, project, want)
+ if err != nil {
+ return fail(err)
+ }
+
+ repo := c.Repository(project.ID, branch.ID)
+ scope := reposync.Scope{ProjectID: project.ID, Branch: branch.ID}
+ state := &reposync.State{ProjectID: project.ID, Branch: branch.ID}
+ tracked := known && req.State.ProjectID == project.ID && req.State.Branch == branch.ID
+ if tracked {
+ state.LastSeenCommit = req.State.LastSeenCommit
+ repo.Resume(state.LastSeenCommit)
+ }
+ opts := reposync.Options{Representation: sysmlapi.Representation{}, ConfirmDeletes: tracked}
+ if opts.Base, err = modelsync.Baseline(ctx, repo, state); err != nil {
+ return fail(err)
+ }
+ remote, err := repo.Graph(ctx)
+ if err != nil {
+ return fail(err)
+ }
+ set, err := reposync.Diff(modelsync.Scoped(cut, scope), remote, opts)
+ if err != nil {
+ return fail(err)
+ }
+ confine(set, cut, remote, roots[0], tracked, req.Root, result)
+ if err := set.Appliable(); err != nil {
+ return fail(fmt.Errorf("refused to publish: %w", err))
+ }
+ applied, err := modelsync.Apply(ctx, repo, set, state, "sysml %publish "+req.Root)
+ if err != nil {
+ return fail(err)
+ }
+ result.Commit = applied.Commit
+ result.Created, result.Updated, result.Deleted = applied.Created, applied.Updated, applied.Deleted
+ result.State = stateOf(base, project, branch)
+ result.State.LastSeenCommit = state.LastSeenCommit
+ return result, nil
+}
+
+func projectLine(project sysmlapi.Project) string {
+ return fmt.Sprintf("%s (%s)", project.Name, project.ID)
+}
+
+// confine keeps a publication to its root: the elements under it locally and
+// the elements under the same root on the branch. What the branch holds under
+// other roots is left as it is, neither deleted nor disputed; and a branch the
+// session never loaded keeps everything it holds, since nothing of it was seen.
+func confine(set *reposync.ChangeSet, cut, remote *rdf.Graph, root rdf.Term, tracked bool, name string, result *replext.PublishResult) {
+ within := map[string]bool{}
+ for _, subject := range cut.Subjects() {
+ within[rdf.LocalName(subject.Value)] = true
+ }
+ // The branch may qualify its ids; the root is the subject with the same
+ // effective id, as the diff matches elements.
+ for _, subject := range remote.Subjects() {
+ if rdf.LocalName(subject.Value) == rdf.LocalName(root.Value) {
+ for _, owned := range modelsync.RootedAt(remote, subject).Subjects() {
+ within[rdf.LocalName(owned.Value)] = true
+ }
+ break
+ }
+ }
+ kept := set.Changes[:0]
+ outside, unseen := 0, 0
+ for _, change := range set.Changes {
+ switch {
+ case !within[change.ID]:
+ outside++
+ case !tracked && change.Kind == reposync.KindDelete:
+ unseen++
+ default:
+ kept = append(kept, change)
+ }
+ }
+ set.Changes = kept
+ if outside > 0 {
+ result.Notes = append(result.Notes, fmt.Sprintf("%d element(s) the branch holds outside %s were left in place", outside, name))
+ }
+ if unseen > 0 {
+ result.Notes = append(result.Notes, fmt.Sprintf("%d element(s) of %s the branch holds beyond the model were left in place: the branch was not loaded into this session", unseen, name))
+ }
+}
diff --git a/internal/frontend/repl/repository.go b/internal/frontend/repl/repository.go
new file mode 100644
index 0000000000..5d267307f5
--- /dev/null
+++ b/internal/frontend/repl/repository.go
@@ -0,0 +1,319 @@
+package repl
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
+ "github.com/Open-MBEE/OpenSysML/internal/syntax/source"
+)
+
+// The repository commands drive a SysML v2 API server, as the pilot's notebook
+// kernel does: %repo names it, %projects lists it, %load reads a project's
+// branch into the session and %publish writes an element's tree back.
+
+func repositoryLinked() bool { return replext.Repo() != nil }
+
+const (
+ usageRepo = "usage: %repo [ ]"
+ usageProjects = "usage: %projects"
+ usageLoadRepo = "usage: %load [--id=] [--name=] [--branch=] []"
+ usageLoadPath = "usage: %load [--cells >] ..."
+ usagePublish = "usage: %publish [-d] [--project=] [--branch=] "
+)
+
+// completionTimeout bounds a project listing asked for by Tab completion.
+const completionTimeout = 2 * time.Second
+
+// repoBase is the base URL the repository commands address: the one %repo set,
+// else the environment's.
+func (s *Session) repoBase() string {
+ if s.repoURL != "" {
+ return s.repoURL
+ }
+ return replext.Repo().DefaultURL()
+}
+
+func (s *Session) metaRepositoryCommand(fields []string, _ string) (metaResult, bool) {
+ if !repositoryLinked() {
+ return metaResult{}, false
+ }
+ switch fields[0] {
+ case "%repo":
+ return s.doRepo(fields[1:]), true
+ case "%projects":
+ return s.doProjects(fields[1:]), true
+ case "%load":
+ if !s.loadsRepository(fields[1:]) {
+ return metaResult{}, false
+ }
+ return s.doLoadRepository(fields[1:]), true
+ case "%publish":
+ return s.doPublish(fields[1:]), true
+ }
+ return metaResult{}, false
+}
+
+func usageError(lines ...string) metaResult {
+ return metaOut(nil, false, &UsageError{Lines: lines})
+}
+
+func (s *Session) doRepo(args []string) metaResult {
+ switch len(args) {
+ case 0:
+ return metaOut([]string{"API base path: " + s.repoBase()}, false, nil)
+ case 1:
+ base := strings.TrimRight(nameText(args[0]), "/")
+ if err := replext.Repo().CheckURL(base); err != nil {
+ return metaOut(nil, false, err)
+ }
+ s.repoURL = base
+ return metaOut([]string{"API base path: " + base}, false, nil)
+ }
+ return usageError(usageRepo)
+}
+
+func (s *Session) doProjects(args []string) metaResult {
+ if len(args) != 0 {
+ return usageError(usageProjects)
+ }
+ projects, err := replext.Repo().Projects(s.command.context(), s.repoBase())
+ if err != nil {
+ return metaOut(nil, false, err)
+ }
+ if len(projects) == 0 {
+ return metaOut([]string{"no projects"}, false, nil)
+ }
+ lines := make([]string, 0, len(projects))
+ for _, p := range projects {
+ lines = append(lines, projectLine(p.Name, p.ID))
+ }
+ return metaOut(lines, false, nil)
+}
+
+func projectLine(name, id string) string { return fmt.Sprintf("%s (%s)", name, id) }
+
+// loadsRepository tells a %load of a repository project from one of files: a
+// repository option names a project or a branch, and a lone name that names no
+// path names a project. Other options and the stdin "-" are the file load's.
+func (s *Session) loadsRepository(args []string) bool {
+ if len(args) == 0 {
+ return false
+ }
+ for _, arg := range args {
+ if strings.HasPrefix(arg, "--id") || strings.HasPrefix(arg, "--name") || strings.HasPrefix(arg, "--branch") {
+ return true
+ }
+ }
+ if len(args) != 1 || args[0] == "-" || strings.HasPrefix(args[0], "--") {
+ return false
+ }
+ return !namesPath(nameText(args[0]))
+}
+
+// namesPath tells a file argument from a project name: a path has a separator,
+// a model file's extension, a glob's wildcard, a home or relative prefix, or
+// is on disk. A project whose name reads as one is loaded by --name.
+func namesPath(arg string) bool {
+ if strings.ContainsAny(arg, `/\*?[`) || strings.HasPrefix(arg, "~") || strings.HasPrefix(arg, ".") {
+ return true
+ }
+ switch strings.ToLower(filepath.Ext(arg)) {
+ case ".sysml", ".kerml", ".ipynb":
+ return true
+ }
+ _, err := os.Stat(expandHome(arg))
+ return err == nil
+}
+
+// repoFlag reads `--name=value` as value; ok is false when arg is no such flag.
+func repoFlag(arg, name string) (value string, ok bool) {
+ return strings.CutPrefix(arg, "--"+name+"=")
+}
+
+// setRepoFlag stores the value of a `--option=value` argument in the field it
+// names, once: no field, no value and a second value are usage errors.
+func setRepoFlag(field *string, arg, usage, command string) error {
+ name, value, _ := strings.Cut(arg, "=")
+ if field == nil || value == "" {
+ return &UsageError{Lines: []string{usage, fmt.Sprintf("%s is not an option of %s", arg, command)}}
+ }
+ if *field != "" {
+ return &UsageError{Lines: []string{usage, fmt.Sprintf("%s was given twice", name)}}
+ }
+ *field = value
+ return nil
+}
+
+func (s *Session) doLoadRepository(args []string) metaResult {
+ var req replext.LoadRequest
+ for _, arg := range args {
+ switch {
+ case strings.HasPrefix(arg, "--"):
+ var field *string
+ switch {
+ case strings.HasPrefix(arg, "--id="):
+ field = &req.ProjectID
+ case strings.HasPrefix(arg, "--name="):
+ field = &req.Name
+ case strings.HasPrefix(arg, "--branch="):
+ field = &req.Branch
+ }
+ if err := setRepoFlag(field, arg, usageLoadRepo, "%load"); err != nil {
+ return metaOut(nil, false, err)
+ }
+ case req.Name != "" || req.ProjectID != "":
+ return usageError(usageLoadRepo, "name the project once: by --id, by --name or by itself")
+ default:
+ req.Name = nameText(arg)
+ }
+ }
+ if req.Name == "" && req.ProjectID == "" {
+ return usageError(usageLoadRepo, "name the project to load, or the files")
+ }
+ if req.Name != "" && req.ProjectID != "" {
+ return usageError(usageLoadRepo, "name the project once: by --id, by --name or by itself")
+ }
+ loaded, err := replext.Repo().Load(s.command.context(), s.repoBase(), req)
+ if err != nil {
+ return metaOut(nil, false, err)
+ }
+ lines := []string{fmt.Sprintf("loaded project %s at branch %s, commit %s",
+ projectLine(loaded.State.ProjectName, loaded.State.ProjectID), branchLine(loaded.State), loaded.State.LastSeenCommit)}
+ for _, warning := range loaded.Warnings {
+ lines = append(lines, "warning: "+warning)
+ }
+ file := SourceFile{Name: repositoryDocName(loaded.State), Text: string(loaded.Notation), Kind: source.KindSysML}
+ found, declared := renderSplit(s.submitFiles([]SourceFile{file}), s.verbosity)
+ state := loaded.State
+ s.repoState = &state
+ return metaOut(append(append(lines, found...), declared...), false, nil)
+}
+
+func branchLine(state replext.ProjectState) string {
+ if state.BranchName == "" {
+ return state.Branch
+ }
+ return projectLine(state.BranchName, state.Branch)
+}
+
+// repositoryDocName is the session document a loaded project becomes: one per
+// project, so loading it again replaces the earlier load.
+func repositoryDocName(state replext.ProjectState) string {
+ return "project:" + state.ProjectID
+}
+
+func (s *Session) doPublish(args []string) metaResult {
+ req := replext.PublishRequest{Origin: sessionOrigin}
+ for _, arg := range args {
+ switch {
+ case arg == "-d":
+ if req.Derived {
+ return usageError(usagePublish, "-d was given twice")
+ }
+ req.Derived = true
+ case strings.HasPrefix(arg, "-"):
+ var field *string
+ switch {
+ case strings.HasPrefix(arg, "--project="):
+ field = &req.Project
+ case strings.HasPrefix(arg, "--branch="):
+ field = &req.Branch
+ }
+ if err := setRepoFlag(field, arg, usagePublish, "%publish"); err != nil {
+ return metaOut(nil, false, err)
+ }
+ case req.Root != "":
+ return usageError(usagePublish, "name one element to publish")
+ default:
+ req.Root = arg
+ }
+ }
+ if req.Root == "" {
+ return usageError(usagePublish, "name the element to publish")
+ }
+ _, fqn, err := s.lookupSymbol(req.Root)
+ if err != nil {
+ return metaOut([]string{errPrefix + err.Error()}, false, nil)
+ }
+ req.Root = fqn
+ req.Source = []byte(s.text())
+ req.State = s.repoState
+ published, err := replext.Repo().Publish(s.command.context(), s.repoBase(), req)
+ if err != nil {
+ return metaOut(nil, false, err)
+ }
+ var lines []string
+ if published.NewProject {
+ lines = append(lines, "created project "+projectLine(published.State.ProjectName, published.State.ProjectID))
+ }
+ if published.Commit == "" {
+ lines = append(lines, fmt.Sprintf("nothing to publish: branch %s already holds %s", branchLine(published.State), fqn))
+ } else {
+ lines = append(lines, fmt.Sprintf("commit %s on branch %s of %s: %d created, %d updated, %d deleted",
+ published.Commit, branchLine(published.State), projectLine(published.State.ProjectName, published.State.ProjectID),
+ published.Created, published.Updated, published.Deleted))
+ }
+ lines = append(lines, published.Notes...)
+ state := published.State
+ s.repoState = &state
+ return metaOut(lines, false, nil)
+}
+
+// repositoryCompletions answers Tab after a repository command: the flags a
+// `-` starts (the file load's --cells among %load's), project names otherwise,
+// from the server when it answers in time.
+func (s *Session) repositoryCompletions(command, word string) ([]string, bool) {
+ if !repositoryLinked() {
+ return nil, false
+ }
+ flags := map[string][]string{
+ "%load": {"--id=", "--name=", "--branch=", "--cells="},
+ "%publish": {"-d", "--project=", "--branch="},
+ }[command]
+ if flags == nil {
+ return nil, false
+ }
+ if strings.HasPrefix(word, "-") {
+ if value, ok := repoFlag(word, "name"); ok {
+ return prefixed("--name=", matchingPrefix(s.projectNames(), value)), true
+ }
+ if value, ok := repoFlag(word, "project"); ok {
+ return prefixed("--project=", matchingPrefix(s.projectNames(), value)), true
+ }
+ if value, ok := repoFlag(word, "id"); ok {
+ return prefixed("--id=", matchingPrefix(s.projectIDs(), value)), true
+ }
+ return matchingPrefix(flags, word), true
+ }
+ if command == "%load" {
+ return append(pathCompletions(word), matchingPrefix(s.projectNames(), word)...), true
+ }
+ return nil, false
+}
+
+func (s *Session) projectNames() []string {
+ return s.projectField(func(p replext.ProjectInfo) string { return p.Name })
+}
+
+func (s *Session) projectIDs() []string {
+ return s.projectField(func(p replext.ProjectInfo) string { return p.ID })
+}
+
+func (s *Session) projectField(field func(replext.ProjectInfo) string) []string {
+ ctx, cancel := context.WithTimeout(context.Background(), completionTimeout)
+ defer cancel()
+ projects, err := replext.Repo().Projects(ctx, s.repoBase())
+ if err != nil {
+ return nil
+ }
+ out := make([]string, 0, len(projects))
+ for _, p := range projects {
+ out = append(out, field(p))
+ }
+ return out
+}
diff --git a/internal/frontend/repl/repository_test.go b/internal/frontend/repl/repository_test.go
new file mode 100644
index 0000000000..10846841a5
--- /dev/null
+++ b/internal/frontend/repl/repository_test.go
@@ -0,0 +1,505 @@
+package repl
+
+import (
+ "errors"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
+)
+
+const vehicles = `package Vehicles {
+ part def Wheel { attribute radius : ScalarValues::Real; }
+ part def Car { part wheels : Wheel[4]; }
+}
+package Spare { part def Boat; }
+`
+
+// repoSession is a session addressing a fake server, started with the
+// environment the commands read their default from.
+func repoSession(t *testing.T) (*Session, *fakeAPI) {
+ t.Helper()
+ if replext.Repo() == nil {
+ t.Fatal("no repository extension is linked")
+ }
+ api := newFakeAPI()
+ t.Cleanup(api.Close)
+ t.Setenv("FLEXO_SYSMLV2_URL", api.URL())
+ t.Setenv("FLEXO_INTEROP_TOKEN", "")
+ return NewSession(), api
+}
+
+func runMeta(t *testing.T, s *Session, line string) []string {
+ t.Helper()
+ out, _, err := s.RunMeta(line)
+ if err != nil {
+ t.Fatalf("%s: %v\n%s", line, err, strings.Join(out, "\n"))
+ }
+ return out
+}
+
+func metaErr(t *testing.T, s *Session, line string) error {
+ t.Helper()
+ out, _, err := s.RunMeta(line)
+ if err == nil {
+ t.Fatalf("%s succeeded:\n%s", line, strings.Join(out, "\n"))
+ }
+ return err
+}
+
+func wantUsage(t *testing.T, err error, line string) {
+ t.Helper()
+ var usage *UsageError
+ if !errors.As(err, &usage) {
+ t.Fatalf("%s: %v is no *UsageError", line, err)
+ }
+ if len(usage.Lines) == 0 || !strings.HasPrefix(usage.Lines[0], "usage: ") {
+ t.Errorf("%s: usage error without a usage line: %q", line, usage.Lines)
+ }
+}
+
+func joined(lines []string) string { return strings.Join(lines, "\n") }
+
+func TestRepoShowsAndSetsTheBaseURL(t *testing.T) {
+ s, api := repoSession(t)
+ if got := joined(runMeta(t, s, "%repo")); got != "API base path: "+api.URL() {
+ t.Errorf("%%repo = %q, want the environment's URL", got)
+ }
+ if got := joined(runMeta(t, s, "%repo http://127.0.0.1:1/api/")); got != "API base path: http://127.0.0.1:1/api" {
+ t.Errorf("%%repo = %q", got)
+ }
+ if got := joined(runMeta(t, s, "%repo")); got != "API base path: http://127.0.0.1:1/api" {
+ t.Errorf("%%repo after setting = %q", got)
+ }
+ t.Setenv("FLEXO_ALLOW_PLAIN_HTTP", "")
+ if err := metaErr(t, s, "%repo http://models.example.com/api"); !strings.Contains(err.Error(), "FLEXO_ALLOW_PLAIN_HTTP") {
+ t.Errorf("plain HTTP off the loopback was taken: %v", err)
+ }
+ if got := joined(runMeta(t, s, "%repo")); got != "API base path: http://127.0.0.1:1/api" {
+ t.Errorf("a refused URL was kept: %q", got)
+ }
+ wantUsage(t, metaErr(t, s, "%repo a b"), "%repo a b")
+ if help := joined(runMeta(t, s, "%help")); !strings.Contains(help, "FLEXO_INTEROP_TOKEN") || strings.Contains(help, "secret") {
+ t.Error("help does not say the token comes from FLEXO_INTEROP_TOKEN")
+ }
+}
+
+func TestProjectsListsEveryPage(t *testing.T) {
+ s, api := repoSession(t)
+ if got := joined(runMeta(t, s, "%projects")); got != "no projects" {
+ t.Errorf("empty repository: %q", got)
+ }
+ for _, name := range []string{"Alpha", "Beta", "Gamma", "Delta", "Epsilon"} {
+ api.addProject(name)
+ }
+ out := runMeta(t, s, "%projects")
+ if len(out) != 5 {
+ t.Fatalf("%%projects listed %d of 5 projects over pages of %d:\n%s", len(out), api.pageSize, joined(out))
+ }
+ if out[0] != "Alpha (project-0001)" || out[4] != "Epsilon (project-0009)" {
+ t.Errorf("%%projects lines: %q", out)
+ }
+ wantUsage(t, metaErr(t, s, "%projects now"), "%projects now")
+
+ runMeta(t, s, "%repo http://127.0.0.1:9/nothing")
+ err := metaErr(t, s, "%projects")
+ if !strings.Contains(err.Error(), "did not answer") {
+ t.Errorf("unreachable server: %v", err)
+ }
+ if strings.Contains(err.Error(), "goroutine") {
+ t.Errorf("a stack trace leaked: %v", err)
+ }
+}
+
+func TestPublishCreatesAProjectThenCommitsOnIt(t *testing.T) {
+ s, api := repoSession(t)
+ s.Submit(vehicles)
+ out := runMeta(t, s, "%publish Vehicles")
+ if len(out) < 2 || out[0] != "created project Vehicles (project-0001)" || !strings.HasPrefix(out[1], "commit commit-") {
+ t.Fatalf("first publish:\n%s", joined(out))
+ }
+ if !strings.Contains(out[1], "deleted") || strings.Contains(out[1], " 0 created") {
+ t.Errorf("first publish reports no creations: %q", out[1])
+ }
+ held := api.elementCount("project-0001", "branch-0002")
+ if held == 0 {
+ t.Fatal("the project holds no elements")
+ }
+ if api.elementCount("project-0001", "branch-0002") != held {
+ t.Fatal("unstable count")
+ }
+ for _, id := range api.order {
+ if api.projects[id].name == "Spare" {
+ t.Error("Spare, outside Vehicles, was published")
+ }
+ }
+
+ again := runMeta(t, s, "%publish Vehicles")
+ if len(again) != 1 || !strings.HasPrefix(again[0], "nothing to publish") {
+ t.Errorf("republishing the same model:\n%s", joined(again))
+ }
+
+ s.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Truck; }")
+ out = runMeta(t, s, "%publish Vehicles")
+ if len(out) != 1 || !strings.Contains(out[0], "on branch main (branch-0002) of Vehicles (project-0001)") {
+ t.Fatalf("second publish:\n%s", joined(out))
+ }
+ if strings.Contains(out[0], " 0 created") {
+ t.Errorf("Truck was not created: %q", out[0])
+ }
+ if len(api.order) != 1 {
+ t.Errorf("%d projects after publishing twice, want 1", len(api.order))
+ }
+
+ out = runMeta(t, s, "%publish --project=Fleet Vehicles::Car")
+ if out[0] != "created project Fleet ("+api.order[len(api.order)-1]+")" {
+ t.Errorf("--project:\n%s", joined(out))
+ }
+ err := metaErr(t, s, "%publish --branch=release Vehicles")
+ if !strings.Contains(err.Error(), "branch release doesn't exist") {
+ t.Errorf("unknown branch: %v", err)
+ }
+ wantUsage(t, metaErr(t, s, "%publish"), "%publish")
+ wantUsage(t, metaErr(t, s, "%publish --derived Vehicles"), "%publish --derived")
+ wantUsage(t, metaErr(t, s, "%publish Vehicles Spare"), "%publish two names")
+ if out := runMeta(t, s, "%publish Nowhere"); len(out) != 1 || !strings.HasPrefix(out[0], "error: ") {
+ t.Errorf("unknown element:\n%s", joined(out))
+ }
+
+ api.conflict = "head moved"
+ err = metaErr(t, s, "%publish -d --project=Fleet Vehicles")
+ if !strings.Contains(err.Error(), "Conflict") || !strings.Contains(err.Error(), "head moved") {
+ t.Errorf("409 is not reported with its status and the server's message: %v", err)
+ }
+}
+
+func TestPublishWithDerivedPropertiesSendsMore(t *testing.T) {
+ s, api := repoSession(t)
+ s.Submit(specialized)
+ runMeta(t, s, "%publish Chains")
+ plain, plainBytes := api.heldBytes("project-0001", "branch-0002")
+ s2 := NewSession()
+ s2.Submit(specialized)
+ runMeta(t, s2, "%publish -d --project=Derived Chains")
+ derived := api.order[len(api.order)-1]
+ with, withBytes := api.heldBytes(derived, api.projects[derived].defaults)
+ if with != plain {
+ t.Errorf("derived properties changed the element count: %d vs %d", with, plain)
+ }
+ if withBytes <= plainBytes {
+ t.Errorf("-d sent %d bytes, no more than the %d without", withBytes, plainBytes)
+ }
+}
+
+// specialized has subclassifications, whose owningClassifier is a derived
+// property the reader recomputes: the one a publish leaves out unless asked.
+const specialized = `package Chains {
+ part def A { part b : B; }
+ part def B :> A { attribute x : ScalarValues::Real; }
+ part def C :> B;
+}
+`
+
+func TestLoadReadsAProjectIntoTheSession(t *testing.T) {
+ s, api := repoSession(t)
+ s.Submit(vehicles)
+ runMeta(t, s, "%publish Vehicles")
+
+ fresh := NewSession()
+ out := runMeta(t, fresh, "%load Vehicles")
+ if !strings.HasPrefix(out[0], "loaded project Vehicles (project-0001) at branch main (branch-0002), commit commit-") {
+ t.Fatalf("%%load:\n%s", joined(out))
+ }
+ listed := joined(runMeta(t, fresh, "%list"))
+ for _, want := range []string{"package Vehicles", "part def Car", "part def Wheel", "part wheels : Wheel[4]"} {
+ if !strings.Contains(listed, want) {
+ t.Errorf("%s is not in the loaded session:\n%s", want, listed)
+ }
+ }
+ if strings.Contains(listed, "Spare") {
+ t.Errorf("Spare was loaded, and it was never published:\n%s", listed)
+ }
+ if fresh.repoState == nil || fresh.repoState.ProjectID != "project-0001" || fresh.repoState.Branch != "branch-0002" {
+ t.Fatalf("the loaded branch was not recorded: %+v", fresh.repoState)
+ }
+ printed := joined(runMeta(t, fresh, "%print Vehicles"))
+ if !strings.Contains(printed, "part wheels : Wheel[4]") {
+ t.Errorf("the loaded notation lost the multiplicity:\n%s", printed)
+ }
+
+ // A publish from the loaded session is a commit on the branch it came from.
+ fresh.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Bus; }")
+ out = runMeta(t, fresh, "%publish Vehicles")
+ if len(out) != 1 || !strings.Contains(out[0], "of Vehicles (project-0001)") {
+ t.Fatalf("publish after load:\n%s", joined(out))
+ }
+ if len(api.order) != 1 {
+ t.Errorf("publishing a loaded project created another: %d projects", len(api.order))
+ }
+
+ byID := NewSession()
+ if out := runMeta(t, byID, "%load --id=project-0001 --branch=main"); !strings.HasPrefix(out[0], "loaded project Vehicles") {
+ t.Errorf("--id --branch:\n%s", joined(out))
+ }
+ if out := runMeta(t, NewSession(), "%load --name=Vehicles --branch=branch-0002"); !strings.HasPrefix(out[0], "loaded project Vehicles") {
+ t.Errorf("--name --branch=:\n%s", joined(out))
+ }
+ if err := metaErr(t, NewSession(), "%load --id=project-9999"); !strings.Contains(err.Error(), "project project-9999 doesn't exist") {
+ t.Errorf("404 by id: %v", err)
+ }
+ if err := metaErr(t, NewSession(), "%load Nowhere"); !strings.Contains(err.Error(), "project Nowhere doesn't exist") {
+ t.Errorf("missing name: %v", err)
+ }
+ if err := metaErr(t, NewSession(), "%load Vehicles --branch=release"); !strings.Contains(err.Error(), "branch release doesn't exist") {
+ t.Errorf("missing branch: %v", err)
+ }
+ empty := api.addProject("Empty")
+ if err := metaErr(t, NewSession(), "%load Empty"); !strings.Contains(err.Error(), "has no head commit") {
+ t.Errorf("empty project %s: %v", empty.id, err)
+ }
+ api.addProject("Vehicles")
+ err := metaErr(t, NewSession(), "%load Vehicles")
+ if !strings.Contains(err.Error(), "project-0001") || strings.Count(err.Error(), "project-00") < 2 {
+ t.Errorf("ambiguous name does not list both ids: %v", err)
+ }
+ wantUsage(t, metaErr(t, s, "%load --id=project-0001 Vehicles"), "two ways to name")
+ wantUsage(t, metaErr(t, s, "%load --branch=main"), "branch alone")
+ wantUsage(t, metaErr(t, s, "%load --tag=x"), "unknown flag")
+ if _, _, err := s.RunMeta("%load /nonexistent/model.sysml"); err == nil || !strings.Contains(err.Error(), "nonexistent") {
+ t.Errorf("a path still loads files: %v", err)
+ }
+ if out, _, _ := s.RunMeta("%load"); joined(out) != usageLoadPath {
+ t.Errorf("%%load without arguments: %q", out)
+ }
+}
+
+func TestPublishLoadRoundTripKeepsTheModel(t *testing.T) {
+ s, _ := repoSession(t)
+ s.Submit(vehicles)
+ runMeta(t, s, "%publish Vehicles")
+ fresh := NewSession()
+ runMeta(t, fresh, "%load Vehicles")
+ want := joined(runMeta(t, s, "%print Vehicles"))
+ got := joined(runMeta(t, fresh, "%print Vehicles"))
+ if normalizeNotation(got) != normalizeNotation(want) {
+ t.Errorf("the model changed in the round trip:\n--- published\n%s\n--- loaded\n%s", want, got)
+ }
+ if fresh.repoState == nil || fresh.repoState.LastSeenCommit == "" {
+ t.Error("the loaded session does not know its commit")
+ }
+}
+
+// normalizeNotation reads notation as its sorted tokens, the ProjectRef the
+// load adds aside: the server orders the elements, and layout is the writer's.
+func normalizeNotation(text string) string {
+ var out []string
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "@IdentityMetadata::ProjectRef") {
+ continue
+ }
+ line = strings.NewReplacer("{", " { ", "}", " } ", ";", " ; ").Replace(line)
+ out = append(out, strings.Fields(line)...)
+ }
+ sort.Strings(out)
+ return strings.Join(out, " ")
+}
+
+func TestRepositoryCommandsAreListedAndCompleted(t *testing.T) {
+ s, api := repoSession(t)
+ api.addProject("Vehicles")
+ api.addProject("Vessels")
+ help := joined(runMeta(t, s, "%help"))
+ for _, want := range []string{"Repository:", "%repo [ ]", "%projects", "%publish [-d] [--project=] [--branch=] ", "--id="} {
+ if !strings.Contains(help, want) {
+ t.Errorf("help lacks %q", want)
+ }
+ }
+ complete := func(line string) []string { return s.Complete(line, len(line)).Candidates }
+ if got := complete("%pub"); joined(got) != "%publish" {
+ t.Errorf("%%pub completes to %q", got)
+ }
+ if got := complete("%load --"); joined(got) != "--branch=\n--cells=\n--id=\n--name=" {
+ t.Errorf("%%load -- completes to %q", got)
+ }
+ if got := complete("%load --c"); joined(got) != "--cells=" {
+ t.Errorf("%%load --c completes to %q", got)
+ }
+ if got := complete("%load --name=Ve"); joined(got) != "--name=Vehicles\n--name=Vessels" {
+ t.Errorf("%%load --name=Ve completes to %q", got)
+ }
+ if got := complete("%load Veh"); joined(got) != "Vehicles" {
+ t.Errorf("%%load Veh completes to %q", got)
+ }
+ if got := complete("%publish -"); joined(got) != "--branch=\n--project=\n-d" {
+ t.Errorf("%%publish - completes to %q", got)
+ }
+ if got := complete("%publish --project=Ves"); joined(got) != "--project=Vessels" {
+ t.Errorf("%%publish --project=Ves completes to %q", got)
+ }
+ if got := complete("%load --id=project-"); len(got) != 2 {
+ t.Errorf("%%load --id= completes to %q", got)
+ }
+}
+
+// With a repository linked, %load still reads notebooks: --cells is the
+// notebook's option, not a project's, and an .ipynb name is a path even when
+// no such file exists.
+func TestLoadWithCellsReadsANotebookNotAProject(t *testing.T) {
+ s, _ := repoSession(t)
+ out := runMeta(t, s, "%load --cells 1,3-4 "+fixtureNotebook("tagged.ipynb"))
+ if joined(out) == "" || !strings.Contains(joined(out), "3 of 4 code cells (--cells 1,3-4)") {
+ t.Errorf("--cells was not read as the notebook's option:\n%s", joined(out))
+ }
+ out = runMeta(t, s, "%load --cells=tag:model "+fixtureNotebook("tagged.ipynb"))
+ if !strings.Contains(joined(out), "2 of 4 code cells (--cells tag:model)") {
+ t.Errorf("--cells= was not read as the notebook's option:\n%s", joined(out))
+ }
+ if err := metaErr(t, s, "%load missing.ipynb"); strings.Contains(err.Error(), "project") {
+ t.Errorf("a missing notebook was looked up as a project: %v", err)
+ }
+}
+
+func TestPublishAfterLoadingABranchCommitsOnThatBranch(t *testing.T) {
+ s, api := repoSession(t)
+ s.Submit(vehicles)
+ runMeta(t, s, "%publish Vehicles")
+ review := api.addBranch("project-0001", "review")
+ main := api.projects["project-0001"].branches["branch-0002"]
+ before := main.head
+
+ fresh := NewSession()
+ out := runMeta(t, fresh, "%load Vehicles --branch=review")
+ if !strings.HasPrefix(out[0], "loaded project Vehicles (project-0001) at branch review ("+review.id+")") {
+ t.Fatalf("%%load:\n%s", joined(out))
+ }
+ fresh.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Van; }")
+ out = runMeta(t, fresh, "%publish Vehicles")
+ if len(out) != 1 || !strings.Contains(out[0], "on branch review ("+review.id+") of Vehicles (project-0001)") {
+ t.Fatalf("publish after loading a branch:\n%s", joined(out))
+ }
+ if main.head != before {
+ t.Errorf("the default branch moved from %s to %s; the loaded branch was review", before, main.head)
+ }
+ if review.head == before || !strings.HasPrefix(out[0], "commit "+review.head+" ") {
+ t.Errorf("review's head is %s, the publish reported %q", review.head, out[0])
+ }
+ if fresh.repoState.Branch != review.id {
+ t.Errorf("the session now tracks branch %s, not review", fresh.repoState.Branch)
+ }
+
+ // --branch still wins over the loaded branch.
+ fresh.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } part def Car { part wheels : Wheel[4]; } part def Van; part def Bus; }")
+ out = runMeta(t, fresh, "%publish --branch=main Vehicles")
+ if len(out) != 1 || !strings.Contains(out[0], "on branch main (branch-0002)") {
+ t.Fatalf("publish --branch after loading another:\n%s", joined(out))
+ }
+ if main.head == before {
+ t.Errorf("--branch=main did not move main")
+ }
+}
+
+func TestPublishingOneRootLeavesTheOthersOnTheBranch(t *testing.T) {
+ s, api := repoSession(t)
+ s.Submit(vehicles)
+ runMeta(t, s, "%publish Vehicles")
+ held := api.elementCount("project-0001", "branch-0002")
+
+ out := runMeta(t, s, "%publish --project=Vehicles Spare")
+ if len(out) < 2 || !strings.Contains(out[0], " 0 deleted") || !strings.Contains(out[1], "left in place") {
+ t.Fatalf("publishing Spare next to Vehicles:\n%s", joined(out))
+ }
+ if now := api.elementCount("project-0001", "branch-0002"); now <= held {
+ t.Errorf("%d elements after adding Spare to %d", now, held)
+ }
+
+ // A session holding the branch's state and a Vehicles without Car: the
+ // publication deletes Car, inside its root, and leaves Spare alone.
+ reduced := NewSession()
+ reduced.Submit("package Vehicles { part def Wheel { attribute radius : ScalarValues::Real; } }")
+ reduced.repoState = s.repoState
+ out = runMeta(t, reduced, "%publish Vehicles")
+ if len(out) != 2 || strings.Contains(out[0], " 0 deleted") || !strings.Contains(out[1], "left in place") {
+ t.Fatalf("removing Car from Vehicles:\n%s", joined(out))
+ }
+ if !strings.Contains(out[0], "Vehicles (project-0001)") {
+ t.Errorf("Vehicles went elsewhere: %q", out[0])
+ }
+ fresh := NewSession()
+ runMeta(t, fresh, "%load Vehicles")
+ if loaded := fresh.text(); !strings.Contains(loaded, "Boat") || strings.Contains(loaded, "Car") {
+ t.Errorf("after removing Car, the branch should hold Boat and no Car:\n%s", loaded)
+ }
+}
+
+func TestPublishForgetsTheStateOfAnotherServer(t *testing.T) {
+ s, api := repoSession(t)
+ other := newFakeAPI()
+ t.Cleanup(other.Close)
+ s.Submit(vehicles)
+ runMeta(t, s, "%publish Vehicles")
+
+ runMeta(t, s, "%repo "+other.URL())
+ runMeta(t, s, "%publish --project=Vehicles Spare")
+ if other.elementCount("project-0001", "branch-0002") == 0 || api.elementCount("project-0001", "branch-0002") == 0 {
+ t.Fatal("both servers should now hold a project-0001")
+ }
+ runMeta(t, s, "%repo "+api.URL())
+ runMeta(t, s, "%publish Vehicles")
+ runMeta(t, s, "%repo "+other.URL())
+
+ // The same ids on another server are no history of it: nothing it holds
+ // may be deleted on the strength of the first server's commits.
+ held := other.elementCount("project-0001", "branch-0002")
+ out := runMeta(t, s, "%publish Vehicles")
+ if len(out) < 1 || !strings.Contains(out[0], " 0 deleted") {
+ t.Fatalf("publishing to the other server:\n%s", joined(out))
+ }
+ if now := other.elementCount("project-0001", "branch-0002"); now < held {
+ t.Errorf("the other server lost elements: %d, had %d", now, held)
+ }
+}
+
+// A %load of --cells alone is the file load's to answer: it wants a notebook,
+// or a selection, and no repository option was given; a lone name beginning
+// with one dash is still a project's.
+func TestLoadWithCellsAloneWantsANotebook(t *testing.T) {
+ s, api := repoSession(t)
+ api.addProject("-demo")
+ if err := metaErr(t, s, "%load -demo"); err == nil || !strings.Contains(err.Error(), "has no head commit") {
+ t.Errorf("a lone -demo did not name the project: %v", err)
+ }
+ if out := joined(runMeta(t, s, "%load --cells=1")); out != usageLoadPath {
+ t.Errorf("%%load --cells=1 answered %q, want the file load's usage", out)
+ }
+ err := metaErr(t, s, "%load --cells")
+ if err == nil || !strings.Contains(err.Error(), "--cells needs a selection") {
+ t.Errorf("%%load --cells: %v", err)
+ }
+ err = metaErr(t, s, "%load --tags=x")
+ var usage *UsageError
+ if !errors.As(err, &usage) || !strings.Contains(joined(usage.Lines), "unknown option --tags") {
+ t.Errorf("%%load --tags=x: %v", err)
+ }
+}
+
+func TestRepositoryOptionsAreGivenOnce(t *testing.T) {
+ s, _ := repoSession(t)
+ s.Submit(vehicles)
+ for _, line := range []string{
+ "%load --id=p1 --id=p2",
+ "%load --name=Vehicles --name=Spare",
+ "%load --branch=main --branch=dev Vehicles",
+ "%publish --project=Fleet --project=Other Vehicles",
+ "%publish --branch=main --branch=dev Vehicles",
+ "%publish -d -d Vehicles",
+ } {
+ err := metaErr(t, s, line)
+ wantUsage(t, err, line)
+ var usage *UsageError
+ if errors.As(err, &usage) && !strings.Contains(joined(usage.Lines), "was given twice") {
+ t.Errorf("%s: %q does not name the repeated option", line, usage.Lines)
+ }
+ }
+}
diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go
index ec1ada84d9..73548afcc3 100644
--- a/internal/frontend/repl/session.go
+++ b/internal/frontend/repl/session.go
@@ -4,6 +4,7 @@ package repl
import (
"fmt"
+ "github.com/Open-MBEE/OpenSysML/internal/frontend/repl/replext"
"path/filepath"
"sort"
"strings"
@@ -153,6 +154,11 @@ type Session struct {
// budgets bounds every runtime context this session creates.
budgets runtime.Budgets
+ // repoURL is the base URL %repo set, empty until it does; repoState is the
+ // branch the model was last loaded from or published to.
+ repoURL string
+ repoState *replext.ProjectState
+
// schedule is the policy runs started from here on resolve choice points under.
schedule runtime.SchedulePolicy
@@ -1310,6 +1316,7 @@ func (s *Session) clear() []string {
}
s.snippets = nil
s.version = 0
+ s.repoState = nil
s.rtCtx, s.replaced = nil, nil
s.dropIndexedDocs()
s.instances = make(map[string]*runtime.Instance)
diff --git a/internal/translate/interop/flexo/apply_measure.go b/internal/translate/interop/flexo/apply_measure.go
index bb0e7fc60b..cf01ec1fc7 100644
--- a/internal/translate/interop/flexo/apply_measure.go
+++ b/internal/translate/interop/flexo/apply_measure.go
@@ -210,7 +210,7 @@ func (run *applyRun) round(ctx context.Context, name string, local *rdf.Graph, g
round.Changes = append(round.Changes, fmt.Sprintf("%s\t%s\t%s", change.Kind, change.ID, orNone(change.Metaclass)))
}
- before, err := run.client.Commits(ctx, run.repo.project)
+ before, err := run.client.Commits(ctx, run.repo.Project())
if err != nil {
return nil, fmt.Errorf("list commits: %w", err)
}
@@ -265,7 +265,7 @@ func (run *applyRun) round(ctx context.Context, name string, local *rdf.Graph, g
// commitCount lists the project's commits; want >= 0 asserts the count held.
func (run *applyRun) commitCount(ctx context.Context, want int) (int, error) {
- commits, err := run.client.Commits(ctx, run.repo.project)
+ commits, err := run.client.Commits(ctx, run.repo.Project())
if err != nil {
return 0, fmt.Errorf("list commits: %w", err)
}
@@ -280,7 +280,7 @@ func (run *applyRun) commitCount(ctx context.Context, want int) (int, error) {
func (run *applyRun) readBack(ctx context.Context, round *ApplyRound, result *reposync.Result) error {
commit := run.state.LastSeenCommit
for _, change := range result.Applied {
- element, err := run.client.ElementByID(ctx, run.repo.project, commit, change.ID)
+ element, err := run.client.ElementByID(ctx, run.repo.Project(), commit, change.ID)
switch {
case change.Kind == reposync.KindDelete && Status(err) == http.StatusNotFound:
round.Absent++
diff --git a/internal/translate/interop/flexo/client.go b/internal/translate/interop/flexo/client.go
index 2623ef1e8d..3331ea576b 100644
--- a/internal/translate/interop/flexo/client.go
+++ b/internal/translate/interop/flexo/client.go
@@ -5,47 +5,46 @@
// `-sync-apply` sync element-wise, `sysml -convert` reads a branch URL as
// notation or pushes a whole Turtle graph to it.
//
+// The standard API — projects, branches, commits, elements — is package
+// sysmlapi's; this package adds what only Flexo has: the Layer 1 org a project
+// lives under, its Turtle graph store, and the SPARQL read of a commit's graph.
+//
// The stack is external and opt-in; see .agents/skills/flexo-interop for how to
// bring it up and what the gate reports.
package flexo
import (
- "bytes"
"context"
"encoding/json"
"errors"
"fmt"
- "io"
- "net"
"net/http"
"net/url"
"os"
- "strconv"
"strings"
"time"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/sysmlapi"
"github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
)
-const projectsPath = "/projects/"
-
// Environment variables read by ConfigFromEnv. FLEXO_INTEROP is the gate: the
// live test skips unless it is set, exactly as the corpus gates skip on an
// absent corpus.
const (
EnvGate = "FLEXO_INTEROP"
EnvLayer1URL = "FLEXO_LAYER1_URL"
- EnvSysMLV2URL = "FLEXO_SYSMLV2_URL"
- EnvToken = "FLEXO_INTEROP_TOKEN" // #nosec G101 -- a variable name, not a credential
+ EnvSysMLV2URL = sysmlapi.EnvBaseURL
+ EnvToken = sysmlapi.EnvToken
EnvOrg = "FLEXO_SYSMLV2_ORG"
- EnvPlainHTTP = "FLEXO_ALLOW_PLAIN_HTTP"
+ EnvPlainHTTP = sysmlapi.EnvPlainHTTP
)
// Defaults matching flexo-mms-sysmlv2/docker-compose/docker-compose.yml, so a
// stack brought up from that file needs no configuration beyond the token.
const (
DefaultLayer1URL = "http://localhost:8080"
- DefaultSysMLV2URL = "http://localhost:8083"
+ DefaultSysMLV2URL = sysmlapi.DefaultBaseURL
DefaultOrg = "sysmlv2"
)
@@ -80,13 +79,7 @@ func ConfigFromEnv() (Config, error) {
// PlaintextError is a bearer token about to cross the network unencrypted: an
// http:// URL whose host is not this machine.
-type PlaintextError struct {
- URL string
-}
-
-func (e *PlaintextError) Error() string {
- return fmt.Sprintf("%s would send the bearer token in the clear; use https://, or set %s=1 for a stack you trust the network to", e.URL, EnvPlainHTTP)
-}
+type PlaintextError = sysmlapi.PlaintextError
// CheckTransport refuses a token over plaintext to anything but a loopback
// host, unless the environment opts in with FLEXO_ALLOW_PLAIN_HTTP=1.
@@ -99,22 +92,13 @@ func (cfg Config) CheckTransport() error {
if err != nil {
return fmt.Errorf("stack url %q: %w", raw, err)
}
- if u.Scheme == "http" && !loopback(u.Hostname()) {
+ if u.Scheme == "http" && !sysmlapi.Loopback(u.Hostname()) {
return &PlaintextError{URL: raw}
}
}
return nil
}
-// loopback reports a host name that resolves to this machine without a lookup.
-func loopback(host string) bool {
- if host == "localhost" || strings.HasSuffix(host, ".localhost") {
- return true
- }
- ip := net.ParseIP(host)
- return ip != nil && ip.IsLoopback()
-}
-
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
@@ -122,89 +106,38 @@ func envOr(name, fallback string) string {
return fallback
}
-// Client talks to one stack. It is safe for sequential use by one test.
+// Client talks to one stack: the standard API through a sysmlapi client, Layer
+// 1 directly. It is safe for sequential use by one test.
type Client struct {
- cfg Config
- http *http.Client
+ cfg Config
+ api *sysmlapi.Client
}
// New returns a client for cfg.
func New(cfg Config) *Client {
- timeout := cfg.Timeout
- if timeout <= 0 {
- timeout = 60 * time.Second
- }
- return &Client{cfg: cfg, http: &http.Client{Timeout: timeout}}
+ api := sysmlapi.New(sysmlapi.Config{BaseURL: cfg.SysMLV2URL, Token: cfg.Token, Timeout: cfg.Timeout})
+ return &Client{cfg: cfg, api: api}
}
// Config returns the configuration the client was built with.
func (c *Client) Config() Config { return c.cfg }
-// statusError is a response the harness did not ask for. It carries the body,
-// because both services answer with a diagnostic there.
-type statusError struct {
- method string
- url string
- status int
- body string
-}
-
-func (e *statusError) Error() string {
- body := strings.TrimSpace(e.body)
- if len(body) > 400 {
- body = body[:400] + "..."
- }
- return fmt.Sprintf("%s %s: %s: %s", e.method, e.url, http.StatusText(e.status), body)
-}
+// API is the standard SysML v2 API client the stack is driven through.
+func (c *Client) API() *sysmlapi.Client { return c.api }
// Status returns the HTTP status of a failed request, or 0 if err is not one.
-func Status(err error) int {
- var se *statusError
- if errors.As(err, &se) {
- return se.status
- }
- return 0
-}
-
-// do performs one authorized request and returns the response body, failing on
-// any status outside 2xx.
-func (c *Client) do(ctx context.Context, method, url string, body []byte, contentType string, headers map[string]string) ([]byte, http.Header, error) {
- var reader io.Reader
- if body != nil {
- reader = bytes.NewReader(body)
- }
- req, err := http.NewRequestWithContext(ctx, method, url, reader)
- if err != nil {
- return nil, nil, err
- }
- req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
- if contentType != "" {
- req.Header.Set("Content-Type", contentType)
- }
- for name, value := range headers {
- req.Header.Set(name, value)
- }
+func Status(err error) int { return sysmlapi.Status(err) }
- resp, err := c.http.Do(req)
- if err != nil {
- return nil, nil, err
- }
- defer func() { _ = resp.Body.Close() }()
-
- content, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, resp.Header, err
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return content, resp.Header, &statusError{method: method, url: url, status: resp.StatusCode, body: string(content)}
- }
- return content, resp.Header, nil
+// do performs one authorized request against either service and returns the
+// response body, failing on any status outside 2xx.
+func (c *Client) do(ctx context.Context, method, target string, body []byte, contentType string, headers map[string]string) ([]byte, http.Header, error) {
+ return c.api.Do(ctx, method, target, body, contentType, headers)
}
// Reachable reports whether both services answer. The SysML v2 project list is
// the cheapest authorized read on the stack.
func (c *Client) Reachable(ctx context.Context) error {
- if _, _, err := c.do(ctx, http.MethodGet, c.cfg.SysMLV2URL+"/projects", nil, "", nil); err != nil {
+ if err := c.api.Reachable(ctx); err != nil {
return fmt.Errorf("sysmlv2 at %s: %w", c.cfg.SysMLV2URL, err)
}
// The org list, not the org itself: a fresh cluster has no org yet.
@@ -232,10 +165,7 @@ func (c *Client) EnsureOrg(ctx context.Context) error {
}
// Project is the part of a created project the harness uses.
-type Project struct {
- ID string
- Name string
-}
+type Project = sysmlapi.Project
// CreateProject creates a project through the SysML v2 API, which also creates
// its default branch and the queries scratch. The id doubles as the Layer 1 repo
@@ -243,31 +173,7 @@ type Project struct {
// rather than left to the service, which would otherwise mint a uuid the graph
// endpoint's path needs.
func (c *Client) CreateProject(ctx context.Context, id, name, branch string) (Project, error) {
- request := map[string]any{
- "@type": "Project",
- "@id": id,
- "name": name,
- "defaultBranch": map[string]any{"@id": branch},
- }
- body, err := json.Marshal(request)
- if err != nil {
- return Project{}, err
- }
- content, _, err := c.do(ctx, http.MethodPost, c.cfg.SysMLV2URL+"/projects", body, "application/json", nil)
- if err != nil {
- return Project{}, err
- }
- var created struct {
- ID string `json:"@id"`
- Name string `json:"name"`
- }
- if err := json.Unmarshal(content, &created); err != nil {
- return Project{}, fmt.Errorf("decode created project: %w", err)
- }
- if created.ID == "" {
- created.ID = id
- }
- return Project{ID: created.ID, Name: created.Name}, nil
+ return c.api.CreateProject(ctx, id, name, branch)
}
// branchGraphURL is the Graph Store Protocol endpoint of one branch's model.
@@ -329,46 +235,19 @@ func (c *Client) BranchETag(ctx context.Context, project, branch string) (string
// PostChanges commits SysML v2 JSON changes through the service's own commit
// path and returns the commit it made. An empty branch takes the default one.
func (c *Client) PostChanges(ctx context.Context, project, branch string, changes []byte) (Commit, error) {
- target := c.cfg.SysMLV2URL + projectsPath + url.PathEscape(project) + "/commits"
- if branch != "" {
- target += "?branchId=" + url.QueryEscape(branch)
- }
- content, _, err := c.do(ctx, http.MethodPost, target, changes, "application/json", nil)
- if err != nil {
- return Commit{}, err
- }
- var commit Commit
- if err := json.Unmarshal(content, &commit); err != nil {
- return Commit{}, fmt.Errorf("decode commit: %w", err)
- }
- return commit, nil
+ return c.api.PostChanges(ctx, project, branch, changes)
}
// Commit identifies one commit of a project.
-type Commit struct {
- ID string `json:"@id"`
- Type string `json:"@type"`
-}
+type Commit = sysmlapi.Commit
// Branch is one branch as the SysML v2 API describes it: its id and the
// commit at its head.
-type Branch struct {
- ID string `json:"@id"`
- Head Commit `json:"head"`
-}
+type Branch = sysmlapi.Branch
// Branch reads one branch of a project, head commit included.
func (c *Client) Branch(ctx context.Context, project, branch string) (Branch, error) {
- content, _, err := c.do(ctx, http.MethodGet,
- c.cfg.SysMLV2URL+projectsPath+url.PathEscape(project)+"/branches/"+url.PathEscape(branch), nil, "", nil)
- if err != nil {
- return Branch{}, err
- }
- var read Branch
- if err := json.Unmarshal(content, &read); err != nil {
- return Branch{}, fmt.Errorf("decode branch: %w", err)
- }
- return read, nil
+ return c.api.Branch(ctx, project, branch)
}
// ErrBlankNode is a blank node in a graph read back from the stack; elements
@@ -455,133 +334,39 @@ func (c *Client) selectGraph(ctx context.Context, target string) (*rdf.Graph, er
// Commits lists a project's commits, newest first as the service returns them.
func (c *Client) Commits(ctx context.Context, project string) ([]Commit, error) {
- content, _, err := c.do(ctx, http.MethodGet,
- c.cfg.SysMLV2URL+projectsPath+url.PathEscape(project)+"/commits", nil, "", nil)
- if err != nil {
- return nil, err
- }
- var commits []Commit
- if err := json.Unmarshal(content, &commits); err != nil {
- return nil, fmt.Errorf("decode commits: %w", err)
- }
- return commits, nil
-}
-
-// Element is one element as the SysML v2 API delivers it: property name to raw
-// JSON value, kept raw so a value's shape — reference, literal, array — is
-// observable rather than normalized away by decoding into a Go type.
-type Element map[string]json.RawMessage
-
-// ID returns an element's identity, preferring @id over elementId.
-func (e Element) ID() string {
- for _, key := range []string{"@id", "elementId"} {
- if raw, ok := e[key]; ok {
- var id string
- if json.Unmarshal(raw, &id) == nil && id != "" {
- return id
- }
- }
- }
- return ""
-}
-
-// Type returns an element's @type, or "" when it carries none.
-func (e Element) Type() string {
- var name string
- if raw, ok := e["@type"]; ok && json.Unmarshal(raw, &name) == nil {
- return name
- }
- return ""
+ return c.api.Commits(ctx, project)
}
-func (c *Client) commitPath(project, commit string) string {
- return fmt.Sprintf("%s/projects/%s/commits/%s",
- c.cfg.SysMLV2URL, url.PathEscape(project), url.PathEscape(commit))
-}
+// Element is one element as the SysML v2 API delivers it.
+type Element = sysmlapi.Element
// Listing is one element listing: the elements it delivered, the number of
// responses it took, and whether the service ignored the paging parameters.
-type Listing struct {
- Elements []Element
- Responses int
- IgnoredPaging bool
-}
+type Listing = sysmlapi.Listing
-// Elements reads a commit's elements a page at a time. A page that overruns
-// pageSize or repeats an element is a service ignoring paging; the repeat is
-// dropped rather than counted twice.
+// Elements reads a commit's elements a page at a time.
func (c *Client) Elements(ctx context.Context, project, commit string, pageSize int) (Listing, error) {
- var listing Listing
- seen := make(map[string]bool)
- after := ""
- for {
- target := c.commitPath(project, commit) + "/elements?pageSize=" + strconv.Itoa(pageSize)
- if after != "" {
- target += "&pageAfter=" + url.QueryEscape(after)
- }
- content, _, err := c.do(ctx, http.MethodGet, target, nil, "", nil)
- if err != nil {
- return listing, err
- }
- var page []Element
- if err := json.Unmarshal(content, &page); err != nil {
- return listing, fmt.Errorf("decode elements page %d: %w", listing.Responses+1, err)
- }
- listing.Responses++
-
- fresh := 0
- for _, element := range page {
- if id := element.ID(); id != "" {
- if seen[id] {
- continue
- }
- seen[id] = true
- }
- listing.Elements = append(listing.Elements, element)
- fresh++
- }
-
- switch {
- case len(page) > pageSize, fresh < len(page):
- listing.IgnoredPaging = true
- return listing, nil
- case len(page) < pageSize:
- return listing, nil
- }
- last := page[len(page)-1].ID()
- if last == "" || last == after || listing.Responses > 1000 {
- return listing, nil
- }
- after = last
- }
+ return c.api.Elements(ctx, project, commit, pageSize)
}
// ElementByID reads one element directly. A non-2xx answer is returned as the
// error, with Status(err) carrying the code: a rejected id is a finding, not a
// harness failure.
func (c *Client) ElementByID(ctx context.Context, project, commit, id string) (Element, error) {
- content, _, err := c.do(ctx, http.MethodGet,
- c.commitPath(project, commit)+"/elements/"+url.PathEscape(id), nil, "", nil)
- if err != nil {
- return nil, err
- }
- var element Element
- if err := json.Unmarshal(content, &element); err != nil {
- return nil, fmt.Errorf("decode element %s: %w", id, err)
- }
- return element, nil
+ return c.api.ElementByID(ctx, project, commit, id)
}
// Roots lists the elements the service considers roots of a commit: those with
// neither an owner nor an owning related element.
func (c *Client) Roots(ctx context.Context, project, commit string) ([]Element, error) {
- content, _, err := c.do(ctx, http.MethodGet, c.commitPath(project, commit)+"/roots", nil, "", nil)
- if err != nil {
- return nil, err
- }
- var roots []Element
- if err := json.Unmarshal(content, &roots); err != nil {
- return nil, fmt.Errorf("decode roots: %w", err)
- }
- return roots, nil
+ return c.api.Roots(ctx, project, commit)
}
+
+// Representation is what the service stores of a posted graph: sysml:
+// properties only, values as JSON spells them. A diff under it converges with
+// an apply.
+type Representation = sysmlapi.Representation
+
+// UnrepresentableError is a value the service's JSON commit path has no
+// spelling for; the diff refuses it rather than approximating.
+type UnrepresentableError = sysmlapi.UnrepresentableError
diff --git a/internal/translate/interop/flexo/measure.go b/internal/translate/interop/flexo/measure.go
index 50f2e8648c..8e6600b916 100644
--- a/internal/translate/interop/flexo/measure.go
+++ b/internal/translate/interop/flexo/measure.go
@@ -15,6 +15,7 @@ import (
"github.com/Open-MBEE/OpenSysML/internal/translate/convert"
"github.com/Open-MBEE/OpenSysML/internal/translate/export"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/sysmlapi"
"github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
)
@@ -466,11 +467,11 @@ func deliveredKind(raw json.RawMessage) string {
// refusalReason is the refused write's diagnostic, kept to one stable line:
// no request shape, no fresh project id, no line breaks.
func refusalReason(err error, projectID string) string {
- var se *statusError
+ var se *sysmlapi.StatusError
if !errors.As(err, &se) {
return "the write failed"
}
- reason := strings.Join(strings.Fields(se.body), " ")
+ reason := strings.Join(strings.Fields(se.Body), " ")
reason = strings.ReplaceAll(reason, projectID, "")
if len(reason) > 200 {
reason = reason[:200]
diff --git a/internal/translate/interop/flexo/repository.go b/internal/translate/interop/flexo/repository.go
index 4cc236e500..9ac8a84b27 100644
--- a/internal/translate/interop/flexo/repository.go
+++ b/internal/translate/interop/flexo/repository.go
@@ -2,44 +2,33 @@ package flexo
import (
"context"
- "encoding/json"
- "errors"
"fmt"
"net/http"
"net/url"
"strings"
- "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/sysmlapi"
"github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
)
-// Repository is one project branch as a sync's repository: read as a graph,
-// written through the service's commit path so every element keeps its id.
-// It remembers the head its last read stood at and refuses to commit past a
-// head that has since moved.
+// Repository is one project branch as a sync's repository: read as a graph
+// through Layer 1, written through the service's commit path so every element
+// keeps its id, or replaced whole by a Turtle push. It remembers the head its
+// last read stood at and refuses to commit past a head that has since moved.
type Repository struct {
- client *Client
- project string
- branch string
- seen string
+ *sysmlapi.Repository
+ client *Client
}
// Repository addresses one project branch for a sync.
func (c *Client) Repository(project, branch string) *Repository {
- return &Repository{client: c, project: project, branch: branch}
+ return &Repository{Repository: c.api.Repository(project, branch), client: c}
}
// StaleBranchError is a branch whose head moved between the read a change set
// was computed against and the commit that would have written it; nothing was
// written, and the change set has to be recomputed against the new head.
-type StaleBranchError struct {
- Project, Branch, Seen, Head string
-}
-
-func (e *StaleBranchError) Error() string {
- return fmt.Sprintf("branch %s of %s moved from commit %s to %s since the change set was computed; nothing was written, so diff again against the new head",
- e.Branch, e.Project, e.Seen, e.Head)
-}
+type StaleBranchError = sysmlapi.StaleBranchError
// UnrecordedPushError is a graph write the branch accepted but whose response
// named no commit; a later head read could be another writer's, so none is recorded.
@@ -63,26 +52,19 @@ func (e *SupersededPushError) Error() string {
e.Project, e.Branch, e.Commit, e.Head)
}
-// Head is the branch's current head commit.
+// Head is the branch's current head commit; a branch naming none is an error,
+// as every Flexo branch stands at a commit.
func (r *Repository) Head(ctx context.Context) (string, error) {
- branch, err := r.client.Branch(ctx, r.project, r.branch)
+ head, err := r.Repository.Head(ctx)
if err != nil {
return "", err
}
- if branch.Head.ID == "" {
- return "", fmt.Errorf("branch %s of %s names no head commit", r.branch, r.project)
+ if head == "" {
+ return "", fmt.Errorf("branch %s of %s names no head commit", r.Branch(), r.Project())
}
- return branch.Head.ID, nil
+ return head, nil
}
-// Seen is the head commit the last Graph read stood at, or the last commit
-// this repository wrote; empty before either.
-func (r *Repository) Seen() string { return r.seen }
-
-// Resume records the last-seen commit a saved sync state carries, so a head
-// that has since moved is refused instead of written past.
-func (r *Repository) Resume(commit string) { r.seen = commit }
-
// Graph reads the branch as its head commit left it, so the read is of one
// commit rather than of a branch that may move under it.
func (r *Repository) Graph(ctx context.Context) (*rdf.Graph, error) {
@@ -90,26 +72,28 @@ func (r *Repository) Graph(ctx context.Context) (*rdf.Graph, error) {
if err != nil {
return nil, err
}
- graph, err := r.client.CommitGraph(ctx, r.project, head)
+ graph, err := r.client.CommitGraph(ctx, r.Project(), head)
if err != nil {
return nil, err
}
- r.seen = head
+ r.Resume(head)
return graph, nil
}
-// GraphAt reads the branch as one earlier commit left it: the last-seen graph
-// a diff needs to tell a repository change from its own.
+// GraphAt reads the branch as one earlier commit left it, through Layer 1's
+// SPARQL endpoint: the last-seen graph a diff needs to tell a repository
+// change from its own.
func (r *Repository) GraphAt(ctx context.Context, commit string) (*rdf.Graph, error) {
- return r.client.CommitGraph(ctx, r.project, commit)
+ return r.client.CommitGraph(ctx, r.Project(), commit)
}
// Push replaces the branch's whole model graph with the given Turtle and
// returns the commit made; a head that moved past what was seen is refused.
func (r *Repository) Push(ctx context.Context, turtle []byte, message string) (string, error) {
+ project, branch := r.Project(), r.Branch()
// The etag is read first: a head read after it can only race into a 412,
// never into a write past a moved head.
- etag, err := r.client.BranchETag(ctx, r.project, r.branch)
+ etag, err := r.client.BranchETag(ctx, project, branch)
if err != nil {
return "", err
}
@@ -117,10 +101,10 @@ func (r *Repository) Push(ctx context.Context, turtle []byte, message string) (s
if err != nil {
return "", err
}
- if r.seen != "" && head != r.seen {
- return "", &StaleBranchError{Project: r.project, Branch: r.branch, Seen: r.seen, Head: head}
+ if seen := r.Seen(); seen != "" && head != seen {
+ return "", &StaleBranchError{Project: project, Branch: branch, Seen: seen, Head: head}
}
- res, err := r.client.PutGraph(ctx, r.project, r.branch, turtle, message, etag)
+ res, err := r.client.PutGraph(ctx, project, branch, turtle, message, etag)
if err != nil {
if Status(err) == http.StatusPreconditionFailed {
current, readErr := r.Head(ctx)
@@ -131,22 +115,22 @@ func (r *Repository) Push(ctx context.Context, turtle []byte, message string) (s
// and only a head still at it counts it as ours.
if c := committedFrom(res); c != "" {
if c == current {
- r.seen = c
+ r.Resume(c)
return c, nil
}
- return "", &SupersededPushError{Project: r.project, Branch: r.branch, Commit: c, Head: current}
+ return "", &SupersededPushError{Project: project, Branch: branch, Commit: c, Head: current}
}
- return "", &StaleBranchError{Project: r.project, Branch: r.branch, Seen: head, Head: current}
+ return "", &StaleBranchError{Project: project, Branch: branch, Seen: head, Head: current}
}
return "", err
}
// Trust the commit the write itself reported; the head may have moved on.
if res.Commit != "" {
- r.seen = res.Commit
+ r.Resume(res.Commit)
return res.Commit, nil
}
- r.seen = ""
- return "", &UnrecordedPushError{Project: r.project, Branch: r.branch}
+ r.Resume("")
+ return "", &UnrecordedPushError{Project: project, Branch: branch}
}
// committedFrom trusts an ETag only when the response's Location names the
@@ -165,132 +149,3 @@ func committedFrom(res PutResult) string {
}
return ""
}
-
-// Commit writes one batch as one SysML v2 commit. Creates and updates send
-// the element whole under its identity; deletes send a null payload. The API
-// takes no precondition, so the head is re-read first and a moved one refuses
-// the write as a StaleBranchError.
-func (r *Repository) Commit(ctx context.Context, changes []reposync.ElementChange, message string) (string, error) {
- body, err := commitRequest(changes, message)
- if err != nil {
- return "", err
- }
- if r.seen != "" {
- head, err := r.Head(ctx)
- if err != nil {
- return "", err
- }
- if head != r.seen {
- return "", &StaleBranchError{Project: r.project, Branch: r.branch, Seen: r.seen, Head: head}
- }
- }
- commit, err := r.client.PostChanges(ctx, r.project, r.branch, body)
- if err != nil {
- return "", err
- }
- r.seen = commit.ID
- return commit.ID, nil
-}
-
-// commitRequest encodes a batch as the service's CommitRequest.
-func commitRequest(changes []reposync.ElementChange, message string) ([]byte, error) {
- versions := make([]map[string]any, 0, len(changes))
- for _, change := range changes {
- version := map[string]any{
- "@type": "DataVersion",
- "identity": map[string]string{"@id": change.ID},
- "payload": nil,
- }
- switch change.Kind {
- case reposync.KindDelete:
- case reposync.KindCreate, reposync.KindUpdate:
- content, err := payload(change)
- if err != nil {
- return nil, fmt.Errorf("%s %s: %w", change.Kind, change.ID, err)
- }
- version["payload"] = content
- default:
- return nil, fmt.Errorf("%s %s: the service has no commit for a %s", change.Kind, change.ID, change.Kind)
- }
- versions = append(versions, version)
- }
- request := map[string]any{"@type": "Commit", "change": versions}
- if message != "" {
- request["description"] = message
- }
- return json.Marshal(request)
-}
-
-// ErrUntyped is an element with no single sysml: metaclass, which the
-// service's payload cannot state.
-var ErrUntyped = errors.New("the element has no single SysML metaclass")
-
-// payload renders an element's content as the service's JSON: @id and @type,
-// then each sysml: property as a value or, when multi-valued, an array.
-func payload(change reposync.ElementChange) (map[string]json.RawMessage, error) {
- out := map[string]json.RawMessage{}
- values := map[string][]json.RawMessage{}
- metaclass := ""
- for _, triple := range change.Content {
- predicate := triple.Predicate.Value
- if predicate == rdf.RDFType {
- name, ok := strings.CutPrefix(triple.Object.Value, rdf.SysML)
- if !ok || !triple.Object.IsIRI() || metaclass != "" {
- return nil, ErrUntyped
- }
- metaclass = name
- continue
- }
- name, ok := strings.CutPrefix(predicate, rdf.SysML)
- if !ok {
- return nil, &UnrepresentableError{Term: triple.Predicate, Reason: "the service stores sysml: properties only"}
- }
- value, err := jsonValue(triple.Object)
- if err != nil {
- return nil, err
- }
- values[name] = append(values[name], value)
- }
- if metaclass == "" {
- return nil, ErrUntyped
- }
- for name, list := range values {
- if len(list) == 1 {
- out[name] = list[0]
- continue
- }
- array, err := json.Marshal(list)
- if err != nil {
- return nil, err
- }
- out[name] = array
- }
- id, err := json.Marshal(change.ID)
- if err != nil {
- return nil, err
- }
- out["@id"] = id
- if out["@type"], err = json.Marshal(metaclass); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-// jsonValue spells one carried term as the service reads it back from JSON.
-func jsonValue(term rdf.Term) (json.RawMessage, error) {
- carried, err := carryTerm(term)
- if err != nil {
- return nil, err
- }
- if carried.IsIRI() {
- if carried.Value == rdfNil {
- return json.RawMessage("null"), nil
- }
- return json.Marshal(map[string]string{"@id": rdf.LocalName(carried.Value)})
- }
- switch carried.Datatype {
- case rdf.XSD + "boolean", rdf.XSD + "integer", rdf.XSD + "decimal":
- return json.RawMessage(carried.Value), nil
- }
- return json.Marshal(carried.Value)
-}
diff --git a/internal/translate/interop/flexo/repository_test.go b/internal/translate/interop/flexo/repository_test.go
index 0b95d1b2ae..8a3ec67f4d 100644
--- a/internal/translate/interop/flexo/repository_test.go
+++ b/internal/translate/interop/flexo/repository_test.go
@@ -2,13 +2,11 @@ package flexo
import (
"context"
- "encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
- "reflect"
"strings"
"testing"
@@ -16,141 +14,6 @@ import (
"github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
)
-func triple(subject, predicate string, object rdf.Term) rdf.Triple {
- return rdf.Triple{Subject: rdf.IRI(subject), Predicate: rdf.IRI(predicate), Object: object}
-}
-
-func TestCommitRequestSpellsEveryChangeKind(t *testing.T) {
- x := rdf.Element + "X1"
- changes := []reposync.ElementChange{
- {Kind: reposync.KindUpdate, ID: "X1", Content: []rdf.Triple{
- triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"PartDefinition")),
- triple(x, rdf.SysML+"declaredName", rdf.String("renamed")),
- triple(x, rdf.SysML+"isAbstract", rdf.TypedLiteral("true", rdf.XSD+"boolean")),
- triple(x, rdf.SysML+"ownedMember", rdf.IRI(rdf.Element+"Y1")),
- triple(x, rdf.SysML+"ownedMember", rdf.IRI(rdf.Expression+"Y2")),
- triple(x, rdf.SysML+"count", rdf.TypedLiteral("3", rdf.XSD+"integer")),
- triple(x, rdf.SysML+"mass", rdf.TypedLiteral("1.5", rdf.XSD+"decimal")),
- triple(x, rdf.SysML+"owner", rdf.IRI(rdf.RDFNS+"nil")),
- }},
- {Kind: reposync.KindCreate, ID: "N1", Content: []rdf.Triple{
- triple(rdf.Element+"N1", rdf.RDFType, rdf.IRI(rdf.SysML+"Package")),
- }},
- {Kind: reposync.KindDelete, ID: "D1"},
- }
- body, err := commitRequest(changes, "sync")
- if err != nil {
- t.Fatal(err)
- }
- var got map[string]any
- if err := json.Unmarshal(body, &got); err != nil {
- t.Fatal(err)
- }
- want := map[string]any{
- "@type": "Commit",
- "description": "sync",
- "change": []any{
- map[string]any{
- "@type": "DataVersion",
- "identity": map[string]any{"@id": "X1"},
- "payload": map[string]any{
- "@id": "X1",
- "@type": "PartDefinition",
- "declaredName": "renamed",
- "isAbstract": true,
- "ownedMember": []any{map[string]any{"@id": "Y1"}, map[string]any{"@id": "Y2"}},
- "count": float64(3),
- "mass": 1.5,
- "owner": nil,
- },
- },
- map[string]any{
- "@type": "DataVersion",
- "identity": map[string]any{"@id": "N1"},
- "payload": map[string]any{"@id": "N1", "@type": "Package"},
- },
- map[string]any{
- "@type": "DataVersion",
- "identity": map[string]any{"@id": "D1"},
- "payload": nil,
- },
- },
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("commit request:\n got %s\nwant %+v", body, want)
- }
-}
-
-func TestCommitRequestRefusesWhatTheServiceCannotHold(t *testing.T) {
- x := rdf.Element + "X1"
- typed := triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"PartDefinition"))
- cases := []struct {
- name string
- content []rdf.Triple
- want error
- }{
- {"untyped", []rdf.Triple{triple(x, rdf.SysML+"declaredName", rdf.String("a"))}, ErrUntyped},
- {"two types", []rdf.Triple{typed, triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"Package"))}, ErrUntyped},
- {"foreign property", []rdf.Triple{typed, triple(x, "urn:opensysml:sysml:memberIndex", rdf.String("0"))}, &UnrepresentableError{}},
- {"language tag", []rdf.Triple{typed, triple(x, rdf.SysML+"declaredName", rdf.Term{Kind: rdf.TermLiteral, Value: "a", Lang: "en"})}, &UnrepresentableError{}},
- {"foreign IRI", []rdf.Triple{typed, triple(x, rdf.SysML+"owner", rdf.IRI("http://example.org/x"))}, &UnrepresentableError{}},
- {"odd datatype", []rdf.Triple{typed, triple(x, rdf.SysML+"when", rdf.TypedLiteral("2020-01-01", rdf.XSD+"date"))}, &UnrepresentableError{}},
- {"non-numeric integer", []rdf.Triple{typed, triple(x, rdf.SysML+"n", rdf.TypedLiteral("+07", rdf.XSD+"integer"))}, &UnrepresentableError{}},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- _, err := commitRequest([]reposync.ElementChange{{Kind: reposync.KindCreate, ID: "X1", Content: tc.content}}, "")
- if err == nil {
- t.Fatal("accepted")
- }
- var unrepresentable *UnrepresentableError
- switch tc.want.(type) {
- case *UnrepresentableError:
- if !errors.As(err, &unrepresentable) {
- t.Errorf("want an UnrepresentableError, got %v", err)
- }
- default:
- if !errors.Is(err, tc.want) {
- t.Errorf("want %v, got %v", tc.want, err)
- }
- }
- })
- }
-}
-
-func TestRepresentationCarriesWhatTheServiceStores(t *testing.T) {
- x := rdf.Element + "X1"
- rep := Representation{}
- dropped, ok, err := rep.Carry(triple(x, "urn:opensysml:sysml:memberIndex", rdf.String("0")))
- if err != nil || ok {
- t.Errorf("a foreign property was carried: %+v, %v", dropped, err)
- }
- cases := []struct {
- in, want rdf.Term
- }{
- {rdf.TypedLiteral("a", rdf.XSD+"string"), rdf.String("a")},
- {rdf.TypedLiteral("2", rdf.XSD+"decimal"), rdf.TypedLiteral("2", rdf.XSD+"integer")},
- {rdf.TypedLiteral("2.5", rdf.XSD+"decimal"), rdf.TypedLiteral("2.5", rdf.XSD+"decimal")},
- {rdf.TypedLiteral("-4", rdf.XSD+"integer"), rdf.TypedLiteral("-4", rdf.XSD+"integer")},
- {rdf.TypedLiteral("false", rdf.XSD+"boolean"), rdf.TypedLiteral("false", rdf.XSD+"boolean")},
- {rdf.IRI(rdf.Expression + "E"), rdf.IRI(rdf.Expression + "E")},
- {rdf.IRI(rdf.RDFNS + "nil"), rdf.IRI(rdf.RDFNS + "nil")},
- }
- for _, tc := range cases {
- got, ok, err := rep.Carry(triple(x, rdf.SysML+"p", tc.in))
- if err != nil || !ok {
- t.Errorf("%s: not carried: %v", tc.in, err)
- continue
- }
- if got.Object != tc.want {
- t.Errorf("%s: carried as %s, want %s", tc.in, got.Object, tc.want)
- }
- }
- if _, _, err := rep.Carry(triple(x, rdf.SysML+"p", rdf.TypedLiteral("1e3", rdf.XSD+"decimal"))); err == nil {
- t.Error("a non-JSON number was carried")
- }
-}
-
// fakePut is a whole-graph write the fake Layer 1 received: the path it hit,
// the entity tag it was preconditioned on, the media type and the Turtle body.
type fakePut struct {
diff --git a/internal/translate/interop/modelsync/modelsync.go b/internal/translate/interop/modelsync/modelsync.go
new file mode 100644
index 0000000000..6cbddcff49
--- /dev/null
+++ b/internal/translate/interop/modelsync/modelsync.go
@@ -0,0 +1,321 @@
+// Package modelsync is what `sysml -sync` and the REPL's repository commands
+// share above reposync: reading a model as the graph a sync compares, cutting
+// the graph rooted in one element, stamping a graph with the project branch it
+// came from so its notation declares a ProjectRef, and applying a change set
+// while advancing the sync state. Neither client diffs or applies on its own.
+package modelsync
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/Open-MBEE/OpenSysML/internal/translate/convert"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/export"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf/ontology"
+)
+
+// Repository is a project branch a sync reads and writes: the surface
+// sysmlapi.Repository and flexo.Repository both offer.
+type Repository interface {
+ reposync.Committer
+ // Head is the branch's current head commit, empty for a branch no commit
+ // has reached yet.
+ Head(ctx context.Context) (string, error)
+ // Seen is the head the last read stood at or the last commit written.
+ Seen() string
+ // Resume records the last-seen commit a saved state carries.
+ Resume(commit string)
+ // Graph reads the branch at its head.
+ Graph(ctx context.Context) (*rdf.Graph, error)
+ // GraphAt reads the branch as one commit left it.
+ GraphAt(ctx context.Context, commit string) (*rdf.Graph, error)
+}
+
+// Baseline reads the repository as the state's last-seen commit left it: the
+// graph a diff tells repository changes since then apart against. Nil when the
+// state names no commit.
+func Baseline(ctx context.Context, repo Repository, state *reposync.State) (*rdf.Graph, error) {
+ if state == nil || state.LastSeenCommit == "" {
+ return nil, nil
+ }
+ base, err := repo.GraphAt(ctx, state.LastSeenCommit)
+ if err != nil {
+ return nil, fmt.Errorf("read the repository at last-seen commit %s: %w", state.LastSeenCommit, err)
+ }
+ return base, nil
+}
+
+// Applied is what one apply left behind: the commit it ended on and how many
+// changes of each kind landed. Commit is empty when the repository already
+// agreed with the model.
+type Applied struct {
+ Commit string
+ Created, Updated, Deleted int
+ Result *reposync.Result
+}
+
+// Apply writes an appliable change set and advances the state: to the last
+// commit written, or, when nothing needed writing, to the head the repository
+// was read at. A partial apply returns the result with the error.
+func Apply(ctx context.Context, repo Repository, set *reposync.ChangeSet, state *reposync.State, message string) (*Applied, error) {
+ if err := set.Appliable(); err != nil {
+ return nil, err
+ }
+ result, err := reposync.Apply(ctx, repo, set, reposync.ApplyOptions{Message: message})
+ if err != nil {
+ return &Applied{Result: result}, err
+ }
+ applied := &Applied{Result: result}
+ for _, change := range result.Applied {
+ switch change.Kind {
+ case reposync.KindCreate:
+ applied.Created++
+ case reposync.KindUpdate:
+ applied.Updated++
+ case reposync.KindDelete:
+ applied.Deleted++
+ }
+ }
+ if len(result.Applied) == 0 {
+ if head := repo.Seen(); head != "" {
+ state.LastSeenCommit = head
+ }
+ return applied, nil
+ }
+ if err := state.Advance(result); err != nil {
+ return applied, err
+ }
+ applied.Commit = result.LastCommit()
+ return applied, nil
+}
+
+// Scope predicates: the provenance an exported graph carries on its scope roots,
+// the ones reposync.GraphScope reads.
+const (
+ predProjectID = rdf.OpenSysML + "projectId"
+ predBranch = rdf.OpenSysML + "branch"
+ predOrg = rdf.OpenSysML + "org"
+)
+
+// ownerPredicates lead from an element to what owns it, in the forms the
+// mapping writes; owningRelationship leads to the relationship, whose own
+// owner is the element's.
+var ownerPredicates = []string{
+ rdf.SysML + "owner",
+ rdf.SysML + "owningRelatedElement",
+ rdf.SysML + "membershipOwningNamespace",
+ rdf.SysML + "owningNamespace",
+ rdf.SysML + "owningMembership",
+ rdf.SysML + "owningRelationship",
+}
+
+// Scoped returns graph with scope stamped on every root element — each
+// element of the graph that nothing in it owns and that is not a library
+// reference — in place of any scope it carried, so the notation written from
+// it declares the ProjectRef a later sync finds the branch by.
+func Scoped(graph *rdf.Graph, scope reposync.Scope) *rdf.Graph {
+ out := rdf.NewGraphWithCapacity(graph.Len())
+ for _, triple := range graph.Triples() {
+ switch triple.Predicate.Value {
+ case predProjectID, predBranch, predOrg:
+ continue
+ }
+ out.AddTriple(triple)
+ }
+ if scope.IsZero() {
+ return out
+ }
+ for _, root := range Roots(graph) {
+ for _, field := range []struct{ predicate, value string }{
+ {predProjectID, scope.ProjectID}, {predBranch, scope.Branch}, {predOrg, scope.Org},
+ } {
+ if field.value != "" {
+ out.Add(root, rdf.IRI(field.predicate), rdf.String(field.value))
+ }
+ }
+ }
+ return out
+}
+
+// Roots lists the elements the graph declares and nothing in it owns, in the
+// graph's subject order: the elements a document's namespace would hold.
+func Roots(graph *rdf.Graph) []rdf.Term {
+ var roots []rdf.Term
+ for _, subject := range graph.Subjects() {
+ if !strings.HasPrefix(subject.Value, rdf.Element) || !strings.HasPrefix(graph.Type(subject), rdf.SysML) {
+ continue
+ }
+ if ownerOf(graph, subject).Value != "" || export.LibraryReference(graph, subject) {
+ continue
+ }
+ roots = append(roots, subject)
+ }
+ return roots
+}
+
+// ownerOf is the element or relationship that owns subject in the graph; the
+// zero term when the graph states none.
+func ownerOf(graph *rdf.Graph, subject rdf.Term) rdf.Term {
+ for _, predicate := range ownerPredicates {
+ if owner, ok := graph.Object(subject, predicate); ok && owner.IsIRI() {
+ return owner
+ }
+ }
+ return rdf.Term{}
+}
+
+// Notation writes a graph as SysML notation, each warning the writer raises
+// passed to warn.
+func Notation(graph *rdf.Graph, warn func(string)) ([]byte, error) {
+ return convert.FromGraphWith(graph, convert.FormatSysML, convert.Options{Warn: warn})
+}
+
+// NotFoundError is a qualified name no element of the graph carries.
+type NotFoundError struct {
+ QualifiedName string
+}
+
+func (e *NotFoundError) Error() string {
+ return fmt.Sprintf("no element of the model is named %s", e.QualifiedName)
+}
+
+// Rooted cuts the graph down to the element of the given qualified name and
+// everything it owns, directly or through its relationships. The element
+// becomes a root: the triples tying it to its own owner are dropped, as a
+// project's root element has none. Elements the cut references but does not
+// own stay references.
+func Rooted(graph *rdf.Graph, qualifiedName string) (*rdf.Graph, error) {
+ for _, subject := range graph.Subjects() {
+ if name, ok := graph.Lexical(subject, rdf.SysML+"qualifiedName"); ok && name == qualifiedName {
+ return RootedAt(graph, subject), nil
+ }
+ }
+ return nil, &NotFoundError{QualifiedName: qualifiedName}
+}
+
+// RootedAt is Rooted at a given element. An element the graph does not hold
+// cuts to an empty graph.
+func RootedAt(graph *rdf.Graph, root rdf.Term) *rdf.Graph {
+ inside := map[rdf.Term]bool{root: true}
+ reaches := func(subject rdf.Term) bool {
+ seen := map[rdf.Term]bool{}
+ var chain []rdf.Term
+ for current := subject; current.Value != "" && !seen[current]; current = ownerOf(graph, current) {
+ if known, ok := inside[current]; ok {
+ for _, c := range chain {
+ inside[c] = known
+ }
+ return known
+ }
+ seen[current] = true
+ chain = append(chain, current)
+ }
+ for _, c := range chain {
+ inside[c] = false
+ }
+ return false
+ }
+ out := rdf.NewGraph()
+ for _, triple := range graph.Triples() {
+ subject := triple.Subject
+ if !subject.IsIRI() || !reaches(subject) {
+ continue
+ }
+ if subject == root && isOwnerPredicate(triple.Predicate.Value) {
+ continue
+ }
+ out.AddTriple(triple)
+ }
+ return out
+}
+
+func isOwnerPredicate(predicate string) bool {
+ for _, p := range ownerPredicates {
+ if predicate == p {
+ return true
+ }
+ }
+ return false
+}
+
+// readerDerived are the derived properties the notation reader takes from the
+// graph rather than recomputing — a collection's order among them — so a
+// publish without derived properties keeps them: a project written without
+// them could not be loaded back as notation.
+// TestWithoutDerivedReadsBackEveryExample pins the list against the reader.
+var readerDerived = map[string]bool{}
+
+func init() {
+ for _, name := range strings.Fields(`annotatedElement annotatingElement argument chainingFeature condition
+ conjugatedPortDefinition connectorEnd effectAction featureChained featureWithValue
+ function guardExpression importOwningNamespace input isLibraryElement isReference
+ lowerBound membershipOwningNamespace multiplicity operand ownedActorParameter
+ ownedAnnotation ownedConcern ownedConstraint ownedEndFeature ownedFeature
+ ownedFeatureChaining ownedFeatureMembership ownedImport ownedMember ownedMemberElement
+ ownedMemberFeature ownedMemberParameter ownedMembership ownedObjectiveRequirement
+ ownedPortConjugator ownedRedefinition ownedReferenceSubsetting ownedRendering
+ ownedRequirement ownedResultExpression ownedSpecialization ownedSubclassification
+ ownedSubjectParameter ownedSubsetting ownedTyping ownedVariantUsage owner owningFeature
+ owningMembership owningNamespace owningType parameter payloadArgument payloadParameter
+ qualifiedName referencedElement referencingFeature referent relatedElement relatedFeature
+ result source sourceFeature succession target targetFeature transitionFeature
+ triggerAction type upperBound value variant variantMembership`) {
+ readerDerived[name] = true
+ }
+}
+
+// WithoutDerived returns graph without the sysml: properties the metamodel
+// derives on each subject's metaclass, readerDerived aside: what a publish
+// without derived properties sends. The pilot's sends none of them; this
+// writer's reader needs most, so only the ones it recomputes are left out.
+func WithoutDerived(graph *rdf.Graph) *rdf.Graph {
+ out := rdf.NewGraphWithCapacity(graph.Len())
+ for _, triple := range graph.Triples() {
+ if derived(graph, triple) {
+ continue
+ }
+ out.AddTriple(triple)
+ }
+ return out
+}
+
+// derived tells a derived property's triple, or the collection annotation that
+// would restate one, from the properties the reader needs.
+func derived(graph *rdf.Graph, triple rdf.Triple) bool {
+ name, ok := strings.CutPrefix(triple.Predicate.Value, rdf.SysML)
+ if !ok {
+ name, ok = strings.CutPrefix(triple.Predicate.Value, rdf.AnnotationJSON)
+ }
+ if !ok || readerDerived[name] {
+ return false
+ }
+ metaclass, ok := strings.CutPrefix(graph.Type(triple.Subject), rdf.SysML)
+ if !ok {
+ return false
+ }
+ property, ok := ontology.PropertyOf(metaclass, name)
+ return ok && property.Derived
+}
+
+// ErrAmbiguousName is a name several projects share.
+var ErrAmbiguousName = errors.New("ambiguous project name")
+
+// AmbiguousNameError names the projects that share one name, so the caller
+// can pick one by id.
+type AmbiguousNameError struct {
+ Name string
+ IDs []string
+}
+
+func (e *AmbiguousNameError) Error() string {
+ ids := append([]string(nil), e.IDs...)
+ sort.Strings(ids)
+ return fmt.Sprintf("%d projects are named %q (%s); choose one with --id", len(ids), e.Name, strings.Join(ids, ", "))
+}
+
+func (e *AmbiguousNameError) Is(target error) bool { return target == ErrAmbiguousName }
diff --git a/internal/translate/interop/modelsync/modelsync_test.go b/internal/translate/interop/modelsync/modelsync_test.go
new file mode 100644
index 0000000000..6c2c86147b
--- /dev/null
+++ b/internal/translate/interop/modelsync/modelsync_test.go
@@ -0,0 +1,176 @@
+package modelsync
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/translate/convert"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/export"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+const model = `package Vehicles {
+ part def Wheel { attribute radius : ScalarValues::Real; }
+ part def Car { part wheels : Wheel[4]; }
+}
+package Other { part def Boat; }
+`
+
+func graphOf(t *testing.T, notation string) *rdf.Graph {
+ t.Helper()
+ graph, err := convert.SysMLToRDF("session.sysml", []byte(notation))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return graph
+}
+
+func TestRootedCutsOneElementAndWhatItOwns(t *testing.T) {
+ graph := graphOf(t, model)
+ cut, err := Rooted(graph, "Vehicles")
+ if err != nil {
+ t.Fatal(err)
+ }
+ roots := Roots(cut)
+ if len(roots) != 1 {
+ t.Fatalf("roots of the cut: %v, want just Vehicles", roots)
+ }
+ for _, subject := range cut.Subjects() {
+ if name, _ := cut.Lexical(subject, rdf.SysML+"qualifiedName"); strings.HasPrefix(name, "Other") {
+ t.Errorf("%s is outside Vehicles but in the cut", name)
+ }
+ }
+ found := false
+ for _, subject := range cut.Subjects() {
+ if name, _ := cut.Lexical(subject, rdf.SysML+"qualifiedName"); name == "Vehicles::Car::wheels" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("Vehicles::Car::wheels is owned by Vehicles but missing from the cut")
+ }
+ if _, err := Rooted(graph, "Nowhere"); err == nil {
+ t.Error("an unknown name was cut")
+ }
+}
+
+func TestRootedCutReadsBackAsNotation(t *testing.T) {
+ graph := graphOf(t, model)
+ for _, derived := range []bool{true, false} {
+ cut, err := Rooted(graph, "Vehicles")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !derived {
+ cut = WithoutDerived(cut)
+ }
+ data, err := export.WriteAPIJSON(cut)
+ if err != nil {
+ t.Fatalf("derived=%v: write: %v", derived, err)
+ }
+ back, err := export.ReadAPIJSON(data)
+ if err != nil {
+ t.Fatalf("derived=%v: read: %v", derived, err)
+ }
+ notation, err := Notation(Scoped(back, reposync.Scope{ProjectID: "p1", Branch: "main"}), nil)
+ if err != nil {
+ t.Fatalf("derived=%v: notation: %v", derived, err)
+ }
+ text := string(notation)
+ for _, want := range []string{"package Vehicles", "part def Wheel", "part wheels : Wheel[4]", "projectId = \"p1\"", "branch = \"main\""} {
+ if !strings.Contains(text, want) {
+ t.Errorf("derived=%v: notation lacks %q:\n%s", derived, want, text)
+ }
+ }
+ if strings.Contains(text, "Boat") {
+ t.Errorf("derived=%v: notation holds Boat, outside Vehicles:\n%s", derived, text)
+ }
+ }
+}
+
+const chained = `package Chains {
+ part def A { part b : B; }
+ part def B { attribute x : ScalarValues::Real; }
+ part a : A { attribute y = a.b.x; }
+ variation part def Choice { variant part one : A; }
+}
+`
+
+func TestWithoutDerivedDropsDerivedPropertiesTheReaderRecomputes(t *testing.T) {
+ full := graphOf(t, chained)
+ cut := WithoutDerived(full)
+ for _, triple := range full.Triples() {
+ if cut.Has(triple) {
+ continue
+ }
+ name := rdf.LocalName(triple.Predicate.Value)
+ if readerDerived[name] || !derived(full, triple) {
+ t.Errorf("%s was dropped from %s", name, triple.Subject)
+ }
+ }
+ for _, name := range []string{"declaredName", "qualifiedName", "owner"} {
+ found := false
+ for _, triple := range cut.Triples() {
+ if triple.Predicate.Value == rdf.SysML+name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("no %s is left", name)
+ }
+ }
+}
+
+// TestWithoutDerivedReadsBackEveryExample pins readerDerived against the
+// notation reader: every example publishes without derived properties to the
+// notation it publishes with them.
+func TestWithoutDerivedReadsBackEveryExample(t *testing.T) {
+ files, err := filepath.Glob("../../../../examples/*.sysml")
+ if err != nil || len(files) == 0 {
+ t.Fatalf("no examples: %v", err)
+ }
+ roundTrip := func(graph *rdf.Graph) ([]byte, error) {
+ data, err := export.WriteAPIJSON(graph)
+ if err != nil {
+ return nil, err
+ }
+ back, err := export.ReadAPIJSON(data)
+ if err != nil {
+ return nil, err
+ }
+ return Notation(back, nil)
+ }
+ dropped := 0
+ for _, file := range files {
+ data, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ graph, err := convert.SysMLToRDF(file, data)
+ if err != nil {
+ continue
+ }
+ want, err := roundTrip(graph)
+ if err != nil {
+ continue
+ }
+ cut := WithoutDerived(graph)
+ dropped += graph.Len() - cut.Len()
+ got, err := roundTrip(cut)
+ if err != nil {
+ t.Errorf("%s: without derived properties: %v", filepath.Base(file), err)
+ continue
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("%s: the notation differs without derived properties", filepath.Base(file))
+ }
+ }
+ if dropped == 0 {
+ t.Error("no example has a derived property to leave out")
+ }
+}
diff --git a/internal/translate/interop/sysmlapi/client.go b/internal/translate/interop/sysmlapi/client.go
new file mode 100644
index 0000000000..c714ec66c9
--- /dev/null
+++ b/internal/translate/interop/sysmlapi/client.go
@@ -0,0 +1,605 @@
+// Package sysmlapi drives any server speaking the standard SysML v2 API: the
+// projects, branches, commits and elements resources the OMG pilot's
+// SysML-v2-API-Services and Flexo MMS alike serve. It knows nothing of a
+// server's extras — Flexo's Layer 1, orgs and Turtle graph writes live in
+// package flexo, which builds on this client.
+package sysmlapi
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Environment variables a client is configured from. The base URL and the token
+// are the ones `sysml -sync` reads, so a REPL and the command line address one
+// server the same way; the token is optional, as the pilot's server takes none.
+const (
+ EnvBaseURL = "FLEXO_SYSMLV2_URL"
+ EnvToken = "FLEXO_INTEROP_TOKEN" // #nosec G101 -- a variable name, not a credential
+ EnvPlainHTTP = "FLEXO_ALLOW_PLAIN_HTTP"
+)
+
+// DefaultBaseURL is the SysML v2 API port of flexo-mms-sysmlv2's compose file.
+const DefaultBaseURL = "http://localhost:8083"
+
+// DefaultTimeout bounds one request when the configuration names none.
+const DefaultTimeout = 60 * time.Second
+
+// pageSize is how many items one listing request asks for.
+const pageSize = 200
+
+// maxPages bounds a listing against a server that pages forever.
+const maxPages = 1000
+
+// maxRedirects is how many redirects one request follows, as net/http does.
+const maxRedirects = 10
+
+// Config addresses one SysML v2 API server.
+type Config struct {
+ BaseURL string // the API's base path, without a trailing slash
+ Token string // bearer token, sent only when set
+ Timeout time.Duration // per-request bound; DefaultTimeout when zero
+}
+
+// ConfigFromEnv reads the base URL and the token from the environment; the URL
+// defaults to the compose file's port, the token to none.
+func ConfigFromEnv() Config {
+ base := os.Getenv(EnvBaseURL)
+ if base == "" {
+ base = DefaultBaseURL
+ }
+ return Config{BaseURL: strings.TrimRight(base, "/"), Token: os.Getenv(EnvToken), Timeout: DefaultTimeout}
+}
+
+// PlaintextError is a bearer token about to cross the network unencrypted: an
+// http:// URL whose host is not this machine.
+type PlaintextError struct {
+ URL string
+}
+
+func (e *PlaintextError) Error() string {
+ return fmt.Sprintf("%s would send the bearer token in the clear; use https://, or set %s=1 for a server you trust the network to", e.URL, EnvPlainHTTP)
+}
+
+// CheckURL refuses a plaintext URL to anything but a loopback host, unless the
+// environment opts in with FLEXO_ALLOW_PLAIN_HTTP=1, and any URL that is not
+// http(s).
+func CheckURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("server url %q: %w", raw, err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" || u.Host == "" {
+ return fmt.Errorf("server url %q is not an http(s) URL", raw)
+ }
+ if u.Scheme == "http" && !Loopback(u.Hostname()) && os.Getenv(EnvPlainHTTP) != "1" {
+ return &PlaintextError{URL: raw}
+ }
+ return nil
+}
+
+// Loopback reports a host name that resolves to this machine without a lookup.
+func Loopback(host string) bool {
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+// Client talks to one server. It is safe for sequential use.
+type Client struct {
+ cfg Config
+ http *http.Client
+}
+
+// New returns a client for cfg.
+func New(cfg Config) *Client {
+ timeout := cfg.Timeout
+ if timeout <= 0 {
+ timeout = DefaultTimeout
+ }
+ cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
+ c := &Client{cfg: cfg}
+ c.http = &http.Client{Timeout: timeout, CheckRedirect: c.checkRedirect}
+ return c
+}
+
+// checkRedirect holds a redirect to the rule of the first request when there
+// is a bearer token to carry: it stays on the server's host, off plaintext and
+// on https once there, so a redirect cannot take the token elsewhere or into
+// the clear — not even on loopback, where a first request may be plain.
+func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error {
+ if len(via) >= maxRedirects {
+ return fmt.Errorf("stopped after %d redirects", maxRedirects)
+ }
+ if c.cfg.Token == "" {
+ return nil
+ }
+ from := via[len(via)-1].URL
+ if err := CheckURL(req.URL.String()); err != nil {
+ return fmt.Errorf("redirect from %s refused: %w", from, err)
+ }
+ if from.Scheme == "https" && req.URL.Scheme != "https" {
+ return fmt.Errorf("redirect from %s to %s refused: the token stays on https", from, req.URL.Scheme)
+ }
+ if base, err := url.Parse(c.cfg.BaseURL); err == nil && !sameServer(req.URL, base) {
+ return fmt.Errorf("redirect from %s to %s refused: the token is for %s only", from, req.URL.Host, base.Host)
+ }
+ return nil
+}
+
+// sameServer compares two URLs by host name and effective port, so that
+// https://api.example and https://api.example:443 are one server.
+func sameServer(a, b *url.URL) bool {
+ return strings.EqualFold(a.Hostname(), b.Hostname()) && effectivePort(a) == effectivePort(b)
+}
+
+func effectivePort(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
+ }
+ if u.Scheme == "https" {
+ return "443"
+ }
+ return "80"
+}
+
+// Config returns the configuration the client was built with.
+func (c *Client) Config() Config { return c.cfg }
+
+// BaseURL is the server's base path.
+func (c *Client) BaseURL() string { return c.cfg.BaseURL }
+
+// StatusError is a response outside 2xx. It carries the body, because servers
+// answer with their diagnostic there.
+type StatusError struct {
+ Method string
+ URL string
+ Status int
+ Body string
+}
+
+func (e *StatusError) Error() string {
+ return fmt.Sprintf("%s %s: %s: %s", e.Method, e.URL, http.StatusText(e.Status), e.Message())
+}
+
+// Message is the server's diagnostic: the body's message field when it is a
+// JSON object stating one, else the body itself, trimmed to a line's worth.
+func (e *StatusError) Message() string {
+ body := strings.TrimSpace(e.Body)
+ var object map[string]json.RawMessage
+ if json.Unmarshal([]byte(body), &object) == nil {
+ for _, key := range []string{"message", "error", "detail"} {
+ var text string
+ if raw, ok := object[key]; ok && json.Unmarshal(raw, &text) == nil && text != "" {
+ body = text
+ break
+ }
+ }
+ }
+ if len(body) > 400 {
+ body = body[:400] + "..."
+ }
+ return body
+}
+
+// Status returns the HTTP status of a failed request, or 0 if err is not one.
+func Status(err error) int {
+ var se *StatusError
+ if errors.As(err, &se) {
+ return se.Status
+ }
+ return 0
+}
+
+// UnreachableError is a server that answered nothing: the connection failed
+// before any status came back.
+type UnreachableError struct {
+ URL string
+ Err error
+}
+
+func (e *UnreachableError) Error() string {
+ return fmt.Sprintf("the server at %s did not answer: %v", e.URL, e.Err)
+}
+
+func (e *UnreachableError) Unwrap() error { return e.Err }
+
+// Do performs one request, authorized when the configuration holds a token,
+// and returns the response body and headers, failing on any status outside 2xx.
+func (c *Client) Do(ctx context.Context, method, target string, body []byte, contentType string, headers map[string]string) ([]byte, http.Header, error) {
+ var reader io.Reader
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, target, reader)
+ if err != nil {
+ return nil, nil, err
+ }
+ if c.cfg.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
+ }
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+ for name, value := range headers {
+ req.Header.Set(name, value)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, nil, &UnreachableError{URL: c.cfg.BaseURL, Err: err}
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, resp.Header, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return content, resp.Header, &StatusError{Method: method, URL: target, Status: resp.StatusCode, Body: string(content)}
+ }
+ return content, resp.Header, nil
+}
+
+func (c *Client) getJSON(ctx context.Context, target string, into any, what string) error {
+ content, _, err := c.Do(ctx, http.MethodGet, target, nil, "", nil)
+ if err != nil {
+ return err
+ }
+ if err := json.Unmarshal(content, into); err != nil {
+ return fmt.Errorf("decode %s: %w", what, err)
+ }
+ return nil
+}
+
+func (c *Client) postJSON(ctx context.Context, target string, request any, into any, what string) error {
+ body, err := json.Marshal(request)
+ if err != nil {
+ return err
+ }
+ content, _, err := c.Do(ctx, http.MethodPost, target, body, "application/json", nil)
+ if err != nil {
+ return err
+ }
+ if err := json.Unmarshal(content, into); err != nil {
+ return fmt.Errorf("decode %s: %w", what, err)
+ }
+ return nil
+}
+
+// Reachable reports whether the server answers: the project list is the
+// cheapest read every server has.
+func (c *Client) Reachable(ctx context.Context) error {
+ _, _, err := c.Do(ctx, http.MethodGet, c.cfg.BaseURL+"/projects", nil, "", nil)
+ return err
+}
+
+// Ref is a reference to another resource, as the API spells it.
+type Ref struct {
+ ID string `json:"@id"`
+}
+
+// Project is one project as the API describes it.
+type Project struct {
+ ID string `json:"@id"`
+ Name string `json:"name"`
+ DefaultBranch Ref `json:"defaultBranch"`
+}
+
+// Branch is one branch as the API describes it: its id, its name and the
+// commit at its head.
+type Branch struct {
+ ID string `json:"@id"`
+ Name string `json:"name"`
+ Head Commit `json:"head"`
+}
+
+// Commit identifies one commit of a project.
+type Commit struct {
+ ID string `json:"@id"`
+ Type string `json:"@type"`
+}
+
+// Element is one element as the API delivers it: property name to raw JSON
+// value, kept raw so a value's shape — reference, literal, array — is
+// observable rather than normalized away by decoding into a Go type.
+type Element map[string]json.RawMessage
+
+// ID returns an element's identity, preferring @id over elementId.
+func (e Element) ID() string {
+ for _, key := range []string{"@id", "elementId"} {
+ if raw, ok := e[key]; ok {
+ var id string
+ if json.Unmarshal(raw, &id) == nil && id != "" {
+ return id
+ }
+ }
+ }
+ return ""
+}
+
+// Type returns an element's @type, or "" when it carries none.
+func (e Element) Type() string {
+ var name string
+ if raw, ok := e["@type"]; ok && json.Unmarshal(raw, &name) == nil {
+ return name
+ }
+ return ""
+}
+
+func (c *Client) projectPath(project string) string {
+ return c.cfg.BaseURL + "/projects/" + url.PathEscape(project)
+}
+
+func (c *Client) commitPath(project, commit string) string {
+ return c.projectPath(project) + "/commits/" + url.PathEscape(commit)
+}
+
+// paged walks one collection to its end. The API spells the paging parameters
+// `page[size]` and `page[after]` and links the next page in a Link header;
+// Flexo spells them `pageSize` and `pageAfter` and links nothing, so both
+// spellings are sent and the next page is the linked one, else the one after
+// the last item. A short page ends the walk; so does a page repeating an item.
+func (c *Client) paged(ctx context.Context, target string, what string, each func(json.RawMessage) (id string, fresh bool)) error {
+ next := withPaging(target, "")
+ for page := 1; page <= maxPages; page++ {
+ content, header, err := c.Do(ctx, http.MethodGet, next, nil, "", nil)
+ if err != nil {
+ return err
+ }
+ var items []json.RawMessage
+ if err := json.Unmarshal(content, &items); err != nil {
+ return fmt.Errorf("decode %s page %d: %w", what, page, err)
+ }
+ last, repeated := "", false
+ for _, item := range items {
+ id, fresh := each(item)
+ if !fresh {
+ repeated = true
+ }
+ last = id
+ }
+ if repeated || last == "" {
+ return nil
+ }
+ // A server linking its pages is followed to the end whatever each
+ // page holds; without a link, a short page is the last.
+ if linked := nextLink(header, next); linked != "" && linked != next {
+ next = linked
+ continue
+ }
+ if len(items) < pageSize {
+ return nil
+ }
+ next = withPaging(target, last)
+ }
+ return fmt.Errorf("%s: the server paged on past %d pages", what, maxPages)
+}
+
+// withPaging adds both spellings of the page size and, when after is set, of
+// the cursor to a collection URL.
+func withPaging(target, after string) string {
+ sep := "?"
+ if strings.Contains(target, "?") {
+ sep = "&"
+ }
+ size := strconv.Itoa(pageSize)
+ out := target + sep + "page%5Bsize%5D=" + size + "&pageSize=" + size
+ if after != "" {
+ out += "&page%5Bafter%5D=" + url.QueryEscape(after) + "&pageAfter=" + url.QueryEscape(after)
+ }
+ return out
+}
+
+// nextLink is the rel="next" target of a Link header, resolved against the
+// request's URL; empty when the response links no next page.
+func nextLink(header http.Header, requested string) string {
+ base, err := url.Parse(requested)
+ if err != nil {
+ return ""
+ }
+ for _, field := range header.Values("Link") {
+ for _, link := range strings.Split(field, ",") {
+ target, params, _ := strings.Cut(strings.TrimSpace(link), ";")
+ if !strings.Contains(params, `rel="next"`) && !strings.Contains(params, "rel=next") {
+ continue
+ }
+ target = strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(target), "<"), ">")
+ u, err := url.Parse(target)
+ if err != nil {
+ return ""
+ }
+ return base.ResolveReference(u).String()
+ }
+ }
+ return ""
+}
+
+// Projects lists every project, paged through to the end.
+func (c *Client) Projects(ctx context.Context) ([]Project, error) {
+ var projects []Project
+ seen := make(map[string]bool)
+ err := c.paged(ctx, c.cfg.BaseURL+"/projects", "projects", func(raw json.RawMessage) (string, bool) {
+ var p Project
+ if json.Unmarshal(raw, &p) != nil || p.ID == "" || seen[p.ID] {
+ return p.ID, false
+ }
+ seen[p.ID] = true
+ projects = append(projects, p)
+ return p.ID, true
+ })
+ return projects, err
+}
+
+// Project reads one project by id.
+func (c *Client) Project(ctx context.Context, id string) (Project, error) {
+ var p Project
+ err := c.getJSON(ctx, c.projectPath(id), &p, "project")
+ return p, err
+}
+
+// CreateProject creates a project. An id names the project when the server
+// honours client ids, as Flexo does; a default branch names the branch the
+// server creates with it. Either empty leaves the choice to the server.
+func (c *Client) CreateProject(ctx context.Context, id, name, branch string) (Project, error) {
+ request := map[string]any{"@type": "Project", "name": name}
+ if id != "" {
+ request["@id"] = id
+ }
+ if branch != "" {
+ request["defaultBranch"] = map[string]any{"@id": branch, "name": branch}
+ }
+ var created Project
+ if err := c.postJSON(ctx, c.cfg.BaseURL+"/projects", request, &created, "created project"); err != nil {
+ return Project{}, err
+ }
+ if created.ID == "" {
+ created.ID = id
+ }
+ if created.Name == "" {
+ created.Name = name
+ }
+ return created, nil
+}
+
+// Branches lists every branch of a project, paged through to the end.
+func (c *Client) Branches(ctx context.Context, project string) ([]Branch, error) {
+ var branches []Branch
+ seen := make(map[string]bool)
+ err := c.paged(ctx, c.projectPath(project)+"/branches", "branches", func(raw json.RawMessage) (string, bool) {
+ var b Branch
+ if json.Unmarshal(raw, &b) != nil || b.ID == "" || seen[b.ID] {
+ return b.ID, false
+ }
+ seen[b.ID] = true
+ branches = append(branches, b)
+ return b.ID, true
+ })
+ return branches, err
+}
+
+// Branch reads one branch of a project, head commit included.
+func (c *Client) Branch(ctx context.Context, project, branch string) (Branch, error) {
+ var b Branch
+ err := c.getJSON(ctx, c.projectPath(project)+"/branches/"+url.PathEscape(branch), &b, "branch")
+ return b, err
+}
+
+// Commits lists a project's commits, in the order the server returns them.
+func (c *Client) Commits(ctx context.Context, project string) ([]Commit, error) {
+ var commits []Commit
+ err := c.getJSON(ctx, c.projectPath(project)+"/commits", &commits, "commits")
+ return commits, err
+}
+
+// PostChanges commits a CommitRequest body through the server's commit path
+// and returns the commit it made. An empty branch takes the default one.
+func (c *Client) PostChanges(ctx context.Context, project, branch string, changes []byte) (Commit, error) {
+ target := c.projectPath(project) + "/commits"
+ if branch != "" {
+ target += "?branchId=" + url.QueryEscape(branch)
+ }
+ content, _, err := c.Do(ctx, http.MethodPost, target, changes, "application/json", nil)
+ if err != nil {
+ return Commit{}, err
+ }
+ var commit Commit
+ if err := json.Unmarshal(content, &commit); err != nil {
+ return Commit{}, fmt.Errorf("decode commit: %w", err)
+ }
+ return commit, nil
+}
+
+// Listing is one element listing: the elements it delivered, the number of
+// responses it took, and whether the server ignored the paging parameters.
+type Listing struct {
+ Elements []Element
+ Responses int
+ IgnoredPaging bool
+}
+
+// Elements reads a commit's elements a page at a time. A page that overruns
+// size or repeats an element is a server ignoring paging; the repeat is
+// dropped rather than counted twice.
+func (c *Client) Elements(ctx context.Context, project, commit string, size int) (Listing, error) {
+ var listing Listing
+ seen := make(map[string]bool)
+ first := c.commitPath(project, commit) + "/elements?pageSize=" + strconv.Itoa(size) + "&page%5Bsize%5D=" + strconv.Itoa(size)
+ target := first
+ for {
+ content, header, err := c.Do(ctx, http.MethodGet, target, nil, "", nil)
+ if err != nil {
+ return listing, err
+ }
+ var page []Element
+ if err := json.Unmarshal(content, &page); err != nil {
+ return listing, fmt.Errorf("decode elements page %d: %w", listing.Responses+1, err)
+ }
+ listing.Responses++
+
+ fresh := 0
+ for _, element := range page {
+ if id := element.ID(); id != "" {
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ }
+ listing.Elements = append(listing.Elements, element)
+ fresh++
+ }
+
+ if len(page) > size || fresh < len(page) {
+ listing.IgnoredPaging = true
+ return listing, nil
+ }
+ if len(page) == 0 || listing.Responses > maxPages {
+ return listing, nil
+ }
+ linked := nextLink(header, target)
+ switch {
+ case linked != "" && linked != target:
+ // A server linking its pages names the continuation itself,
+ // whatever its cursor is; the link is followed as given.
+ target = linked
+ case len(page) < size:
+ // Without a link to a next page, a short page is the last.
+ return listing, nil
+ default:
+ last := page[len(page)-1].ID()
+ if last == "" {
+ return listing, nil
+ }
+ target = first + "&pageAfter=" + url.QueryEscape(last) + "&page%5Bafter%5D=" + url.QueryEscape(last)
+ }
+ }
+}
+
+// ElementByID reads one element directly. A non-2xx answer is returned as the
+// error, with Status(err) carrying the code.
+func (c *Client) ElementByID(ctx context.Context, project, commit, id string) (Element, error) {
+ var element Element
+ err := c.getJSON(ctx, c.commitPath(project, commit)+"/elements/"+url.PathEscape(id), &element, "element "+id)
+ return element, err
+}
+
+// Roots lists the elements the server considers roots of a commit: those with
+// neither an owner nor an owning related element.
+func (c *Client) Roots(ctx context.Context, project, commit string) ([]Element, error) {
+ var roots []Element
+ err := c.getJSON(ctx, c.commitPath(project, commit)+"/roots", &roots, "roots")
+ return roots, err
+}
diff --git a/internal/translate/interop/sysmlapi/client_test.go b/internal/translate/interop/sysmlapi/client_test.go
new file mode 100644
index 0000000000..fe571ce771
--- /dev/null
+++ b/internal/translate/interop/sysmlapi/client_test.go
@@ -0,0 +1,122 @@
+package sysmlapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// TestElementsFollowLinksWithAnyCursor pages a server whose next links name an
+// offset, not an id: every page is read as the server links it.
+func TestElementsFollowLinksWithAnyCursor(t *testing.T) {
+ const total = 7
+ var requests []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests = append(requests, r.URL.RequestURI())
+ if r.URL.Query().Get("page[after]") != "" || r.URL.Query().Get("pageAfter") != "" {
+ http.Error(w, "this server pages by offset", http.StatusBadRequest)
+ return
+ }
+ offset, _ := strconv.Atoi(r.URL.Query().Get("page[offset]"))
+ var page []map[string]any
+ for i := offset; i < total && len(page) < 3; i++ {
+ page = append(page, map[string]any{"@id": fmt.Sprintf("e%d", i), "@type": "Package"})
+ }
+ if offset+len(page) < total {
+ next := *r.URL
+ q := next.Query()
+ q.Set("page[offset]", strconv.Itoa(offset+len(page)))
+ next.RawQuery = q.Encode()
+ w.Header().Set("Link", fmt.Sprintf(`<%s>; rel="next"`, next.String()))
+ }
+ _ = json.NewEncoder(w).Encode(page)
+ }))
+ defer server.Close()
+
+ listing, err := New(Config{BaseURL: server.URL}).Elements(context.Background(), "p", "c", 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(listing.Elements) != total || listing.Responses != 3 || listing.IgnoredPaging {
+ t.Fatalf("%d elements in %d responses (ignored paging: %v), want %d in 3\n%s",
+ len(listing.Elements), listing.Responses, listing.IgnoredPaging, total, strings.Join(requests, "\n"))
+ }
+ for i, element := range listing.Elements {
+ if element.ID() != fmt.Sprintf("e%d", i) {
+ t.Errorf("element %d is %s", i, element.ID())
+ }
+ }
+}
+
+// TestRedirectsKeepTheTokenOffPlaintext: a redirect is held to the same rule as
+// the first request, so an https server cannot send the token on to http.
+func TestRedirectsKeepTheTokenOffPlaintext(t *testing.T) {
+ t.Setenv(EnvPlainHTTP, "")
+ from, _ := http.NewRequest(http.MethodGet, "https://api.example/projects", nil)
+ request := func(target string) *http.Request {
+ u, err := url.Parse(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &http.Request{URL: u}
+ }
+ withToken := New(Config{BaseURL: "https://api.example", Token: "secret"})
+ if err := withToken.checkRedirect(request("http://api.example/projects/"), []*http.Request{from}); err == nil {
+ t.Error("a downgrade to http carried the token")
+ } else if strings.Contains(err.Error(), "secret") {
+ t.Errorf("the refusal names the token: %v", err)
+ }
+ if err := withToken.checkRedirect(request("https://api.example/v2/projects"), []*http.Request{from}); err != nil {
+ t.Errorf("a redirect within https was refused: %v", err)
+ }
+ for _, elsewhere := range []string{"https://other.example/projects", "https://evil.api.example/projects", "https://api.example:8443/projects"} {
+ if err := withToken.checkRedirect(request(elsewhere), []*http.Request{from}); err == nil {
+ t.Errorf("a redirect to %s carried the token", elsewhere)
+ } else if strings.Contains(err.Error(), "secret") {
+ t.Errorf("the refusal names the token: %v", err)
+ }
+ }
+ for _, canonical := range []string{"https://API.example/projects", "https://api.example:443/projects"} {
+ if err := withToken.checkRedirect(request(canonical), []*http.Request{from}); err != nil {
+ t.Errorf("a redirect to %s, the same server, was refused: %v", canonical, err)
+ }
+ }
+ explicit := New(Config{BaseURL: "https://api.example:443", Token: "secret"})
+ if err := explicit.checkRedirect(request("https://api.example/projects"), []*http.Request{from}); err != nil {
+ t.Errorf("a redirect dropping the default port was refused: %v", err)
+ }
+ loopback := New(Config{BaseURL: "https://localhost:8443", Token: "secret"})
+ if err := loopback.checkRedirect(request("http://localhost:8083/projects"), []*http.Request{from}); err == nil {
+ t.Error("a redirect to another port of loopback carried the token")
+ }
+ if err := loopback.checkRedirect(request("http://localhost:8443/projects"), []*http.Request{from}); err == nil {
+ t.Error("a downgrade to plaintext on loopback carried the token")
+ }
+ t.Setenv(EnvPlainHTTP, "1")
+ plain, _ := http.NewRequest(http.MethodGet, "http://localhost:8083/projects", nil)
+ plainBase := New(Config{BaseURL: "http://localhost:8083", Token: "secret"})
+ if err := plainBase.checkRedirect(request("http://localhost:8083/projects/"), []*http.Request{plain}); err != nil {
+ t.Errorf("a plain redirect of a plain loopback request was refused: %v", err)
+ }
+ if err := plainBase.checkRedirect(request("https://localhost:8083/projects/"), []*http.Request{plain}); err != nil {
+ t.Errorf("an upgrade to https was refused: %v", err)
+ }
+ t.Setenv(EnvPlainHTTP, "")
+ via := make([]*http.Request, maxRedirects)
+ for i := range via {
+ via[i] = from
+ }
+ if err := withToken.checkRedirect(request("https://api.example/projects"), via); err == nil {
+ t.Error("an endless redirect chain was followed")
+ }
+ tokenless := New(Config{BaseURL: "https://api.example"})
+ if err := tokenless.checkRedirect(request("http://other.example/projects/"), []*http.Request{from}); err != nil {
+ t.Errorf("without a token, a redirect elsewhere was refused: %v", err)
+ }
+}
diff --git a/internal/translate/interop/sysmlapi/commit.go b/internal/translate/interop/sysmlapi/commit.go
new file mode 100644
index 0000000000..ad258e6cbc
--- /dev/null
+++ b/internal/translate/interop/sysmlapi/commit.go
@@ -0,0 +1,116 @@
+package sysmlapi
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+// CommitRequest encodes a batch of element changes as the API's CommitRequest:
+// creates and updates send the element whole under its identity, deletes send
+// a null payload.
+func CommitRequest(changes []reposync.ElementChange, message string) ([]byte, error) {
+ versions := make([]map[string]any, 0, len(changes))
+ for _, change := range changes {
+ version := map[string]any{
+ "@type": "DataVersion",
+ "identity": map[string]string{"@id": change.ID},
+ "payload": nil,
+ }
+ switch change.Kind {
+ case reposync.KindDelete:
+ case reposync.KindCreate, reposync.KindUpdate:
+ content, err := payload(change)
+ if err != nil {
+ return nil, fmt.Errorf("%s %s: %w", change.Kind, change.ID, err)
+ }
+ version["payload"] = content
+ default:
+ return nil, fmt.Errorf("%s %s: the API has no commit for a %s", change.Kind, change.ID, change.Kind)
+ }
+ versions = append(versions, version)
+ }
+ request := map[string]any{"@type": "Commit", "change": versions}
+ if message != "" {
+ request["description"] = message
+ }
+ return json.Marshal(request)
+}
+
+// ErrUntyped is an element with no single sysml: metaclass, which the API's
+// payload cannot state.
+var ErrUntyped = errors.New("the element has no single SysML metaclass")
+
+// payload renders an element's content as the API's JSON: @id and @type, then
+// each sysml: property as a value or, when multi-valued, an array.
+func payload(change reposync.ElementChange) (map[string]json.RawMessage, error) {
+ out := map[string]json.RawMessage{}
+ values := map[string][]json.RawMessage{}
+ metaclass := ""
+ for _, triple := range change.Content {
+ predicate := triple.Predicate.Value
+ if predicate == rdf.RDFType {
+ name, ok := strings.CutPrefix(triple.Object.Value, rdf.SysML)
+ if !ok || !triple.Object.IsIRI() || metaclass != "" {
+ return nil, ErrUntyped
+ }
+ metaclass = name
+ continue
+ }
+ name, ok := strings.CutPrefix(predicate, rdf.SysML)
+ if !ok {
+ return nil, &UnrepresentableError{Term: triple.Predicate, Reason: "the API stores sysml: properties only"}
+ }
+ value, err := jsonValue(triple.Object)
+ if err != nil {
+ return nil, err
+ }
+ values[name] = append(values[name], value)
+ }
+ if metaclass == "" {
+ return nil, ErrUntyped
+ }
+ for name, list := range values {
+ if len(list) == 1 {
+ out[name] = list[0]
+ continue
+ }
+ array, err := json.Marshal(list)
+ if err != nil {
+ return nil, err
+ }
+ out[name] = array
+ }
+ id, err := json.Marshal(change.ID)
+ if err != nil {
+ return nil, err
+ }
+ out["@id"] = id
+ if out["@type"], err = json.Marshal(metaclass); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// jsonValue spells one carried term as the API reads it back from JSON.
+func jsonValue(term rdf.Term) (json.RawMessage, error) {
+ carried, err := carryTerm(term)
+ if err != nil {
+ return nil, err
+ }
+ if carried.IsIRI() {
+ if carried.Value == rdfNil {
+ return json.RawMessage("null"), nil
+ }
+ return json.Marshal(map[string]string{"@id": rdf.LocalName(carried.Value)})
+ }
+ switch carried.Datatype {
+ case rdf.XSD + "boolean", rdf.XSD + "integer", rdf.XSD + "decimal":
+ return json.RawMessage(carried.Value), nil
+ }
+ return json.Marshal(carried.Value)
+}
diff --git a/internal/translate/interop/sysmlapi/commit_test.go b/internal/translate/interop/sysmlapi/commit_test.go
new file mode 100644
index 0000000000..f7daa2d56e
--- /dev/null
+++ b/internal/translate/interop/sysmlapi/commit_test.go
@@ -0,0 +1,146 @@
+package sysmlapi
+
+import (
+ "encoding/json"
+ "errors"
+ "reflect"
+ "testing"
+
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+func triple(subject, predicate string, object rdf.Term) rdf.Triple {
+ return rdf.Triple{Subject: rdf.IRI(subject), Predicate: rdf.IRI(predicate), Object: object}
+}
+
+func TestCommitRequestSpellsEveryChangeKind(t *testing.T) {
+ x := rdf.Element + "X1"
+ changes := []reposync.ElementChange{
+ {Kind: reposync.KindUpdate, ID: "X1", Content: []rdf.Triple{
+ triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"PartDefinition")),
+ triple(x, rdf.SysML+"declaredName", rdf.String("renamed")),
+ triple(x, rdf.SysML+"isAbstract", rdf.TypedLiteral("true", rdf.XSD+"boolean")),
+ triple(x, rdf.SysML+"ownedMember", rdf.IRI(rdf.Element+"Y1")),
+ triple(x, rdf.SysML+"ownedMember", rdf.IRI(rdf.Expression+"Y2")),
+ triple(x, rdf.SysML+"count", rdf.TypedLiteral("3", rdf.XSD+"integer")),
+ triple(x, rdf.SysML+"mass", rdf.TypedLiteral("1.5", rdf.XSD+"decimal")),
+ triple(x, rdf.SysML+"owner", rdf.IRI(rdf.RDFNS+"nil")),
+ }},
+ {Kind: reposync.KindCreate, ID: "N1", Content: []rdf.Triple{
+ triple(rdf.Element+"N1", rdf.RDFType, rdf.IRI(rdf.SysML+"Package")),
+ }},
+ {Kind: reposync.KindDelete, ID: "D1"},
+ }
+ body, err := CommitRequest(changes, "sync")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]any{
+ "@type": "Commit",
+ "description": "sync",
+ "change": []any{
+ map[string]any{
+ "@type": "DataVersion",
+ "identity": map[string]any{"@id": "X1"},
+ "payload": map[string]any{
+ "@id": "X1",
+ "@type": "PartDefinition",
+ "declaredName": "renamed",
+ "isAbstract": true,
+ "ownedMember": []any{map[string]any{"@id": "Y1"}, map[string]any{"@id": "Y2"}},
+ "count": float64(3),
+ "mass": 1.5,
+ "owner": nil,
+ },
+ },
+ map[string]any{
+ "@type": "DataVersion",
+ "identity": map[string]any{"@id": "N1"},
+ "payload": map[string]any{"@id": "N1", "@type": "Package"},
+ },
+ map[string]any{
+ "@type": "DataVersion",
+ "identity": map[string]any{"@id": "D1"},
+ "payload": nil,
+ },
+ },
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("commit request:\n got %s\nwant %+v", body, want)
+ }
+}
+
+func TestCommitRequestRefusesWhatTheServiceCannotHold(t *testing.T) {
+ x := rdf.Element + "X1"
+ typed := triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"PartDefinition"))
+ cases := []struct {
+ name string
+ content []rdf.Triple
+ want error
+ }{
+ {"untyped", []rdf.Triple{triple(x, rdf.SysML+"declaredName", rdf.String("a"))}, ErrUntyped},
+ {"two types", []rdf.Triple{typed, triple(x, rdf.RDFType, rdf.IRI(rdf.SysML+"Package"))}, ErrUntyped},
+ {"foreign property", []rdf.Triple{typed, triple(x, "urn:opensysml:sysml:memberIndex", rdf.String("0"))}, &UnrepresentableError{}},
+ {"language tag", []rdf.Triple{typed, triple(x, rdf.SysML+"declaredName", rdf.Term{Kind: rdf.TermLiteral, Value: "a", Lang: "en"})}, &UnrepresentableError{}},
+ {"foreign IRI", []rdf.Triple{typed, triple(x, rdf.SysML+"owner", rdf.IRI("http://example.org/x"))}, &UnrepresentableError{}},
+ {"odd datatype", []rdf.Triple{typed, triple(x, rdf.SysML+"when", rdf.TypedLiteral("2020-01-01", rdf.XSD+"date"))}, &UnrepresentableError{}},
+ {"non-numeric integer", []rdf.Triple{typed, triple(x, rdf.SysML+"n", rdf.TypedLiteral("+07", rdf.XSD+"integer"))}, &UnrepresentableError{}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := CommitRequest([]reposync.ElementChange{{Kind: reposync.KindCreate, ID: "X1", Content: tc.content}}, "")
+ if err == nil {
+ t.Fatal("accepted")
+ }
+ var unrepresentable *UnrepresentableError
+ switch tc.want.(type) {
+ case *UnrepresentableError:
+ if !errors.As(err, &unrepresentable) {
+ t.Errorf("want an UnrepresentableError, got %v", err)
+ }
+ default:
+ if !errors.Is(err, tc.want) {
+ t.Errorf("want %v, got %v", tc.want, err)
+ }
+ }
+ })
+ }
+}
+
+func TestRepresentationCarriesWhatTheServiceStores(t *testing.T) {
+ x := rdf.Element + "X1"
+ rep := Representation{}
+ dropped, ok, err := rep.Carry(triple(x, "urn:opensysml:sysml:memberIndex", rdf.String("0")))
+ if err != nil || ok {
+ t.Errorf("a foreign property was carried: %+v, %v", dropped, err)
+ }
+ cases := []struct {
+ in, want rdf.Term
+ }{
+ {rdf.TypedLiteral("a", rdf.XSD+"string"), rdf.String("a")},
+ {rdf.TypedLiteral("2", rdf.XSD+"decimal"), rdf.TypedLiteral("2", rdf.XSD+"integer")},
+ {rdf.TypedLiteral("2.5", rdf.XSD+"decimal"), rdf.TypedLiteral("2.5", rdf.XSD+"decimal")},
+ {rdf.TypedLiteral("-4", rdf.XSD+"integer"), rdf.TypedLiteral("-4", rdf.XSD+"integer")},
+ {rdf.TypedLiteral("false", rdf.XSD+"boolean"), rdf.TypedLiteral("false", rdf.XSD+"boolean")},
+ {rdf.IRI(rdf.Expression + "E"), rdf.IRI(rdf.Expression + "E")},
+ {rdf.IRI(rdf.RDFNS + "nil"), rdf.IRI(rdf.RDFNS + "nil")},
+ }
+ for _, tc := range cases {
+ got, ok, err := rep.Carry(triple(x, rdf.SysML+"p", tc.in))
+ if err != nil || !ok {
+ t.Errorf("%s: not carried: %v", tc.in, err)
+ continue
+ }
+ if got.Object != tc.want {
+ t.Errorf("%s: carried as %s, want %s", tc.in, got.Object, tc.want)
+ }
+ }
+ if _, _, err := rep.Carry(triple(x, rdf.SysML+"p", rdf.TypedLiteral("1e3", rdf.XSD+"decimal"))); err == nil {
+ t.Error("a non-JSON number was carried")
+ }
+}
diff --git a/internal/translate/interop/sysmlapi/repository.go b/internal/translate/interop/sysmlapi/repository.go
new file mode 100644
index 0000000000..3800c86d11
--- /dev/null
+++ b/internal/translate/interop/sysmlapi/repository.go
@@ -0,0 +1,137 @@
+package sysmlapi
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/Open-MBEE/OpenSysML/internal/translate/export"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/interop/reposync"
+ "github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
+)
+
+// elementsPageSize is how many elements one page of a branch read asks for.
+const elementsPageSize = 500
+
+// Repository is one project branch as a sync's repository: read as the graph
+// its elements spell, written through the commit path so every element keeps
+// its id. It remembers the head its last read stood at and refuses to commit
+// past a head that has since moved.
+type Repository struct {
+ client *Client
+ project string
+ branch string
+ seen string
+}
+
+// Repository addresses one project branch for a sync.
+func (c *Client) Repository(project, branch string) *Repository {
+ return &Repository{client: c, project: project, branch: branch}
+}
+
+// Client is the client the repository reads and writes through.
+func (r *Repository) Client() *Client { return r.client }
+
+// Project is the id of the project the repository is a branch of.
+func (r *Repository) Project() string { return r.project }
+
+// Branch is the id of the branch the repository reads and writes.
+func (r *Repository) Branch() string { return r.branch }
+
+// StaleBranchError is a branch whose head moved between the read a change set
+// was computed against and the commit that would have written it; nothing was
+// written, and the change set has to be recomputed against the new head.
+type StaleBranchError struct {
+ Project, Branch, Seen, Head string
+}
+
+func (e *StaleBranchError) Error() string {
+ return fmt.Sprintf("branch %s of %s moved from commit %s to %s since the change set was computed; nothing was written, so diff again against the new head",
+ e.Branch, e.Project, e.Seen, e.Head)
+}
+
+// Head is the branch's current head commit; empty for a branch no commit has
+// reached yet, as a freshly created project's is on some servers.
+func (r *Repository) Head(ctx context.Context) (string, error) {
+ branch, err := r.client.Branch(ctx, r.project, r.branch)
+ if err != nil {
+ return "", err
+ }
+ return branch.Head.ID, nil
+}
+
+// Seen is the head commit the last Graph read stood at, or the last commit
+// this repository wrote; empty before either.
+func (r *Repository) Seen() string { return r.seen }
+
+// Resume records the last-seen commit a saved sync state carries, so a head
+// that has since moved is refused instead of written past.
+func (r *Repository) Resume(commit string) { r.seen = commit }
+
+// Graph reads the branch as its head commit left it, so the read is of one
+// commit rather than of a branch that may move under it. A branch with no
+// commit yet reads as an empty graph.
+func (r *Repository) Graph(ctx context.Context) (*rdf.Graph, error) {
+ head, err := r.Head(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if head == "" {
+ r.seen = ""
+ return rdf.NewGraph(), nil
+ }
+ graph, err := r.GraphAt(ctx, head)
+ if err != nil {
+ return nil, err
+ }
+ r.seen = head
+ return graph, nil
+}
+
+// GraphAt reads the branch as one commit left it: its elements, paged through
+// and read as the graph their JSON spells.
+func (r *Repository) GraphAt(ctx context.Context, commit string) (*rdf.Graph, error) {
+ listing, err := r.client.Elements(ctx, r.project, commit, elementsPageSize)
+ if err != nil {
+ return nil, err
+ }
+ return ElementsGraph(listing.Elements)
+}
+
+// ElementsGraph reads a commit's elements as the RDF graph their API JSON
+// spells, the inverse of what a commit's payloads were written from.
+func ElementsGraph(elements []Element) (*rdf.Graph, error) {
+ if len(elements) == 0 {
+ return rdf.NewGraph(), nil
+ }
+ data, err := json.Marshal(elements)
+ if err != nil {
+ return nil, err
+ }
+ return export.ReadAPIJSON(data)
+}
+
+// Commit writes one batch as one SysML v2 commit. The API takes no
+// precondition, so the head is re-read first and a moved one refuses the write
+// as a StaleBranchError.
+func (r *Repository) Commit(ctx context.Context, changes []reposync.ElementChange, message string) (string, error) {
+ body, err := CommitRequest(changes, message)
+ if err != nil {
+ return "", err
+ }
+ if r.seen != "" {
+ head, err := r.Head(ctx)
+ if err != nil {
+ return "", err
+ }
+ if head != r.seen {
+ return "", &StaleBranchError{Project: r.project, Branch: r.branch, Seen: r.seen, Head: head}
+ }
+ }
+ commit, err := r.client.PostChanges(ctx, r.project, r.branch, body)
+ if err != nil {
+ return "", err
+ }
+ r.seen = commit.ID
+ return commit.ID, nil
+}
diff --git a/internal/translate/interop/flexo/representation.go b/internal/translate/interop/sysmlapi/representation.go
similarity index 89%
rename from internal/translate/interop/flexo/representation.go
rename to internal/translate/interop/sysmlapi/representation.go
index 6596bbe2ac..8096b5840a 100644
--- a/internal/translate/interop/flexo/representation.go
+++ b/internal/translate/interop/sysmlapi/representation.go
@@ -1,4 +1,4 @@
-package flexo
+package sysmlapi
import (
"fmt"
@@ -8,7 +8,7 @@ import (
"github.com/Open-MBEE/OpenSysML/internal/translate/rdf"
)
-// rdfNil is what the service stores for a JSON null.
+// rdfNil is what the server stores for a JSON null.
const rdfNil = rdf.RDFNS + "nil"
var (
@@ -16,11 +16,11 @@ var (
decimalLexical = regexp.MustCompile(`^-?(0|[1-9][0-9]*)\.[0-9]+$`)
)
-// Representation is what the service stores of a posted graph: sysml: properties
+// Representation is what the server stores of a posted graph: sysml: properties
// only, values as JSON spells them. A diff under it converges with an apply.
type Representation struct{}
-// UnrepresentableError is a value the service's JSON commit path has no
+// UnrepresentableError is a value the server's JSON commit path has no
// spelling for; the diff refuses it rather than approximating.
type UnrepresentableError struct {
Term rdf.Term
@@ -31,7 +31,7 @@ func (e *UnrepresentableError) Error() string {
return fmt.Sprintf("%s cannot be written through the SysML v2 API: %s", e.Term, e.Reason)
}
-// Carry keeps sysml: properties in the form the service stores them and drops
+// Carry keeps sysml: properties in the form the server stores them and drops
// every other predicate: it has no place for them.
func (Representation) Carry(triple rdf.Triple) (rdf.Triple, bool, error) {
if !strings.HasPrefix(triple.Predicate.Value, rdf.SysML) {
diff --git a/tests/hygiene/layering_test.go b/tests/hygiene/layering_test.go
index cbde0a356c..f65d39f143 100644
--- a/tests/hygiene/layering_test.go
+++ b/tests/hygiene/layering_test.go
@@ -87,20 +87,22 @@ var packageLayer = map[string]string{
"internal/exec/hostcap": "exec",
"internal/exec/objref": "exec",
- "internal/translate/rdf": "translate",
- "internal/translate/rdf/ontology": "translate",
- "internal/translate/convert": "translate",
- "internal/translate/export": "translate",
- "internal/translate/filename": "translate",
- "internal/translate/fmi": "translate",
- "internal/translate/migrate": "translate",
- "internal/translate/deferred": "translate",
- "internal/translate/mtip": "translate",
- "internal/translate/xmi": "translate",
- "internal/translate/xmi/sysmlv1": "translate",
- "internal/translate/codegen": "translate",
- "internal/translate/interop/flexo": "translate",
- "internal/translate/interop/reposync": "translate",
+ "internal/translate/rdf": "translate",
+ "internal/translate/rdf/ontology": "translate",
+ "internal/translate/convert": "translate",
+ "internal/translate/export": "translate",
+ "internal/translate/filename": "translate",
+ "internal/translate/fmi": "translate",
+ "internal/translate/migrate": "translate",
+ "internal/translate/deferred": "translate",
+ "internal/translate/mtip": "translate",
+ "internal/translate/xmi": "translate",
+ "internal/translate/xmi/sysmlv1": "translate",
+ "internal/translate/codegen": "translate",
+ "internal/translate/interop/sysmlapi": "translate",
+ "internal/translate/interop/modelsync": "translate",
+ "internal/translate/interop/flexo": "translate",
+ "internal/translate/interop/reposync": "translate",
"internal/semantic/query": "semantic",
"internal/doc/queryexec": "doc",
@@ -150,6 +152,7 @@ var packageLayer = map[string]string{
"internal/frontend/repl/replext/instancegraph": "frontend",
"internal/frontend/repl/replext/notation": "frontend",
"internal/frontend/repl/replext/positional": "frontend",
+ "internal/frontend/repl/replext/repository": "frontend",
}
// tolerated is the imports the layer table does not permit and that still