diff --git a/.agents/skills/testing-doc-pdf/SKILL.md b/.agents/skills/testing-doc-pdf/SKILL.md index 5dde9bb8bf..062f483eff 100644 --- a/.agents/skills/testing-doc-pdf/SKILL.md +++ b/.agents/skills/testing-doc-pdf/SKILL.md @@ -1,12 +1,12 @@ --- name: testing-doc-pdf -description: How to end-to-end test the sysml PDF document backend (internal/doc/docpdf + -doc-form pdf) on Linux — provisioning the pinned WeasyPrint/mermaid/KaTeX/Graphviz/PlantUML toolchain, rendering the worked example and the docrender goldens, and proving inline runs, anchors, links, LaTeX formulas and every diagram form render rather than appearing literal. +description: How to end-to-end test the sysml PDF document backend (internal/doc/docpdf + -doc-form pdf) on Linux — provisioning the pinned WeasyPrint/mermaid/KaTeX/Graphviz/PlantUML/D2 toolchain, rendering the worked example and the docrender goldens, and proving inline runs, anchors, links, LaTeX formulas and every diagram form render rather than appearing literal. --- # Testing the PDF document backend ## Toolchain -- Provision once: `./scripts/download-doc-pdf-toolchain.sh` installs a pinned WeasyPrint venv, pandoc, mermaid-cli, KaTeX, Graphviz (unpacked from the upstream Ubuntu .debs; its `neato` plugin needs the host's `libgts-0.7-5`) and the PlantUML jar under `build/doc-pdf/`. PlantUML needs a `java` on `PATH` (or `OPENSYSML_JAVA`). +- Provision once: `./scripts/download-doc-pdf-toolchain.sh` installs a pinned WeasyPrint venv, pandoc, mermaid-cli, KaTeX, Graphviz (unpacked from the upstream Ubuntu .debs; its `neato` plugin needs the host's `libgts-0.7-5`) the PlantUML jar and D2 under `build/doc-pdf/`. PlantUML needs a `java` on `PATH` (or `OPENSYSML_JAVA`). - Export before any PDF render (env does NOT persist between exec tool calls even in the same shell session — re-export in the same command line): - `OPENSYSML_WEASYPRINT=$PWD/build/doc-pdf/weasyprint/bin/weasyprint` - `OPENSYSML_MMDC=$PWD/build/doc-pdf/mermaid/node_modules/.bin/mmdc` @@ -15,8 +15,9 @@ description: How to end-to-end test the sysml PDF document backend (internal/doc - `OPENSYSML_KATEX=$PWD/build/doc-pdf/katex/node_modules/.bin/katex` (only a document with formulas needs it) - `OPENSYSML_DOT=$PWD/build/doc-pdf/graphviz/bin/dot` (for `-diagram-form dot`; absent, DOT blocks stay as source under a notice) - `OPENSYSML_PLANTUML_JAR=$PWD/build/doc-pdf/plantuml/plantuml-1.2026.8.jar` (for `-diagram-form plantuml`; absent, PlantUML blocks stay as source under a notice) + - `OPENSYSML_D2=$PWD/build/doc-pdf/d2/d2-v0.9.0/bin/d2` (for `-diagram-form d2`; absent, D2 blocks stay as source under a notice naming the variable) - `go test -run Installed ./internal/doc/docpdf` runs the real-toolchain integration tests (they skip per missing tool, `pdftotext`/`pdfimages` from `poppler-utils` included — the tests read every PDF back through them, so install the package or the text assertions never run); the rest of the package's tests use fake tools and need nothing installed. `OPENSYSML_REQUIRE_PDF_TOOLCHAIN=1` turns every such skip but Prince's into a failure — set it once the script has run, so a misconfigured variable cannot pass as a skip. This is how the CI `pdf-toolchain` job runs them. -- Diagram forms: render the worked example with `-diagram-form dot` and `-diagram-form plantuml` too; the PDF must contain no "did not draw" notice (pypdf `extract_text`) and the page's XObjects grow by one per diagram. With `OPENSYSML_DOT=/nonexistent` the render still succeeds and the notice names `OPENSYSML_DOT`. A `// layout: neato -n` block (the DOT writer emits one for a positioned diagram) must run neato: `dot -Kneato` failing with "no layout engine support" means the GTS library is missing. +- Diagram forms: render the worked example with `-diagram-form dot`, `-diagram-form plantuml` and `-diagram-form d2` too; the PDF must contain no "did not draw" notice (pypdf `extract_text`) and the page's XObjects grow by one per diagram. With `OPENSYSML_DOT=/nonexistent` the render still succeeds and the notice names `OPENSYSML_DOT`; the same with `OPENSYSML_D2`. A `// layout: neato -n` block (the DOT writer emits one for a positioned diagram) must run neato: `dot -Kneato` failing with "no layout engine support" means the GTS library is missing. ## Rendering - Worked example: `bin/sysml docs/manual/examples/observatory.sysml -render-document Observatory::MassReport -doc-form pdf -pdf-title-page -pdf-toc -pdf-number-sections -o /tmp/observatory.pdf`. It exercises emphasis, code span, external Link, Ref (`#breakdown`), a standalone `` anchor line, grouped table (`**zone: ...**` headings), numbered list and two mermaid diagrams. @@ -28,7 +29,8 @@ description: How to end-to-end test the sysml PDF document backend (internal/doc - Formulas: `internal/doc/docrender/testdata/math_report.sysml` → `Optics::OpticsReport` has an inline `$m \propto D^{2.5}_{\text{eff}}$` beside an escaped prose `\$`, a captioned `Formula` that is a `Ref` target, a multi-line display block, a `\$` inside math, and query-driven math list items. Render under both engines and check with pypdf: the page's `/Font` `/BaseFont`s must include `KaTeX_Main`, `KaTeX_Math-Italic` (and `KaTeX_Size3` for the tall parenthesis); extracted text must contain `∝`, `π`, `θ`, `λ` and none of `$$`, `\propto`, `\frac`, `_{`, `^{`, `katex`, `` text, which paged-media engines would set literally. A malformed formula (`\frac{a}`) fails the run with `KaTeX parse error: ...` from the CLI's stderr, not a PDF with a gap. - `docpdf.Render`'s second argument is the engine NAME (`"weasyprint"`, `"pandoc"`, `"prince"`), not a binary path; binaries come from the OPENSYSML_* env vars. -- Diagrams: Mermaid is drawn once through `mmdc` and the image replaces the block (`HTMLOptions.DiagramImages` / the Lua filter); a table-kind diagram stays a table. DOT and PlantUML are not drawn — the PDF carries an emphasized notice and the source, under both engines. +- Diagrams: each is drawn once through its tool and the image replaces the block (`HTMLOptions.DiagramImages` / the Lua filter); a table-kind diagram stays a table. Mermaid's `mmdc` is required; Graphviz, the PlantUML jar and `d2` are optional — absent, the PDF carries an emphasized notice and the source, under both engines. +- D2 needs pixels, not text: `d2` writes the `` that cuts a connection's label out of its line after the connections, and WeasyPrint draws such a mask as a white rectangle over the whole figure while `pdftotext` still extracts every label. `docpdf/d2.go` `defineMasks` moves the masks under ``; regress it with a labelled connection (`telescope_report.sysml`'s `link`), rasterize the page (`pdftoppm -r 100 -png`) and look for the nested containers, the state glyphs and the gap in the line around the label, under both engines. - Linked sets: `-render-documents ... -doc-form pdf` is refused with a typed error; render each document with `-render-document`. A `Ref` into another document then links to that document's page file name, which no PDF beside it carries. ## Verifying the PDF is real, not literal diff --git a/.agents/skills/testing-docs-site/SKILL.md b/.agents/skills/testing-docs-site/SKILL.md new file mode 100644 index 0000000000..4b8e55e43c --- /dev/null +++ b/.agents/skills/testing-docs-site/SKILL.md @@ -0,0 +1,72 @@ +--- +name: testing-docs-site +description: How to build and browser-test the MkDocs documentation site locally — genuine Material instant navigation, Back/forward restoration, palette toggle and responsive landing-page behavior — without changing repository configuration. +--- + +# Testing the documentation site in a browser + +Use a virtualenv with `pip install -r docs-requirements.txt`. Do not change repository configuration +to serve a local preview: build to a directory outside the checkout and serve that with +`python -m http.server`. + +For instant-navigation testing, the build's `site_url` must match the local origin, port included. +Material's sitemap resolver can rewrite protocol and hostname without rewriting the port, so a +production build served on a nonstandard local port can silently fall back to full navigation. + +From the repository root, build through the MkDocs API with runtime overrides: + +```python +from mkdocs.config import load_config +from mkdocs.commands.build import build +config = load_config( + "mkdocs.yml", + site_url="http://localhost:8766/", + site_dir="/site-browser-test", + strict=True, +) +build(config) +``` + +Serve that directory on the matching port. Verify instant navigation rather than assuming it: read +`performance.timeOrigin`, click an internal link through the UI, and read it again; it must not +change. Test browser Back too, and check visually that page-specific UI is removed and restored +without duplicates. + +Use the header palette toggle for light and dark modes, and DevTools responsive mode for narrow +widths. Verify pixels, not just element presence. + +If browser inspection tooling appears to alter `target="_blank"` attributes, repeat the external-link +tests from a fresh page without annotated DOM inspection before the click. Read-only CDP inspection +plus native mouse input distinguishes tooling interference from the built page's behavior. + +When the change under test is in `overrides/` — a stylesheet or template — do not +assume `mkdocs serve` reloaded it. The live reload may keep serving the old asset +through a browser hard refresh. Restart the server, wait for the build and serving +messages, reload, and confirm the loaded file carries the intended rule before +diagnosing a CSS fix as ineffective. Do not change repository configuration solely +to make a preview work. + +### The in-browser engines + +The landing diagram and the CLI page's REPL load generated assets: `docs/assets/sysml-engine.wasm.gz`, +`sysml-repl.wasm.gz`, `wasm_exec.js` and `repl-examples/`. They are gitignored; if any is missing, +run `make docs-engine-assets` before building the site. + +The REPL walkthroughs' inputs and expected output strings live in `docs/assets/repl-walkthroughs.json`. +Run each step through its button and check only newly appended terminal output. Wait for the input +queue to drain and `is-busy` to clear before sending the next command. + +To check the SMT refusal, declare a valid constraint first, for example +`constraint def Positive { in x : ScalarValues::Integer; x > 0 }`, then run `%check Positive`. Expect +the message that a WebAssembly build cannot start external processes. + +Completion considers every matching name, standard library included. `%inst` has several candidates; +`%instanti` has one. For a shared-prefix test, pick names that no library name shares. + +At mobile width, navigate through the header menu. `label[for="__drawer"]` also matches a hidden +overlay, so scope automation to `header label[for="__drawer"]`. Compare the document's width with the +viewport's; a code textarea's own horizontal scrolling is not page overflow. + +### Devin Secrets Needed + +None for the public local site and public outbound link checks. diff --git a/.agents/skills/testing-engine-browser/SKILL.md b/.agents/skills/testing-engine-browser/SKILL.md new file mode 100644 index 0000000000..4842cc2c73 --- /dev/null +++ b/.agents/skills/testing-engine-browser/SKILL.md @@ -0,0 +1,55 @@ +--- +name: testing-engine-browser +description: How to build sysml-engine for js/wasm and exercise it in a real browser through an external static harness — JSON-RPC envelopes, typed values, error recovery after bad calls, and startup/transfer measurements. +--- + +# Testing `sysml-engine` in a browser + +## Setup + +- Read `docs/reference/wasm.md`, `cmd/sysml-engine/serve_js.go`, `internal/frontend/engine/json.go` + and `tests/wasm/testdata/` first. +- Keep the harness page outside the checkout. The targeted build can live outside it too: + `make build-wasm-js WASM_COMMANDS=sysml-engine WASM_DIR=/build`. +- Serve the page with `python3 -m http.server 8765 --bind 127.0.0.1 --directory `. +- Load the matching `build/js/wasm_exec.js`. Instantiate the module with `new Go().importObject` and + `WebAssembly.instantiateStreaming`; check the server sends `application/wasm`, and fall back to + `arrayBuffer` if it does not. +- Run with no extra argv. Do not await `go.run(instance)` before checking `globalThis.sysmlEngine`: + the engine stays alive on purpose. Record promise rejection and unexpected resolution as runtime + events. + +## Browser controls and assertions + +- Give the page editable source textareas for `model.sysml` and `engine.sysml`, and + Parse/Evaluate/ExecuteAction/ExecuteState/Instantiate controls. +- Use the lowerCamelCase fields from `json.go`. ParseSources takes `{documents:[{name,content}]}`. + Reuse the `modelHash` it returns; never hardcode one. +- `sysmlEngine.call(method, JSON.stringify(params))` synchronously returns a JSON-RPC envelope as a + string, not the bare protojson result. +- Fixture expectations: total realValue 6; speed quantity 5 metres/second; `Color::red` enum; Double + input `x:{intValue:"6"}` returns y `"12"`; Switch visits `"on"` and instantiates a graph. +- SysML sequences are written `(1, 2, 3)`, not `[1,2,3]`. A parity-only test can treat matching + error payloads as equal, so assert actual values in browser checks. +- Probe unknown modelHash (5), unsupported method (12), `schedule:"explore"` (12), malformed params + JSON (3) and invalid source (diagnostics with spans). After every error, reparse edited source and + confirm a new hash and the updated value. +- To enter fixture text with literal tabs into a textarea, paste it through the clipboard; typed tab + keystrokes move focus to the buttons. Plain spaces are safe to type. + +## Evidence + +- Open DevTools Network before the first navigation; record transfer size and response headers. +- Take `performance.now()` when the global first appears: that is navigation-to-ready time. Record + the local HTTP compression and cache configuration; an uncompressed localhost transfer is not + comparable with a production gzip budget. +- Keep raw request/response envelopes and per-call durations, but drive every call through the + visible UI. +- Capture console errors, unhandled rejections, Go panic/exit events, and that calls keep working + after the error probes. +- Record the build commit and the displayed version. A later checkout does not change a wasm file + that is already being served. + +### Devin Secrets Needed + +None. The page runs the engine entirely inside the browser. diff --git a/.agents/skills/testing-node-browser-client/SKILL.md b/.agents/skills/testing-node-browser-client/SKILL.md new file mode 100644 index 0000000000..9d00a5b88d --- /dev/null +++ b/.agents/skills/testing-node-browser-client/SKILL.md @@ -0,0 +1,46 @@ +--- +name: testing-node-browser-client +description: How to test the @openmbee/opensysml browser entry point (client/node/src/browser) against a real sysml-grpc service — CORS-allowed origins, observing requests without altering them, decoding captured bodies, protobuf versus JSON encodings, and differential comparison with the Python client. +--- + +# Testing the Node client's browser entry point + +## Setup + +- Read the browser section of `client/node/README.md` and `docs/reference/node-api.md` (In a + browser), and `client/node/test/browser.test.ts`, which starts the service the way a page needs it. +- Build the service (`make build-grpc`) and start it with the page's exact origin allowed: + `bin/sysml-grpc -port 0 -health-port 0 -report-address -cors-allowed-origins `. The first + stdout line is the address. Never allow `*`; an HTTPS page also needs `-tls-cert`/`-tls-key`. +- Bundle a harness page importing `@openmbee/opensysml/browser` outside the checkout and serve it from + the allowed origin. `connect({ address })` is the only path: a browser cannot start a service. + +## Shared-core browser validation + +- Wrap the browser's `fetch` only to observe and forward the original request and response + unchanged. Counting observed service calls distinguishes a client-side refusal from a similarly + typed backend error; assert zero requests for invalid inputs. +- Decode captured unary request bodies with the generated schema and protobuf `fromBinary`/`fromJson`. + That verifies `strictConformance` and collection union kinds while the real request still runs. + Never substitute responses. +- Verify collection conversion with a real Echo calculation, not only `toValue`: check both the + outgoing sequence/set union and the returned values. +- Read the public result types before writing assertions. Verification methods can return a wrapper + with `.verdict.standing`, while `CalcResult` has `.standing`. +- Run every check under both protobuf and JSON (`connect({ encoding: "json" })`). Record the concrete + error class, code, `instanceof` result and elapsed time rather than assuming browser `fetch` maps + failures the way Node gRPC does; an unreachable service or CORS refusal arrives as `UNKNOWN`. + +## Differential options and baseline preservation + +- When reusing saved Python output, verify every baseline file is unchanged. +- Compare option semantics, not just spelling: Python `strict` raises on parsing diagnostics, while + Node's `strict` is a deprecated alias of `strictConformance`. That intentional difference can change + hashes for otherwise identical source. Report it, or agree on equivalent inputs before regenerating + baselines; do not quietly remove or normalize the hash field. +- Keep approved differences separate from newly acknowledged intentional exceptions. A prior finding + is not automatically an approved difference. + +### Devin Secrets Needed + +None for a local unauthenticated service and an external harness. diff --git a/.agents/skills/testing-pilot-corpora-gate/SKILL.md b/.agents/skills/testing-pilot-corpora-gate/SKILL.md index 4ce54f070c..28d75ebd5d 100644 --- a/.agents/skills/testing-pilot-corpora-gate/SKILL.md +++ b/.agents/skills/testing-pilot-corpora-gate/SKILL.md @@ -12,7 +12,7 @@ Four pinned OMG model roots, **one mechanism, two policies** (`tests/corpus/corp | root | dir | policy | expectation file | |---|---|---|---| -| training | `examples/sysml-v2-training` | **assertion** — errors only, `.sysml` only, must be 100% clean, no per-file counts allowed | `testdata/training_examples_expected.txt` | +| training | `examples/sysml-v2-training` | **assertion** — zero semantic errors, `.sysml` only; warnings are outside the gate, no per-file counts allowed | `testdata/training_examples_expected.txt` | | sysml-examples | `examples/pilot-corpora/sysml-examples` | per-file **ratchet**, every severity | `testdata/pilot_corpora_expected.txt` | | sysml-validation | `examples/pilot-corpora/sysml-validation` | ratchet | same | | kerml-examples | `examples/pilot-corpora/kerml-examples` | ratchet | same | @@ -33,7 +33,7 @@ examples/sysml-v2-training /tmp/pristine/`) — every absence/mutation test move Expect `--- PASS` for `TestTrainingExamplesSemanticErrors`, `TestPilotCorporaDiagnostics` and `TestCorpusGatesCacheStateIndependent` (subtests `training` and `pilot-corpora` — the older `TestTrainingExamplesCacheStateIndependent` / `TestPilotCorporaCacheStateIndependent` no longer -exist), plus `100/100 training files clean` and three `N/M pilot corpus files clean` lines +exist), plus the error-only `100/100 training files clean` line and three `N/M pilot corpus files clean` lines (observed 2026-08: sysml-examples 76/98, sysml-validation 52/56, kerml-examples 51/58 — these move whenever diagnostics change, so read the current numbers from `docs/project/pilot-corpora.md` rather than hard-coding them). @@ -183,8 +183,8 @@ gate's own helpers are package-private but reusable (`pilotCorporaGate.files(t)` `actionlint`, `shellcheck`, `python3 scripts/check-doc-links.py`, `gofmt`, `go vet`, `go run -C tools ./cmd/pilot-diff` (validators pre-downloaded; ~4min, prints e.g. -the headline the committed baseline holds — `380 file(s), 345 fully agreeing; 38 agreed -diagnostic(s), 41 only ours, 1614 only the pilot's` at the `2026-08` pin, so read it from +the headline the committed baseline holds — `391 file(s), 353 fully agreeing; 45 agreed +diagnostic(s), 282 only ours, 92 only the pilot's` at the `2026-08` pin, so read it from `docs/project/pilot-differential-baseline.json` rather than from this line) and `make lint` (staticcheck+gosec, ~2min) all work. There is **no** `yamllint` and **no** `circleci` CLI, so `.circleci/config.yml` can only be parsed as YAML, not schema-validated — say so diff --git a/.agents/skills/testing-pilot-differential/SKILL.md b/.agents/skills/testing-pilot-differential/SKILL.md index 5089fde81a..259bf3c760 100644 --- a/.agents/skills/testing-pilot-differential/SKILL.md +++ b/.agents/skills/testing-pilot-differential/SKILL.md @@ -21,11 +21,14 @@ GNU-format diagnostics **relative to `--root`**. Consequences for testing: - The pin `tools/referee/diff` reports comes from `build/pilot-sysml-validator/pilot-pin.txt` (written by the new script), not from the DeciSym `pom.xml`. - `-validator /nonexistent` now says `run ./scripts/download-pilot-sysml-validator.sh`. -- Measured at the `2026-08` pin after bare parameters took their effective range `[0..*]`, removing - the adjudicated `Behaviors.kerml:14` multiplicity warning (the `[1]` `RocketEquation` inputs keep - its warning at `delta-v-budget.sysml:93`): `380 file(s), 345 fully agreeing; 38 agreed, 41 only - ours, 1614 only the pilot's`, JSON totals `openSysMLDiagnostics 82 / pilotDiagnostics 1655 / - severityMismatch 3`; ~2 min wall, byte-identical across runs *and* after a from-scratch rebuild of +- Measured at the `2026-08` pin after the view concern framing round retired the six view-body + `frame` `syntax` rows (bare parameters already at their effective range `[0..*]`, so the + adjudicated `Behaviors.kerml:14` multiplicity warning is gone while the `[1]` `RocketEquation` + inputs keep its warning at `delta-v-budget.sysml:93`): `391 file(s), 353 fully agreeing; 45 agreed, + 282 only ours, 92 only the pilot's`, JSON totals `openSysMLDiagnostics 328 / pilotDiagnostics 138 / + severityMismatch 1`; the two only-ours rows the multiplicity rule added are the expected `action-step-multiplicity-not-fixed` + warnings on `training/18. Action Performance/Action Performance Example.sysml:10` and + `pilot-examples/Camera Example/Camera.sysml:4`. ~2 min wall, byte-identical across runs *and* after a from-scratch rebuild of `build/pilot-validator`. The six `kerml-examples` pilot-only rows the `2026-07` run carried (`The opposite features 'owningType' … do not refer to each other`) are gone: the pilot fixed its `ownedDisjoining` delegate, which did not move our side at that time. `kerml-examples` carries no @@ -142,8 +145,11 @@ committed result of the *last refreshed* run, so **the harness is testable by re but only while the baseline is current. Check that first. The latest rebaseline, after bare parameters took their effective range `[0..*]` and removed the adjudicated `Behaviors.kerml:14` warning (the `[1]` `RocketEquation` inputs still produce the warning at -`delta-v-budget.sysml:93`), is current: a live run gives `380 file(s), 345 fully agreeing; 38 -agreed, 41 only ours, 1614 only the pilot's`, byte-identical to the committed baseline, and +`delta-v-budget.sysml:93`), is current: the action-step multiplicity rule adds the two expected +`action-step-multiplicity-not-fixed` warnings on `takePhoto[*]` in the training corpus and +`takePicture[*]` in `Camera Example/Camera.sysml`, and the view concern framing round retired the +six view-body `frame` `syntax` rows on `examples`; a live run gives `391 file(s), 353 fully +agreeing; 45 agreed, 282 only ours, 92 only the pilot's`, byte-identical to the committed baseline, and `docs/project/pilot-differential.md`'s "Results" table matches. The prior rebaseline, when the Legend of the Red Dragon example left for its own repository, gave `380 file(s), 344 fully agreeing; 38 agreed, 42 only ours, 1614 only the pilot's`. @@ -231,6 +237,38 @@ move?). A run at `82ff0fac` gives 0 changed, 0 removed, 10 added (`examples/parser_features_demo_*.kerml`). Files clean on both sides appear in no entry map at all, so a newly-compared clean file shows up only as `filesFullyAgreeing +1`. +## The OpenSysML libraries the reference is handed + +Every batch passes `--extension-library "/internal/workspace/libs/stdlib/OpenSysML Libraries"` +to the bridge (both `validate-sysml-batch` and `validate-kerml` take it, repeatably); the bridge +loads the directory like `sysml.library` — resolved against, never validated. There is no harness +flag for it: `resolve` takes `OpenSysML Libraries` under the standard-library root OpenSysML itself +loads (`internal/workspace/libs/stdlib`, or `OPENSYSML_LIBRARY_PATH` when set), so both sides of +the comparison always see the same library text; the directory is recorded in the baseline's +provenance as the `opensysml-libraries` input (14 files), and `resolve` fails loudly when it is +missing or outside the tree (`TestResolveFollowsTheLibraryRootOpenSysMLLoads`, +`TestResolveRefusesLibrariesOutsideTheRepository`). Without an override OpenSysML loads the +libraries embedded in the build, so `-repo ` is refused when that checkout's +`OpenSysML Libraries` differ byte for byte from the embedded ones — set `OPENSYSML_LIBRARY_PATH` +to that checkout's `internal/workspace/libs/stdlib` to have both sides load its text +(`TestResolveRefusesACheckoutWhoseLibrariesDiffer`). The Java-free provenance guard digests the +same directory `resolve` derives (`TestCurrentProvenanceFollowsTheLibraryOverride`). Proving it is +load-bearing: + +- `OPENSYSML_LIBRARY_PATH` pointed at a copy of the stdlib tree inside the repository (under + `build/`) whose `OpenSysML Libraries` holds one trivial `.sysml`, with `-out` elsewhere → the + headline moves on *both* sides: `45 agreed, 44 only ours, 85 only the pilot's` becomes + `728 agreed, 36 only ours, 944 only the pilot's`, the two validators now agreeing on the + unresolved `DocumentQueries`/`OOSEM`/`MOSA` references (`self-model/document.sysml` alone + carries ~347 `unresolved-reference`/`kind-mismatch` rows), the report's `libraries` field names + the override, and `-check` then fails on `inputs[opensysml-libraries].digest`. +- by hand: `build/pilot-sysml-validator/validate-sysml-batch examples/oosem-demo/oosem-demo.sysml` + prints ~294 rows; add `--extension-library "internal/workspace/libs/stdlib/OpenSysML Libraries"` + and it prints none. `--extension-library /nonexistent` → exit 2 + `Error: extension library not found`. +- the library files themselves never appear in the output: a diagnostic naming a path under + `OpenSysML Libraries` would be logged as `pilot output not attributable to a corpus file`. + ## Auditing a docs-only PR's numeric claims against the report Adjudication PRs (e.g. #356, the S1–S10 / F60–F69 classes) assert per-root, per-category and diff --git a/.agents/skills/testing-pilot-execution-referee/SKILL.md b/.agents/skills/testing-pilot-execution-referee/SKILL.md index 895d0dca09..aadc06853f 100644 --- a/.agents/skills/testing-pilot-execution-referee/SKILL.md +++ b/.agents/skills/testing-pilot-execution-referee/SKILL.md @@ -60,14 +60,16 @@ go run -C tools ./cmd/pilot-exec-diff # ~13 s wall with the default fix Use `-cases DIR` for another directory of `.cases` files, `-out DIR`, `-launcher PATH`, `-repo DIR`. Each `.cases` file lists `model: ` -lines followed by `id :: target :: expression` lines. Reports go to +lines followed by `id :: target :: expression` lines; a `by-design: ` line marks the +cases after it, so a disagreement among them buckets `differs-by-design` with the clause in the +report. Reports go to `build/pilot-exec-diff/pilot-exec-diff.{txt,json}`. -Reference values at the current implementation (446 cases, all twenty default +Reference values at the current implementation (456 cases, all twenty-one default fixtures): -`agree 211 · kind-only 1 · order-only 0 · disagree 30 · pilot-unevaluated 124 · -pilot-silent 21 · pilot-error 9 · ours-error 10 · ours-undetermined 28 · both-error 12 · -nondeterministic 0`. +`agree 217 · kind-only 1 · order-only 0 · disagree 29 · differs-by-design 5 · +pilot-unevaluated 124 · pilot-silent 21 · pilot-error 5 · ours-error 10 · ours-undetermined 28 · +both-error 16 · nondeterministic 0`. Four of the nine `pilot-error` are the whole of `unknown_bounds.cases`: the pilot rejects a model whose multiplicity bound names a valueless feature (`a : Real[n]`, `Must have a Natural value`) and resolves nothing in it afterwards, which is why those cases have a model of their @@ -148,8 +150,8 @@ pilot answers the representation's own. See `pilot-exec-diff: :: model no/such/model.sysml: stat : no such file or directory`. - **Additivity.** `go run -C tools ./cmd/pilot-diff` must still print the headline the - committed baseline holds (`380 file(s), 345 fully agreeing; 38 agreed - diagnostic(s), 41 only ours, 1614 only the pilot's` at the `2026-08` pin — read it from the baseline JSON, not from this line, since each + committed baseline holds (`391 file(s), 353 fully agreeing; 45 agreed + diagnostic(s), 282 only ours, 92 only the pilot's` at the `2026-08` pin — read it from the baseline JSON, not from this line, since each fix round moves it) and `jq -S` diff clean against `docs/project/pilot-differential-baseline.json`; `git status --porcelain` empty at the end. diff --git a/.agents/skills/testing-pilot-rejection/SKILL.md b/.agents/skills/testing-pilot-rejection/SKILL.md index 469792c9e6..9344646502 100644 --- a/.agents/skills/testing-pilot-rejection/SKILL.md +++ b/.agents/skills/testing-pilot-rejection/SKILL.md @@ -9,7 +9,7 @@ Sibling of `testing-pilot-differential` and `testing-pilot-xpect` (same pin `scripts/pilot-pin.sh`, same committed-baseline shape), but pointed the other way: the differential measures what the reference accepts and we reject; this oracle measures what the reference **rejects and we accept** — permissiveness gaps. Its corpus is committed under -`tools/referee/reject/testdata/negative/` (310 hand-written invalid models, one violated rule + citation +`tools/referee/reject/testdata/negative/` (313 hand-written invalid models, one violated rule + citation in each file's mandatory `// Invalid: ...` first line), so no corpus download exists. Method and findings: `docs/project/pilot-rejection.md`. @@ -35,8 +35,8 @@ As of the `semantic/` source (named pilot constraints, KerML and SysML, the cont succession rules, the feature-value overriding rule, the enumeration-variation rules, the send-action cases, the metadata typing, annotated-element and body rules, the trigger-argument typing rules, the owning-body member rules, the cross-subsetting rules, the variant port rule, the association arity, binary-link end count and multiplicity-bound typing rules, the result-expression ownership rules, and the annotation-ownership, binding-arity, feature-chain conformance, -conjugated typing and end-feature rules, the end-multiplicity, return-owner and single-conjugator rules, the keyword-first relationship end kinds, the prefix and body-context cases, the redefinition name-resolution cases, and the transition accept usage-typing rule), with a fresh library cache: -`310 case(s): 301 both reject, 0 only the pilot rejects, 9 only we reject, 0 both accept`, +conjugated typing and end-feature rules, the end-multiplicity, return-owner and single-conjugator rules, the keyword-first relationship end kinds, the prefix and body-context cases, the redefinition name-resolution cases, the transition accept usage-typing rule, and the connector-end accessibility of a qualified definition feature), with a fresh library cache: +`313 case(s): 304 both reject, 0 only the pilot rejects, 9 only we reject, 0 both accept`, byte-identical to the committed baseline. Any `both accept` case is a bug in the corpus (the case is not actually invalid under the loaded standard library) — fix the case, never ignore it. A candidate the pilot accepts because it does not enforce the named constraint is not a case either: @@ -48,7 +48,7 @@ record it under "Constraints the pilot declares but does not enforce" in The baseline is the default `auto` policy: the `extensions/` cases are judged under strict conformance (OpenSysML notation the reference rejects as a syntax error), everything else in the -default mode. The report names each case's mode and lists the four strict-only agreements +default mode. The report names each case's mode and lists the three strict-only agreements separately, so a strict agreement never reads as a default one. ```bash diff --git a/.agents/skills/testing-pilot-xpect/SKILL.md b/.agents/skills/testing-pilot-xpect/SKILL.md index ced55c2a43..7df55774e6 100644 --- a/.agents/skills/testing-pilot-xpect/SKILL.md +++ b/.agents/skills/testing-pilot-xpect/SKILL.md @@ -62,6 +62,13 @@ elsewhere 42` (silence 8), `warnings` `rows 113 | agree 99 | same-line 7 | sever `noErrors` falling 254 → 248 is wave 10E's unavoidable cost, not a regression to chase: the six visibility fixtures declare file-wide silence and the protected-import errors at once, so no implementation satisfies both. +After the part-definition rule (`validatePartUsagePartDefinition`) and the unresolved-reference +import hint landed, re-recorded 2026-10-01: `1293 agree (of which 248 wording-only), 33 disagree, +0 unlocated, 0 not adjudicated`, per-suite kerml 948/20 and sysml 345/13, `noErrors` `262 / 276`; +the three new `noErrors` rows are `IndividualTest`, `ItemTest` and `validation/valid/PartUsage` +(`A part must be typed by at least one part definition.`), and 16 kerml wording-only `errors` rows changed only +their recorded `ours` text (the hint). `pilot-xpect -check` now also runs in `pr.yml` +(`xpect-oracle`), so a verdict movement fails the pull request rather than the next nightly run. Read the live totals from the baseline rather than this paragraph; it is an anchor, not the check. **`wording-only` is a verdict, not a tolerance.** `tools/referee/xpect/wording.go` admits a row into @@ -416,8 +423,8 @@ census in `w5c_census_test.go` is live two ways: perturb one pinned triple (e.g. ## Regression neighbour `go run -C tools ./cmd/pilot-diff` (~1m12s) must still print the headline the *committed* baseline holds — -at the `2026-08` pin that is `380 file(s), 345 fully agreeing; 38 agreed diagnostic(s), 41 -only ours, 1614 only the pilot's`. Read the number out of +at the `2026-08` pin that is `391 file(s), 353 fully agreeing; 45 agreed diagnostic(s), 282 +only ours, 92 only the pilot's`. Read the number out of `docs/project/pilot-differential-baseline.json` rather than trusting this line, since a landing fix round moves it. When the baseline is itself stale (it was at `19a3ce03`, holding 273 / 281 / 317), a failing `cmp` against it is *not* evidence of an Xpect regression — compare the summary line, and see diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 04a7139404..6268636036 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -2939,6 +2939,77 @@ actually descend into: second part with its own `connect ox to oy`. The send must still fail with the typed error — if the scope walk goes too far it could pick up the unrelated part's connectors. +## Reflecting on connector ends and owned relationships (PR #938) + +Reflection is driven from the CLI: `bin/sysml model.sysml -e ""` with +`M = SequenceFunctions::last(Pkg::Asm::c1.metadata)`; every element prints as +`meta( : )`. The shapes below hold once a connector owns the ends its `connect` +clause writes and a chain target is reflected as the feature it denotes (PR #938); on a tree +without that change the end counts read `0`, `connectorEnd` fails and `chainingFeature` is +underived, while the relationship metaobjects (`ownedRedefinition`, `ownedSubsetting`, +`ownedSpecialization`) already derive. The pre-fix shapes double as A/B canaries against a binary +built from `develop`. A fixture that exercises the whole family: + +```sysml +package R { + private import ScalarValues::*; + port def P { attribute v : Real; } + part def Source { port y : P; } + part def Sink { port u : P; } + connection def C { end source[1] : P; end target[1] : P; } + interface def I { end a : P; end b : ~P; } + part def Asm { + part s : Source; + part k : Sink; + connection c1 : C connect [1] s.y to [1] k.u; + interface i1 : I connect s.y to k.u; + attribute x : Real; + part sub { attribute w : Real; } + } + part def Derived :> Asm { + attribute :>> x = 2.0; + attribute deep :>> sub.w; + } +} +``` + +- **The fixture must analyse clean before any reflection claim counts.** A single error makes + every `-e` run end in `sysml: model.sysml did not analyse cleanly` with no value printed, which + looks exactly like a reflection failure. The mistakes that produce it: `derived` as a feature + name (`"derived" is a reserved keyword … write 'derived' to use it as a name`); `attribute :>> x` + in the *same* body that declares `x` (`unresolved reference: x — did you mean R::Derived::x?`) — + a redefinition belongs in a subtype, as above; and a chain that passes through a port or other + reference (`:>> s.y.v`: `nested redefinition through reference y has no owned object to redefine + on`) — chain through a composite part (`:>> sub.w`) instead. Unnamed ends print identically + (`meta(R::Asm::c1:: : …)` twice), so tell them apart through + `.ownedReferenceSubsetting.referencedFeature.chainingFeature`, which names the chain + (`[meta(R::Asm::k : …PartUsage), meta(R::Sink::u : …PortUsage)]`), never through the printed text. +- **Count, do not just print.** For a binary connector typed by a definition with two named ends, + `SequenceFunctions::size(M.ownedMember)`, `size(M.ownedElement)`, `size(M.ownedFeature)`, + `size(M.ownedEndFeature)` and `size(M.connectorEnd)` are all `2`. `4` means the unnamed `connect` + ends were appended to the inherited `source`/`target` instead of replacing them by position; + `0` (with `ownedEndFeature = []` and `connectorEnd` failing + `no reflective metaclass classifies the element`) is the pre-fix shape. Per end, + `ownedSubsetting` has one element and it is the same `ReferenceSubsetting` that + `ownedReferenceSubsetting` yields. For `attribute :>> x`, `ownedRedefinition` is one + `Redefinition` whose `redefinedFeature` is the *supertype's* `x` (`meta(R::Asm::x : …)`) and + `ownedSubsetting` returns that same relationship. For a chain target, + `ownedRedefinition.redefinedFeature.chainingFeature` is the whole chain in order + (`[R::Asm::sub, R::Asm::sub::w]`), `redefinedFeature.owner` is the declaring attribute + (`R::Derived::deep`), the relationship's `ownedRelatedElement` is the chain feature, and the + relationship's own `ownedElement` is `[]` — a relationship owns its related elements directly, not + through a relationship of its own. A property the library declares but the engine does not + derive fails with `reflective feature is not derived: :: for `; + `inheritedFeature` is one, so it serves as the negative control. +- **Interface ends are `PortUsage`, connection ends are `ReferenceUsage`.** `i1.ownedEndFeature` + prints `[meta(R::Asm::i1:: : SysML::Systems::PortUsage), …]` while `c1`'s ends print + `SysML::Systems::ReferenceUsage`; expecting `ReferenceUsage` on an interface end is a wrong + expectation, not a defect. The relationship metaobjects are KerML metaclasses — + `KerML::Core::ReferenceSubsetting`, `KerML::Core::Redefinition`, and `KerML::Core::FeatureTyping` + from `ownedSpecialization` — and the chain feature is a `KerML::Core::Feature`. + `M.relatedFeature` / `sourceFeature` / `targetFeature` give the chain *tips* + (`R::Source::y`, `R::Sink::u`) and are the quickest check that an end attached where intended. + ## Driving a state machine and its transition effects on camera - **`-e` is not a file flag.** `sysml -e [file]` evaluates an expression; to load a model @@ -4223,7 +4294,9 @@ Read-only printer of the session buffer or one element: `%view Demo::report` prints `exposes` then `viewpoint conformance` with `satisfy structure (from Demo::StructureView): violated` and one line per concern -(`conforms` / `violated (framed by the viewpoint but not by the view)` / `unevaluable` + reason). +(`conforms` / `violated` naming the exposed element and condition / `unevaluable` + reason); the +view frames nothing itself — every concern the viewpoint frames is evaluated against what it exposes, +and a `frame` written in a view body draws a `nonstandard-notation` warning. A satisfy target that is a `requirementUsage` is diagnosed at load *and* reported as `unevaluable (satisfy target spec is a requirementUsage, not a viewpoint)` — never a silent pass. `%view` registers no object: `%instances` afterwards says none created, a repeat is identical, and a @@ -6180,3 +6253,62 @@ is `6 [one]`, and `800 [W] * 120 [s] * 0.7 [one]` in a calc returning `EnergyVal ### Devin Secrets Needed None for these local REPL/gRPC checks. + +## Inherited action results and rejected action returns + +- For inherited-result checks, use the runtime conformance fixtures + `action_result_inherited_from_calc_def`, `action_result_inherited_from_use_case_def`, + `action_result_inherited_from_use_case_library_result` and `action_result_read_by_performer` + under `internal/exec/runtime/testdata/conformance/`. The first, second and fourth do not import + `ScalarValues::*`, so validating CLI/REPL loads need a scratch copy that adds it. Keep the + originals unchanged, and compare the scratch text with them ignoring whitespace and that import. +- `%action test::Compute`, two `%step`s, then `%tokens` shows `r = 42` with the token at `done`; the + third `%step` completes and prints Results. The use-case counterpart `test::ByUC` gives `n = 7`. +- Keep validation separate from execution: a `return` owned directly by an action is diagnosed by + `%load`, while `%action` still reaches the runtime's guarded refusal. Python `Connection.load` + defaults to `strict=False`, so inspect its diagnostics, then call ExecuteAction to verify the + runtime refusal; `strict=True` alone tests only loading, not the runtime guard. +- Check both the decoded Python `type(value) is int` and the raw + `Value.WhichOneof("kind") == "int_value"` for small Integer results. After a refused action, run a + valid one on the same Connection to show the service did not panic. +- A REPL session with a diagnostic does not exit nonzero after a later successful `%eval` and + `%quit`; check the refusal in the output and liveness separately from CLI `-action`'s exit status. +- Compare an unwritten inherited result against an unwritten `out` in the same model. A completed + action's output map leaves both out rather than inventing a zero. + +## Prod builds and default-build parity + +- If other agents own branches or worktrees, create detached testing worktrees at the PR and + `origin/develop` revisions and record both hashes; do not switch a shared checkout. +- Build the PR twice, `go build -tags sysml_prod -o /sysml-prod ./cmd/sysml` and + without the tag to a separate binary, and build `origin/develop` untagged to a third. Raw builds + avoid unrelated version-string differences when comparing behavior. +- Keep fixtures, output files, transcripts and comparison scripts outside source checkouts. + +### Load-bearing fixture and output assertions + +- A compact fixture is `package P { part def A { attribute n : ScalarValues::Integer = 7; } part a : A; }`. +- `%instantiate P::a` then `%features P::a depth 0` shows a concrete `n = 7` without pages of + inherited nested features. +- `%features P::a json` returns a graph with `instance.featureValues.n.value.intValue == "7"`: + `featureValues` is a map, not a features array. +- In a build that omits instancegraph, `%features P::a json` says `unknown option "json"` and shows + usage without JSON; plain `%features` still shows `n = 7`. +- `-query 'sysml:name="A"' model.sysml` can print `✓ package P` before `P::A PartDefinition`, + because model loading writes declaration reports to stdout. Require the exact match line and exit + 0; do not assume stdout holds only match lines. + +### Comparing the default build against develop + +- Use the same relative input and output names in separate working directories, with fresh files, + so `(replaced the existing file)` notes do not differ. +- Capture complete stdout, stderr and status for identical REPL command streams and standalone + queries. +- Parse the JSON graph before comparing (protojson whitespace can differ); compare the remaining + text and the saved SysML/Turtle byte for byte. +- Validate saved Turtle independently with rdflib; check real triples and declarations rather than + trusting the save confirmation. + +### Devin Secrets Needed + +None for native local CLI/REPL tests. diff --git a/.agents/skills/testing-transformation-census/SKILL.md b/.agents/skills/testing-transformation-census/SKILL.md new file mode 100644 index 0000000000..f487cb7297 --- /dev/null +++ b/.agents/skills/testing-transformation-census/SKILL.md @@ -0,0 +1,107 @@ +--- +name: testing-transformation-census +description: How to verify the SysML v1 to v2 transformation census gate (tools/census/transformation) — provisioning the pinned OMG mapping model, checking the census document and its citations against the committed baseline, and the mutations the gate must catch. +--- + +# Testing the transformation census (`tools/census/transformation`) + +The census (`docs/project/sysml-v1-transformation-census.md`) answers "which of the OMG SysML v1 +to v2 transformation model's 783 mapping classes does OpenSysML's migrator carry out?". Its +denominator is extracted from the pinned XMI (`SysMLv1Tov2.xmi`, OMG document `ptc/25-04-10`) and +committed as `docs/project/sysml-v1-transformation-census-baseline.json` together with each +mapping's extracted fields (`package`, `qualified`, `abstract`, `from`, `to`, `generals`, +`operations`, `ocl` digest) and its census verdict (`status`, `implementation`, `tests`, `reason`, +`scope`). + +## Prerequisites + +- Go on PATH. Nothing else for `-check` without the model: it reads only committed files. +- The model comparison needs `./scripts/download-sysml-v1tov2.sh`, which writes + `build/sysml-v1tov2/SysMLv1Tov2.xmi`; `SYSML_V1TOV2_ROOT` points both the downloader and + `-check` at a different directory holding the file (absolute as-is, relative resolved + against the repository root; `-xmi` overrides everything). `-check` compares against the + model when present and skips (saying so) when absent; `-check -require-xmi` — or + `OPENSYSML_REQUIRE_SYSML_V1TOV2=1` — fails when it is absent, naming the downloader. +- The scope-token measurement reads the PSSM suite (`build/pssm/PSSM_TestSuite.xmi`, from + `./scripts/download-pssm-suite.sh`) and the migration fixtures + (`tests/migrate/testdata/xmi/*.xmi` plus `conformance/fixtures/vehicle.xmi`). `-check` recomputes + the counts when the suite is present and skips the comparison when absent unless + `OPENSYSML_REQUIRE_PSSM_SUITE=1`; `-measure` fails outright without it. CI downloads the model + and sets `OPENSYSML_REQUIRE_SYSML_V1TOV2=1`. + +## The core checks + +```bash +go run -C tools ./cmd/transformation-census -check # baseline ↔ document ↔ cites ↔ measurement (↔ model if present) +go run -C tools ./cmd/transformation-census -check -require-xmi +go test -C tools -count=1 ./census/transformation # the same gate plus extraction and mutation tests +``` + +Reproduction of the baseline: `-update` re-extracts the rows and keeps every recorded verdict, +so on a current tree it must leave the file byte-identical +(`git diff --exit-code docs/project/sysml-v1-transformation-census-baseline.json`). +`go run -C tools ./cmd/transformation-census` (no flag) rewrites the generated blocks of the +census document between the `` / `` markers and must +likewise be a no-op on a current tree. `-measure` recomputes only the scope tokens the rows use. + +## What the document generates + +Six blocks between `` / `` markers: +`source` (provenance line with the OCL split: 1,084 bodyCondition bodies of 1,088 OCL2.0 +specifications — the rest are recorded postconditions and owned rules), `summary` (totals plus a +per-package status table), `rows` (one table per package), `beyond` (migrator behaviours with no +OMG mapping class, from the baseline's `beyond` entries — each needs ≥1 implementation and ≥1 test +cite, resolved like a row's), `errata` (rows whose reason carries `candidate erratum:`, sorted by +package then name), and `gaps` (approximate/not-implemented rows **grouped by shared status, +scope and reason** — dozens of sub-mappings carry one scope — ranked by total measured count, +ties by first mapping name). + +Two CI env nuances: `-check` consults `OPENSYSML_REQUIRE_PSSM_SUITE` only for the measurement +comparison and `OPENSYSML_REQUIRE_SYSML_V1TOV2` only for the model comparison (a job that +provisions neither input sets both to `""` at step level so an ambient `=1` cannot fail it — +pr.yml sets them workflow-wide, CircleCI per provisioning step). + +## Mutations the gate must catch + +Each of these must make `-check` exit non-zero with a message naming the drift; restore the file +afterwards (`git checkout -- ` on a clean tree, or keep a copy). + +- Delete one `mappings` entry from the baseline: with the model present, the name is reported as + "in the model but not in the baseline"; and the document is reported stale. +- Insert a table row for a name the baseline lacks (inside the `rows` block): "`` is in the + census table but not in …baseline.json". +- Delete a table row: "`` is in …baseline.json but has no census row". +- Change a digit of the `**Census:**` line, or the source line's counts or digest: "a generated + block is stale". +- Edit a baseline row's extracted field (`from`, `to`, `ocl`, a general or operation): "extracted + fields drifted" when the model is provisioned. +- Point a row's `implementation` at a file that does not exist, or a function/`Type.method` it does + not declare: "the citations do not resolve". Same for a `tests` cite that is not a + `func TestX(t *testing.T)` in a `_test.go` file. +- Give a `faithful`/`approximate`/`known-failure` row an empty `implementation` or `tests`, or a + `not-implemented`/`approximate` row an empty `scope`, or any non-`faithful` row an empty + `reason`: the baseline is rejected before the document or the model is read. +- Use a scope token that is not `uml:` or `sysml:` (optionally qualified + `[attr]`/`[attr=value]` — the qualifier restricts to elements carrying the attribute, or a + direct child element of that name; the value form matches the attribute exactly), or one + `-measure` has not counted. +- Edit a recorded count under `measurement.counts` (with the PSSM suite present): "the recorded + measurement is stale". +- Edit `source.document`/`url`/`digest` away from `scripts/sysml-v1tov2-pin.sh`: the source block + is rejected before the model is read. +- Edit a `source` count (`packages`, `classes`, `mappings`, `oclBodies`, `oclSpecifications`): + with the model present, the recorded dimensions are reported stale. +- Hand-edit the `beyond` table or give a beyond entry a cite that does not resolve: the stale + block or the unresolved cite is reported. +- Hand-edit the `errata` or `gaps` table (a note, a group's count or figure): "a generated block + is stale". + +## Adjudicating a status change + +Verdicts are edited by hand in the baseline (the extracted fields are not). Moving a row to +✅/⚠️/🚧 needs an implementation cite that resolves (`.go:` or `.go:`) +and a test cite that resolves (`_test.go:`); a ❌/⚠️ row needs at least one +`uml:`/`sysml:` scope token (with an optional `[attr]`/`[attr=value]` qualifier +when only a subset applies, e.g. `uml:ObjectFlow[weight]`), after which `-measure` must re-run so the token is counted +and the gaps table re-orders, then `go run -C tools ./cmd/transformation-census` rewrites the +document — `-check` fails until all three agree. diff --git a/.circleci/config.yml b/.circleci/config.yml index e283f32c1a..05611ded17 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -185,6 +185,21 @@ commands: paths: - build/pssm + # The SysML v1 to v2 transformation model, one pinned file, gated by + # tools/census/transformation. + - restore_cache: + keys: + - sysml-v1tov2-v1-{{ checksum "scripts/download-sysml-v1tov2.sh" }}-{{ checksum "scripts/sysml-v1tov2-pin.sh" }} + + - run: + name: Download the SysML v1 to v2 transformation model + command: ./scripts/download-sysml-v1tov2.sh + + - save_cache: + key: sysml-v1tov2-v1-{{ checksum "scripts/download-sysml-v1tov2.sh" }}-{{ checksum "scripts/sysml-v1tov2-pin.sh" }} + paths: + - build/sysml-v1tov2 + # The fUML reference implementation's models and jar, gated by tools/referee/fuml; # the jar is never run here (docs/project/fuml-referee.md). - restore_cache: @@ -272,10 +287,23 @@ jobs: go vet ./... go vet -C tools ./... + # The third-party notices link the licence text this bundle copies into + # docs/assets/landing, which is generated rather than tracked. + - run: + name: Build the landing-page diagram + command: make docs-landing-assets + - run: name: Check documentation links command: python3 scripts/check-doc-links.py + # python-test runs the same link check under coverage (make scripts-coverage) + # on an executor without Node, so it reads the bundle built here instead. + - persist_to_workspace: + root: . + paths: + - docs/assets/landing + # The shipped pages are generated from each command's description, so a # flag added without regenerating them would ship a page for a command # that no longer exists. @@ -303,6 +331,15 @@ jobs: name: Check the validation-constraint census matches its baseline command: go run -C tools ./cmd/validation-census -check + # Same for the transformation census; this job provisions neither input, + # so the model and measurement comparisons run in the gate job that does. + - run: + name: Check the transformation census matches its baseline + command: go run -C tools ./cmd/transformation-census -check + environment: + OPENSYSML_REQUIRE_PSSM_SUITE: "" + OPENSYSML_REQUIRE_SYSML_V1TOV2: "" + # Changelog entries live in changes/unreleased/ until a release folds them in. - run: name: Check changelog fragments @@ -392,7 +429,10 @@ jobs: - install-fmpy # One scripts/race-shard.sh shard per node: internal/exec/runtime alone peaks near - # 6.5 GB under -race, so it shares its 8 GB node only sequentially (tests/wasm, tools). + # 8.5 GB under -race, so it shares its 8 GB node only sequentially (tests/wasm, tools). + # The race detector's memory is per process and the corpus-wide exploration alone + # accounts for half of that peak, so the package runs as two processes: the + # exploration, then everything else (4 GB each). - run: name: Run Go race tests command: | @@ -405,10 +445,14 @@ jobs: shard=${shards[$CIRCLE_NODE_INDEX]} pkgs=$(scripts/race-shard.sh "$shard") echo "shard $shard:"; echo "$pkgs" - go test -v -race -pgo=off -timeout 30m $pkgs if [ "$shard" = runtime ]; then + exploration='^TestExploreWithIsExploreOverTheConformanceCorpus$' + go test -v -race -pgo=off -timeout 30m -run "$exploration" $pkgs + go test -v -race -pgo=off -timeout 30m -skip "$exploration" $pkgs go test -v -race -pgo=off -timeout 30m ./tests/wasm go test -C tools -v -race -pgo=off -timeout 30m ./... + else + go test -v -race -pgo=off -timeout 30m $pkgs fi no_output_timeout: 30m environment: @@ -417,6 +461,7 @@ jobs: OPENSYSML_REQUIRE_TRAINING_CORPUS: "1" OPENSYSML_REQUIRE_PILOT_CORPORA: "1" OPENSYSML_REQUIRE_PSSM_SUITE: "1" + OPENSYSML_REQUIRE_SYSML_V1TOV2: "1" OPENSYSML_REQUIRE_FUML_SUITE: "1" OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1" # Same for the solver installed above: absent means failure, not skip. @@ -429,11 +474,13 @@ jobs: OPENSYSML_REQUIRE_REFERENCE_FMUS: "1" # A second, race-free pass, because the profile the scan reads is written - # with -coverpkg, which is too slow to instrument under -race. + # with -coverpkg, which is too slow to instrument under -race. Serially the + # instrumented suite overran the 60-minute job limit even on large, so it runs + # as the race test's four package shards, and go-coverage-merge joins them. go-coverage: executor: go-executor - # 4 vCPU: the instrumented suite takes ~39 min on medium. resource_class: large + parallelism: 4 steps: - checkout - go/load-cache @@ -445,13 +492,20 @@ jobs: - install-fmpy - run: - name: Write the coverage profile the scan reads - command: make coverage + name: Write this shard's coverage profile + command: | + shards=(runtime model export rest) + if [ "$CIRCLE_NODE_TOTAL" != "${#shards[@]}" ]; then + echo "error: parallelism is $CIRCLE_NODE_TOTAL, but scripts/race-shard.sh has ${#shards[@]} shards" >&2 + exit 1 + fi + make coverage-shard SHARD="${shards[$CIRCLE_NODE_INDEX]}" no_output_timeout: 30m environment: OPENSYSML_REQUIRE_TRAINING_CORPUS: "1" OPENSYSML_REQUIRE_PILOT_CORPORA: "1" OPENSYSML_REQUIRE_PSSM_SUITE: "1" + OPENSYSML_REQUIRE_SYSML_V1TOV2: "1" OPENSYSML_REQUIRE_FUML_SUITE: "1" OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI: "1" OPENSYSML_REQUIRE_SMT: "1" @@ -461,6 +515,27 @@ jobs: # Same for the Reference-FMUs and fmpy (tests/fmi). OPENSYSML_REQUIRE_REFERENCE_FMUS: "1" + - persist_to_workspace: + root: . + paths: + # coverage-.txt, one per node. + - coverage-*.txt + + # Joins the shards' profiles into the one the scan reads. + go-coverage-merge: + executor: go-executor + steps: + - checkout + - go/load-cache + - go/mod-download + + - attach_workspace: + at: . + + - run: + name: Write the coverage profile the scan reads + command: make coverage-merge + - persist_to_workspace: root: . paths: @@ -578,6 +653,15 @@ jobs: environment: OPENSYSML_REQUIRE_PSSM_SUITE: "1" + # The transformation census's baseline lists the mapping classes the pinned + # model declares; here the model is provisioned, so the baseline is + # compared against a fresh extraction. + - run: + name: Transformation census lists what the pinned model contains + command: go run -C tools ./cmd/transformation-census -check -require-xmi + environment: + OPENSYSML_REQUIRE_SYSML_V1TOV2: "1" + # The migration of the PSSM suite to v2 notation alone, so its report totals # are legible in the log and a skip cannot hide behind a green suite # (docs/project/pssm-migration.md). @@ -662,13 +746,16 @@ jobs: # The other half of the solver contract: with discovery pointed at an # executable that does not exist, solver-dependent tests skip and the - # absent-solver paths must still report one. + # absent-solver paths must still report one. The whole of internal/frontend/repl + # runs here, and it needs more than go test's default ten-minute budget. + # no_output_timeout: go test prints a package's -v output only once it finishes, + # and internal/frontend/repl alone takes eight to ten minutes, past CircleCI's 10m default. - run: name: Run solver-absent gate command: | set -o pipefail env -u OPENSYSML_REQUIRE_SMT OPENSYSML_SMT=/nonexistent/no-such-solver \ - go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log + go test -count=1 -v -timeout 30m ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log for name in TestCheckReportsAnAbsentSolver TestExplainReportsAnAbsentSolver \ TestSolveReportsAnAbsentSolver TestConfigureReportsAnAbsentSolver \ TestOptimizeReportsAnAbsentSolver; do @@ -677,6 +764,7 @@ jobs: exit 1 } done + no_output_timeout: 30m # The WebAssembly gate: both wasm targets compiled and vetted, the commands # linked and then run. Node is on the executor, so the require variable makes @@ -982,6 +1070,12 @@ jobs: make python-install # Pinned and wheel-only, as in .github/workflows/pr.yml. pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 pytest-cov==7.0.0 fmpy==0.3.32 mypy==2.4.0 types-protobuf==7.35.1.20260906 'pyright[nodejs]==1.1.414' + # make scripts-coverage runs the MkDocs hooks' tests, which build a site. + pip install --only-binary :all: -r docs-requirements.txt + # The Jupyter kernel package: its tests drive jupyter_client and build + # its wheels with python -m build. + pip install --only-binary :all: jupyter_client==8.10.0 build==1.6.1 + make jupyter-kernel-install # The integration tests connect to a service on the standard port with # auto_start=False, which is the explicit opt-in to one the client does not @@ -1017,6 +1111,10 @@ jobs: name: Run Python client tests command: OPENSYSML_REQUIRE_SERVICE=1 OPENSYSML_REQUIRE_REFERENCE_FMUS=1 OPENSYSML_REQUIRE_TYPECHECKERS=1 make python-coverage + - run: + name: Run the Jupyter kernel package tests + command: make jupyter-kernel-test + # The repository scripts the Go static checks run are Python too; running # them and their tests under coverage here credits what those checks execute. - run: @@ -1336,6 +1434,15 @@ jobs: cd client/node npm run lint + # The verification tests ask the service solver-backed questions; the + # solver is an external process the service finds on PATH. + - run: + name: Install Z3 + command: | + sudo apt-get update + sudo apt-get install -y z3 + z3 --version + - run: name: Run the tests command: | @@ -1350,8 +1457,11 @@ jobs: paths: - coverage-node.lcov - # --allow-skips because v1 covers 5 of the 15 RPCs; the skipped count is - # reported and the artifact records which scenarios they were. + # --allow-skips because three scenarios (parse/naming_no_source_is_invalid, + # parse_sources/naming_no_document_is_invalid and + # parse_sources/two_documents_of_one_name_are_invalid) ask for a request + # the client's public API refuses before asking the service; the skipped + # count is reported and the artifact records which scenarios they were. - run: name: Run the conformance suite through the client command: | @@ -1432,6 +1542,9 @@ jobs: # Nothing publishes the editors, but their manifests carry the same # version, so a tag fails here when one disagrees; no export needed. python client/python/scripts/check_version.py --editors > /dev/null + # jupyter-opensysml-kernel is published from this tag at the same + # version; build-jupyter-kernel-package reads it, this only checks. + python client/python/scripts/check_version.py --jupyter-kernel > /dev/null echo "export OPENSYSML_VERSION=${VERSION}" >> "$BASH_ENV" echo "export NPM_VERSION=${NPM_VERSION}" >> "$BASH_ENV" echo "export JAVA_VERSION=${JAVA_VERSION}" >> "$BASH_ENV" @@ -1563,6 +1676,186 @@ jobs: paths: - client/python/dist + # The Jupyter kernel's Python distributions, built like build-python-package: + # stamped from the sysml-jupyter-kernel binaries build-release-binaries + # produced, then proven to pin every one of them for the tag. One wheel per + # platform bundles that platform's kernel, so `pip install` alone registers + # it; the sdist carries none and downloads by the pins. + build-jupyter-kernel-package: + executor: python-executor + steps: + - checkout + - rehearsal-tag + # dist/jupyter from build-release-binaries: the kernel binaries whose + # digests the distribution ships. + - attach_workspace: + at: . + + - run: + name: Install build tooling + command: python -m pip install --upgrade pip build twine packaging + + - run: + name: Resolve the version from the tag + command: | + # Fails loudly when the tag names a version other than the one the + # package would publish, or the kernel package's _version.py + # disagrees with the Python client's, before anything is built. + VERSION="$(python client/python/scripts/check_version.py --jupyter-kernel)" + echo "export KERNEL_VERSION=${VERSION}" >> "$BASH_ENV" + echo "Building jupyter-opensysml-kernel ${VERSION}" + + - run: + name: Stamp this release's kernel digests into the distribution + command: | + ls -l dist/jupyter + python client/python/scripts/pin_release_checksums.py \ + --version "${CIRCLE_TAG}" \ + --from-binaries dist/jupyter \ + --table client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json + git diff --stat -- client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json + + - run: + name: Build the sdist and the platform wheels + command: | + # The sdist, then one wheel per platform bundling the kernel from + # dist/jupyter; the script opens each and checks what it carries. + scripts/build-jupyter-kernel-dist.sh dist/jupyter client/jupyter-kernel/dist + # The version is PEP 440 in canonical form and the build tools + # normalize the name, so these are the files; the release jobs rely on them. + ls client/jupyter-kernel/dist/"jupyter_opensysml_kernel-${KERNEL_VERSION}.tar.gz" > /dev/null + set -- client/jupyter-kernel/dist/jupyter_opensysml_kernel-"${KERNEL_VERSION}"-py3-none-*.whl + if [ ! -f "$1" ] || [ $# -ne 5 ]; then + echo "Error: expected five platform wheels for ${KERNEL_VERSION}, found $#: $*" + exit 1 + fi + + - run: + name: Check the metadata (twine check --strict) + command: python -m twine check --strict client/jupyter-kernel/dist/* + + # The job runs on Linux x86_64, so that platform's wheel is the one pip + # would pick here: installed alone, it must register the `sysml` kernelspec + # under the virtualenv and start the bundled kernel from it. + - run: + name: Install this platform's wheel into a clean virtualenv and run its kernel + command: | + python -m venv /tmp/verify-kernel-wheel + /tmp/verify-kernel-wheel/bin/python -m pip install --upgrade pip + /tmp/verify-kernel-wheel/bin/python -m pip install client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-manylinux*_x86_64*.whl + /tmp/verify-kernel-wheel/bin/python -m pip install jupyter_client + /tmp/verify-kernel-wheel/bin/python - \<<'EOF' + import json + import os + import subprocess + import sys + + import jupyter_opensysml_kernel + from jupyter_opensysml_kernel.binary import bundled_binary + + expected = os.environ["KERNEL_VERSION"] + assert jupyter_opensysml_kernel.__version__ == expected, ( + f"installed wheel reports {jupyter_opensysml_kernel.__version__}, expected {expected}" + ) + if bundled_binary() is None: + raise SystemExit("Error: the installed wheel bundles no sysml-jupyter-kernel") + specs = json.loads(subprocess.check_output( + [sys.executable, "-m", "jupyter_client.kernelspecapp", "list", "--json"], text=True, + ))["kernelspecs"] + resource_dir = specs.get("sysml", {}).get("resource_dir", "") + wanted = os.path.join(sys.prefix, "share", "jupyter", "kernels", "sysml") + if os.path.realpath(resource_dir) != os.path.realpath(wanted): + raise SystemExit(f"Error: Jupyter lists the sysml kernelspec at {resource_dir!r}, not {wanted}") + argv = specs["sysml"]["spec"]["argv"] + if argv[:3] != ["python", "-m", "jupyter_opensysml_kernel"]: + raise SystemExit(f"Error: the installed kernelspec starts {argv}, not the package launcher") + # The launcher execs the bundled kernel, which reports the tag. + reported = subprocess.check_output([sys.executable, "-m", "jupyter_opensysml_kernel", "-version"], text=True) + if f"version {os.environ['CIRCLE_TAG']}" not in reported: + raise SystemExit(f"Error: the bundled kernel reports {reported!r}, not {os.environ['CIRCLE_TAG']}") + print(f"jupyter-opensysml-kernel {expected} registered sysml at {resource_dir} and starts {reported.splitlines()[0]!r}") + EOF + # The kernelspec `install` would write names the binary it copies in. + /tmp/verify-kernel-wheel/bin/python -m jupyter_opensysml_kernel kernelspec + + # A distribution whose table lacks any of the five kernel assets for the + # tag, or pins a digest other than the one its binary hashes to, fails + # the job here: every platform wheel and the sdist. The installed wheel + # is asked too, through the lookup `install --release` makes. + - run: + name: Verify the distributions embed this release's kernel digests + command: | + /tmp/verify-kernel-wheel/bin/python - "${CIRCLE_TAG}" client/jupyter-kernel/dist/jupyter_opensysml_kernel-[0-9]*.tar.gz client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-*.whl \<<'EOF' + import hashlib + import json + import os + import sys + import tarfile + import zipfile + + from jupyter_opensysml_kernel.binary import pinned_digest + + tag, sdist_path, *wheel_paths = sys.argv[1:] + if len(wheel_paths) != 5: + raise SystemExit(f"Error: expected five platform wheels, got {wheel_paths}") + required = { + "sysml-jupyter-kernel-darwin-amd64", + "sysml-jupyter-kernel-darwin-arm64", + "sysml-jupyter-kernel-linux-amd64", + "sysml-jupyter-kernel-linux-arm64", + "sysml-jupyter-kernel-windows-amd64.exe", + } + built = {} + for asset in sorted(required): + with open(os.path.join("dist", "jupyter", asset), "rb") as f: + built[asset] = hashlib.sha256(f.read()).hexdigest() + + def check(what, table): + digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {}) + missing = sorted(required - digests.keys()) + if missing: + raise SystemExit( + f"Error: {what} has no complete digest pin for {tag}: " + f"{', '.join(missing)}" + ) + wrong = sorted(a for a in required if digests[a] != built[a]) + if wrong: + raise SystemExit( + f"Error: {what} pins a digest for {tag} other than the one " + f"its binary hashes to: {', '.join(wrong)}" + ) + print(f"Verified {what} embeds all five kernel digests for {tag}.") + + for wheel_path in wheel_paths: + with zipfile.ZipFile(wheel_path) as wheel: + with wheel.open("jupyter_opensysml_kernel/release-digests.json") as member: + check(os.path.basename(wheel_path), json.load(member)) + with tarfile.open(sdist_path, "r:gz") as sdist: + name = next( + (n for n in sdist.getnames() if n.endswith("/jupyter_opensysml_kernel/release-digests.json")), + None, + ) + if name is None: + raise SystemExit("Error: the sdist has no jupyter_opensysml_kernel/release-digests.json.") + with sdist.extractfile(name) as member: + check("the sdist", json.load(member)) + unpinned = sorted(a for a in required if pinned_digest(tag, a) != built[a]) + if unpinned: + raise SystemExit( + f"Error: the installed wheel does not pin {tag} for: {', '.join(unpinned)}" + ) + print(f"Verified the installed wheel pins {tag} for every kernel asset.") + EOF + + - store_artifacts: + path: client/jupyter-kernel/dist + destination: jupyter-opensysml-kernel-dist + + - persist_to_workspace: + root: . + paths: + - client/jupyter-kernel/dist + # Runs the built wheel against the release published by the same workflow. python-zero-config-smoke: executor: python-executor @@ -1726,6 +2019,110 @@ jobs: - rehearsal-skip: what: "twine upload" + # Publishes jupyter-opensysml-kernel from the same core tag, with the same + # token, index routing and refusals as publish-pypi; independent of it so + # neither project refusing a re-run blocks the other. + publish-pypi-jupyter-kernel: + executor: python-executor + steps: + - checkout + - rehearsal-tag + - attach_workspace: + at: . + + - run: + name: Install publishing tooling + command: python -m pip install --upgrade pip twine packaging + + - run: + name: Resolve the version from the tag + command: | + VERSION="$(python client/python/scripts/check_version.py --jupyter-kernel)" + PRE_RELEASE="$(python client/python/scripts/check_version.py --pre-release)" + echo "export KERNEL_VERSION=${VERSION}" >> "$BASH_ENV" + echo "export OPENSYSML_PRE_RELEASE=${PRE_RELEASE}" >> "$BASH_ENV" + echo "Publishing jupyter-opensysml-kernel ${VERSION} (pre-release: ${PRE_RELEASE})" + # The distributions must be the ones build-jupyter-kernel-package made for this tag. + ls -l client/jupyter-kernel/dist/"jupyter_opensysml_kernel-${VERSION}.tar.gz" + set -- client/jupyter-kernel/dist/jupyter_opensysml_kernel-"${VERSION}"-py3-none-*.whl + if [ ! -f "$1" ] || [ $# -ne 5 ]; then + echo "Error: expected five platform wheels for ${VERSION}, found $#: $*" + exit 1 + fi + ls -l "$@" + + - run: + name: Require the publishing token + command: | + if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then + if [ -z "${TEST_PYPI_API_TOKEN:-}" ]; then + echo "Error: TEST_PYPI_API_TOKEN is not set, and ${CIRCLE_TAG} names a" + echo "pre-release version, which is published to TestPyPI." + echo "Add it to the 'PyPI' context (Organization Settings ->" + echo "Contexts) as a TestPyPI API token. See docs/project/releasing.md." + exit 1 + fi + elif [ -z "${PYPI_API_TOKEN:-}" ]; then + echo "Error: PYPI_API_TOKEN is not set. This job authenticates to PyPI" + echo "with an API token, not OIDC, and refuses to run twine without one." + echo "The token must be allowed to upload jupyter-opensysml-kernel as" + echo "well as opensysml. Add it to the 'PyPI' context (Organization" + echo "Settings -> Contexts). See docs/project/releasing.md." + exit 1 + fi + + - run: + name: Refuse a version the index already has + command: | + if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then + INDEX_HOST="test.pypi.org" + else + INDEX_HOST="pypi.org" + fi + STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ + "https://${INDEX_HOST}/pypi/jupyter-opensysml-kernel/${KERNEL_VERSION}/json") + if [ "$STATUS" = "200" ]; then + echo "Error: jupyter-opensysml-kernel ${KERNEL_VERSION} is already on ${INDEX_HOST}." + echo "A PyPI version is immutable, so this job fails rather than" + echo "half-publishing. If this is a re-run of an already published" + echo "release, this failure is expected and the package needs no action." + echo "If the package itself must change, that is a new core release." + exit 1 + fi + echo "${INDEX_HOST} has no jupyter-opensysml-kernel ${KERNEL_VERSION} (HTTP ${STATUS}); continuing" + + - run: + name: Check the metadata (twine check --strict) + command: python -m twine check --strict client/jupyter-kernel/dist/* + + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: Upload to the index + command: | + export TWINE_USERNAME=__token__ + if [ "$OPENSYSML_PRE_RELEASE" = "yes" ]; then + export TWINE_REPOSITORY_URL=https://test.pypi.org/legacy/ + export TWINE_PASSWORD="$TEST_PYPI_API_TOKEN" + echo "Uploading jupyter-opensysml-kernel ${KERNEL_VERSION} to TestPyPI" + else + export TWINE_PASSWORD="$PYPI_API_TOKEN" + echo "Uploading jupyter-opensysml-kernel ${KERNEL_VERSION} to PyPI" + fi + # No --skip-existing: a version that appeared since the check above + # must fail the job rather than pass silently. + python -m twine upload client/jupyter-kernel/dist/* + - when: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: "REHEARSAL: no twine upload" + command: | + echo "REHEARSAL: PyPI has no read-only token check; the token's presence was checked above." + - rehearsal-skip: + what: "twine upload" + # Publishes packaging/pypi-pysysml on a `pysysml-v` tag: the final # release of the pre-rename name, which raises on import to point at # opensysml. It shares the `PyPI` context, and like publish-pypi refuses to @@ -1888,6 +2285,38 @@ jobs: GOOS=windows GOARCH=amd64 make build-grpc VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" mv bin/sysml-grpc dist/grpc/sysml-grpc-windows-amd64.exe + + # The Jupyter kernel backs jupyter-opensysml-kernel, which downloads + # one file per platform and verifies it the same way, so these are + # published raw too, from their own subdirectory. + mkdir -p dist/jupyter + + GOOS=linux GOARCH=amd64 make build-jupyter-kernel VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/sysml-jupyter-kernel dist/jupyter/sysml-jupyter-kernel-linux-amd64 + + GOOS=linux GOARCH=arm64 make build-jupyter-kernel VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/sysml-jupyter-kernel dist/jupyter/sysml-jupyter-kernel-linux-arm64 + + GOOS=darwin GOARCH=amd64 make build-jupyter-kernel VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/sysml-jupyter-kernel dist/jupyter/sysml-jupyter-kernel-darwin-amd64 + + GOOS=darwin GOARCH=arm64 make build-jupyter-kernel VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/sysml-jupyter-kernel dist/jupyter/sysml-jupyter-kernel-darwin-arm64 + + GOOS=windows GOARCH=amd64 make build-jupyter-kernel VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/sysml-jupyter-kernel dist/jupyter/sysml-jupyter-kernel-windows-amd64.exe + + # A .sha256 sidecar beside each raw binary: the one checksum the + # packages that download it read. The package jobs check the binaries + # they bundle against these, and build-release checks them again + # before folding them into the signed manifest. + (cd dist/grpc && for f in sysml-grpc-*; do sha256sum "$f" > "$f.sha256"; done) + (cd dist/jupyter && for f in sysml-jupyter-kernel-*; do sha256sum "$f" > "$f.sha256"; done) + + # The Node client ships this combined WebAssembly module and matching Go runtime. + mkdir -p dist/wasm + make build-release-wasm VERSION="$VERSION" COMMIT="$COMMIT" BUILD_TIME="$BUILD_TIME" GO_VERSION="$GO_VERSION" + mv bin/wasm/release/sysml-wasm.wasm bin/wasm/release/wasm_exec.js dist/wasm/ # Create tarballs cd dist @@ -1921,8 +2350,8 @@ jobs: rm -rf stage # The checksum manifest and the per-file sidecars are - # build-release's, once the Python distribution exists to list. - ls -l . grpc + # build-release's, once the Python distributions exist to list. + ls -l . grpc jupyter # A Linux binary linked against this executor's glibc would not start on # older distributions; the Makefile builds without cgo, and this proves it. @@ -1932,7 +2361,8 @@ jobs: scripts/check-static-binaries.sh \ dist/sysml-linux-amd64 dist/sysml-linux-arm64 \ dist/sysml-lsp-linux-amd64 dist/sysml-lsp-linux-arm64 \ - dist/grpc/sysml-grpc-linux-amd64 dist/grpc/sysml-grpc-linux-arm64 + dist/grpc/sysml-grpc-linux-amd64 dist/grpc/sysml-grpc-linux-arm64 \ + dist/jupyter/sysml-jupyter-kernel-linux-amd64 dist/jupyter/sysml-jupyter-kernel-linux-arm64 # A binary whose version disagrees with its tag looks identical on the # release page. The host-platform builds are asked what they report; the @@ -1953,18 +2383,19 @@ jobs: status=1 } for binary in dist/sysml-linux-amd64 dist/sysml-lsp-linux-amd64 \ - dist/grpc/sysml-grpc-linux-amd64; do + dist/grpc/sysml-grpc-linux-amd64 \ + dist/jupyter/sysml-jupyter-kernel-linux-amd64; do reported="$("$binary" --version 2>&1 | head -n 1)" case " $reported " in *" ${CIRCLE_TAG} "*) echo "ok: $binary reports ${CIRCLE_TAG}" ;; *) fail "$binary" "it reports '${reported}'" ;; esac done - for binary in dist/sysml-* dist/grpc/sysml-grpc-*; do + for binary in dist/sysml-* dist/grpc/sysml-grpc-* dist/jupyter/sysml-jupyter-kernel-* dist/wasm/sysml-wasm.wasm; do case "$binary" in *.tar.gz|*.zip|*.sha256) continue ;; dist/sysml-linux-amd64|dist/sysml-lsp-linux-amd64) continue ;; - dist/grpc/sysml-grpc-linux-amd64) continue ;; + dist/grpc/sysml-grpc-linux-amd64|dist/jupyter/sysml-jupyter-kernel-linux-amd64) continue ;; esac if grep -qa -- "${CIRCLE_TAG}" "$binary"; then echo "ok: $binary carries ${CIRCLE_TAG}" @@ -1979,8 +2410,8 @@ jobs: paths: - dist - # Assembles the release from the binaries and the Python distribution the - # upstream jobs built, writes the one checksum manifest covering both, and + # Assembles the release from the binaries and the Python distributions the + # upstream jobs built, writes the one checksum manifest covering them all, and # signs it. Nothing is rebuilt here: the bytes hashed, signed and published # are the bytes those jobs verified. build-release: @@ -1999,17 +2430,24 @@ jobs: command: | cd dist - # The opensysml wheel and sdist, released with the binaries they drive. + # The opensysml wheel and sdist, released with the binaries they drive, + # and the jupyter-opensysml-kernel platform wheels and sdist likewise. cp ../client/python/dist/opensysml-*.whl ../client/python/dist/opensysml-*.tar.gz . + cp ../client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.whl \ + ../client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.tar.gz . # Checksums over every published archive, so a Homebrew formula # (or a careful user) can verify a download. sha256sum ./*.tar.gz ./*.zip ./*.whl | sed 's|\./||' > SHA256SUMS.txt - # Same for the raw gRPC binaries, plus a per-file sidecar, which is - # the only checksum opensysml reads. - (cd grpc && sha256sum sysml-grpc-* >> ../SHA256SUMS.txt) - (cd grpc && for f in sysml-grpc-*; do sha256sum "$f" > "$f.sha256"; done) + # Same for the raw gRPC binaries, through the per-file sidecars + # written beside them when they were built, the only checksum + # opensysml reads: checked against the bytes here, then folded in. + (cd grpc && sha256sum --check --strict sysml-grpc-*.sha256 && cat sysml-grpc-*.sha256 >> ../SHA256SUMS.txt) + # And the raw kernel binaries, the only checksum jupyter-opensysml-kernel reads. + (cd jupyter && sha256sum --check --strict sysml-jupyter-kernel-*.sha256 && cat sysml-jupyter-kernel-*.sha256 >> ../SHA256SUMS.txt) + (cd wasm && sha256sum sysml-wasm.wasm wasm_exec.js >> ../SHA256SUMS.txt) + (cd wasm && for f in sysml-wasm.wasm wasm_exec.js; do sha256sum "$f" > "$f.sha256"; done) cat SHA256SUMS.txt # The distribution was stamped from the binaries before the manifest @@ -2044,6 +2482,40 @@ jobs: print(f"Verified the wheel pins the five service digests the manifest lists for {tag}.") EOF + # The same proof for every kernel wheel over the kernel binaries. + - run: + name: Verify the Jupyter kernel wheels pin the kernel digests the manifest lists + command: | + python3 - "${CIRCLE_TAG}" dist/SHA256SUMS.txt dist/jupyter_opensysml_kernel-*-py3-none-*.whl \<<'EOF' + import json + import os + import sys + import zipfile + + tag, manifest_path, *wheel_paths = sys.argv[1:] + if len(wheel_paths) != 5: + raise SystemExit(f"Error: expected five kernel wheels, got {wheel_paths}; nothing was signed.") + listed = {} + with open(manifest_path, encoding="utf-8") as manifest: + for line in manifest: + fields = line.split() + if len(fields) != 2 or not fields[1].startswith("sysml-jupyter-kernel-"): + continue + if not fields[1].endswith(".sha256"): + listed[fields[1]] = fields[0] + for wheel_path in wheel_paths: + with zipfile.ZipFile(wheel_path) as wheel: + with wheel.open("jupyter_opensysml_kernel/release-digests.json") as member: + pinned = json.load(member).get("Open-MBEE/OpenSysML", {}).get(tag, {}) + if len(listed) != 5 or pinned != listed: + raise SystemExit( + f"Error: {os.path.basename(wheel_path)} pins {json.dumps(pinned, indent=2, sort_keys=True)} " + f"for {tag}, but the manifest lists " + f"{json.dumps(listed, indent=2, sort_keys=True)}; nothing was signed." + ) + print(f"Verified the five kernel wheels pin the five kernel digests the manifest lists for {tag}.") + EOF + - unless: condition: << pipeline.parameters.release_rehearsal >> steps: @@ -2110,7 +2582,7 @@ jobs: command: | export PATH="$(go env GOPATH)/bin:$PATH" cd dist - for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 opensysml-*-py3-none-any.whl; do + for artifact in opensysml-linux-amd64.tar.gz grpc/sysml-grpc-linux-amd64 jupyter/sysml-jupyter-kernel-linux-amd64 wasm/sysml-wasm.wasm opensysml-*-py3-none-any.whl jupyter_opensysml_kernel-*-py3-none-*.whl; do cosign verify-blob-attestation "$artifact" \ --bundle provenance.intoto.json.bundle \ --type slsaprovenance1 \ @@ -2127,7 +2599,7 @@ jobs: - rehearsal-skip: what: "cosign keyless signing and attestation (they write to the public Rekor log)" - run: - name: Verify the release carries the tag's Python distribution + name: Verify the release carries the tag's Python distributions command: | if [ -z "${CIRCLE_TAG}" ]; then echo "Error: CIRCLE_TAG is empty; this job only runs on a tag" @@ -2139,20 +2611,31 @@ jobs: echo "being released as: $2. Nothing was published." status=1 } - # build-python-package named the Python distribution after the tag's - # version (SemVer 'v0.9.0-rc1' is PEP 440 '0.9.0rc1'); one of each is due. - # The sdist is the one opensysml-*.tar.gz whose name continues with a digit; - # the platform archives (opensysml-linux-amd64.tar.gz, ...) continue with a letter. - for pattern in "dist/opensysml-*-py3-none-any.whl" "dist/opensysml-[0-9]*.tar.gz"; do + # build-python-package and build-jupyter-kernel-package named the + # distributions after the tag's version (SemVer 'v0.9.0-rc1' is PEP 440 + # '0.9.0rc1'); one of each is due, and five kernel wheels, one per + # platform. The sdist is the one opensysml-*.tar.gz whose name continues + # with a digit; the platform archives (opensysml-linux-amd64.tar.gz, ...) + # continue with a letter. + for pattern in "dist/opensysml-*-py3-none-any.whl" "dist/opensysml-[0-9]*.tar.gz" \ + "dist/jupyter_opensysml_kernel-[0-9]*.tar.gz"; do set -- $pattern if [ ! -f "$1" ]; then - fail "$pattern" "build-python-package did not produce it" + fail "$pattern" "the package job did not produce it" elif [ $# -ne 1 ]; then fail "$pattern" "matches $# files, not one" else echo "ok: $1 is the distribution for ${CIRCLE_TAG}" fi done + set -- dist/jupyter_opensysml_kernel-*-py3-none-*.whl + if [ ! -f "$1" ]; then + fail "dist/jupyter_opensysml_kernel-*-py3-none-*.whl" "the package job did not produce them" + elif [ $# -ne 5 ]; then + fail "dist/jupyter_opensysml_kernel-*-py3-none-*.whl" "matches $# files, not the five platform wheels" + else + echo "ok: $* are the kernel wheels for ${CIRCLE_TAG}" + fi exit $status - store_artifacts: @@ -2166,9 +2649,11 @@ jobs: root: . paths: - dist/SHA256SUMS.txt - - dist/grpc/*.sha256 + - dist/wasm/*.sha256 - dist/opensysml-*-py3-none-any.whl - dist/opensysml-[0-9]*.tar.gz + - dist/jupyter_opensysml_kernel-*-py3-none-*.whl + - dist/jupyter_opensysml_kernel-[0-9]*.tar.gz - unless: condition: << pipeline.parameters.release_rehearsal >> steps: @@ -2226,6 +2711,25 @@ jobs: echo "Publishing ${version} to the '${dist_tag}' dist-tag" # Proves the workspace carries the release binaries and sidecars. ls -l dist/grpc/sysml-grpc-* + ls -l dist/wasm/sysml-wasm.wasm dist/wasm/wasm_exec.js \ + dist/wasm/sysml-wasm.wasm.sha256 dist/wasm/wasm_exec.js.sha256 + + # The package's own copy of the table is the only pin the published + # client carries; stamping it here, from build-release's binaries, is + # what lets @openmbee/opensysml download this release without sigstore. + # The shared client/release-digests.json is not touched, so the + # sync check elsewhere in this pipeline is unaffected. + - run: + name: Stamp this release's service digests into the package + command: | + if ! command -v python3 >/dev/null; then + sudo apt-get update -q && sudo apt-get install -yq python3 + fi + python3 client/python/scripts/pin_release_checksums.py \ + --version "${CIRCLE_TAG}" \ + --from-binaries dist/grpc \ + --table client/node/release-digests.json + git diff --stat - run: name: Require the publishing token @@ -2243,17 +2747,19 @@ jobs: name: Refuse a version already on the registry command: | # The package names come from package.json, not a literal list: - # the client itself plus the optionalDependencies that carry the - # platform binaries. Anything but six means the manifest drifted. + # the client, platform binaries and optional WASM peer. Anything + # but seven means the manifest drifted. names=$(node -e " const pkg = require('./client/node/package.json'); const platforms = Object.keys(pkg.optionalDependencies || {}) .filter(n => n.startsWith(pkg.name + '-sysml-grpc-')); - console.log([pkg.name, ...platforms].join('\n')); + const wasm = Object.keys(pkg.peerDependencies || {}) + .filter(n => n.startsWith(pkg.name + '-wasm')); + console.log([pkg.name, ...platforms, ...wasm].join('\n')); ") count=$(echo "$names" | wc -l) - if [ "$count" -ne 6 ]; then - echo "Error: expected 6 packages to publish (client + 5 platform)," + if [ "$count" -ne 7 ]; then + echo "Error: expected 7 packages to publish (client + 5 platform + WASM)," echo "package.json yields $count:" echo "$names" exit 1 @@ -2271,6 +2777,8 @@ jobs: command: | chmod +x dist/grpc/sysml-grpc-linux-amd64 export OPENSYSML_BINARY="$(pwd)/dist/grpc/sysml-grpc-linux-amd64" + # Makes the suite assert that the table just stamped pins this tag. + export OPENSYSML_EXPECT_PINNED_RELEASE="${CIRCLE_TAG}" cd client/node npm ci npm run build @@ -2279,12 +2787,70 @@ jobs: npm test - run: - name: Build the per-platform packages + name: Build the platform and WASM packages command: | # These are build-release's bytes; the generator checks each one # against its .sha256 sidecar before packaging it. cd client/node - npm run platform-packages -- --binaries ../../dist/grpc + npm run platform-packages -- --binaries ../../dist/grpc --wasm ../../dist/wasm + + - run: + name: Pack the client and verify its pins + command: | + # The tarball packed here is the one published below, so what is + # verified is what ships. Its table must pin this tag for every + # service asset, with the digests of the binaries in dist/grpc. + rm -rf dist/npm && mkdir -p dist/npm + (cd client/node && npm pack --pack-destination ../../dist/npm) + tarballs=$(ls dist/npm/openmbee-opensysml-*.tgz) + if [ "$(echo "$tarballs" | wc -l)" -ne 1 ]; then + echo "Error: expected exactly one client tarball in dist/npm:" + echo "$tarballs" + exit 1 + fi + echo "export NPM_TARBALL=$(pwd)/${tarballs}" >> "$BASH_ENV" + python3 - "${tarballs}" "${CIRCLE_TAG}" dist/grpc \<<'PY' + import hashlib + import json + import os + import sys + import tarfile + + tarball_path, tag, binaries = sys.argv[1:] + required = { + "sysml-grpc-darwin-amd64", + "sysml-grpc-darwin-arm64", + "sysml-grpc-linux-amd64", + "sysml-grpc-linux-arm64", + "sysml-grpc-windows-amd64.exe", + } + with tarfile.open(tarball_path, "r:gz") as tarball: + try: + member = tarball.extractfile("package/release-digests.json") + except KeyError: + member = None + if member is None: + raise SystemExit("Error: packed tarball has no release-digests.json.") + with member: + table = json.load(member) + + digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {}) + missing = sorted(required - digests.keys()) + if missing: + raise SystemExit( + f"Error: packed tarball has no complete digest pin for {tag}: " + f"{', '.join(missing)}" + ) + for asset in sorted(required): + with open(os.path.join(binaries, asset), "rb") as binary: + actual = hashlib.sha256(binary.read()).hexdigest() + if digests[asset] != actual: + raise SystemExit( + f"Error: packed tarball pins {digests[asset]} for {asset} of {tag}, " + f"but the release binary is {actual}" + ) + print(f"Verified the packed tarball embeds all five service digests for {tag}.") + PY - run: name: Authenticate to npm @@ -2299,8 +2865,9 @@ jobs: # No --provenance: the npm CLI only mints attestations on GitHub Actions and # GitLab CI/CD, and it fails rather than publish unattested when asked here. - run: - name: Publish the per-platform packages + name: Publish the WASM and per-platform packages command: | + npm publish client/node/packages/sysml-wasm --access public --tag "$NPM_DIST_TAG" for directory in client/node/packages/sysml-grpc-*; do npm publish "$directory" --access public --tag "$NPM_DIST_TAG" done @@ -2308,18 +2875,19 @@ jobs: - run: name: Publish @openmbee/opensysml command: | - cd client/node - npm publish --access public --tag "$NPM_DIST_TAG" + # The tarball verified above, not a fresh pack of the directory. + npm publish "$NPM_TARBALL" --access public --tag "$NPM_DIST_TAG" - when: condition: << pipeline.parameters.release_rehearsal >> steps: - run: name: "REHEARSAL: pack every package" command: | + # The client itself was packed and verified above. + npm pack client/node/packages/sysml-wasm --dry-run for directory in client/node/packages/sysml-grpc-*; do npm pack "$directory" --dry-run done - (cd client/node && npm pack --dry-run) - rehearsal-skip: what: "npm publish" @@ -2336,6 +2904,10 @@ jobs: - checkout - rehearsal-tag + # build-release's dist/grpc: the binaries whose digests the jar pins. + - attach_workspace: + at: . + - restore_cache: keys: - maven-v1-{{ checksum "client/java/pom.xml" }}-{{ checksum "client/java/opensysml-client/pom.xml" }}-{{ checksum "client/java/opensysml-conformance/pom.xml" }} @@ -2368,6 +2940,25 @@ jobs: esac echo "export JAVA_VERSION=${version}" >> "$BASH_ENV" echo "Publishing org.openmbee:opensysml:${version}" + # Proves the workspace carries the release binaries. + ls -l dist/grpc/sysml-grpc-* + + # The jar's own copy of the table is the only pin the published client + # carries; stamping it here, from build-release's binaries, is what lets + # org.openmbee:opensysml download this release without sigstore. The + # shared client/release-digests.json is not touched, so the sync check + # elsewhere in this pipeline is unaffected. + - run: + name: Stamp this release's service digests into the jar's resources + command: | + if ! command -v python3 >/dev/null; then + sudo apt-get update -q && sudo apt-get install -yq python3 + fi + python3 client/python/scripts/pin_release_checksums.py \ + --version "${CIRCLE_TAG}" \ + --from-binaries dist/grpc \ + --table client/java/opensysml-client/src/main/resources/release-digests.json + git diff --stat - run: name: Require the publishing credentials @@ -2399,6 +2990,59 @@ jobs: esac done + - run: + name: Package the jar and verify its pins + command: | + # java-test ran the whole suite on this revision; only the test that + # reads the stamped resource runs again, now asserting it pins this + # tag. The jar packaged here is then opened and checked directly. + OPENSYSML_EXPECT_PINNED_RELEASE="${CIRCLE_TAG}" mvn -B -f client/java/pom.xml \ + clean package -pl :opensysml -am \ + -Dtest=ReleaseAssetsTest -Dsurefire.failIfNoSpecifiedTests=false + jar="client/java/opensysml-client/target/opensysml-${JAVA_VERSION}.jar" + if [ ! -f "${jar}" ]; then + echo "Error: mvn package did not create ${jar}." + exit 1 + fi + python3 - "${jar}" "${CIRCLE_TAG}" dist/grpc \<<'PY' + import hashlib + import json + import os + import sys + import zipfile + + jar_path, tag, binaries = sys.argv[1:] + required = { + "sysml-grpc-darwin-amd64", + "sysml-grpc-darwin-arm64", + "sysml-grpc-linux-amd64", + "sysml-grpc-linux-arm64", + "sysml-grpc-windows-amd64.exe", + } + with zipfile.ZipFile(jar_path) as jar: + if "release-digests.json" not in jar.namelist(): + raise SystemExit("Error: packaged jar has no release-digests.json.") + with jar.open("release-digests.json") as member: + table = json.load(member) + + digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {}) + missing = sorted(required - digests.keys()) + if missing: + raise SystemExit( + f"Error: packaged jar has no complete digest pin for {tag}: " + f"{', '.join(missing)}" + ) + for asset in sorted(required): + with open(os.path.join(binaries, asset), "rb") as binary: + actual = hashlib.sha256(binary.read()).hexdigest() + if digests[asset] != actual: + raise SystemExit( + f"Error: packaged jar pins {digests[asset]} for {asset} of {tag}, " + f"but the release binary is {actual}" + ) + print(f"Verified the packaged jar embeds all five service digests for {tag}.") + PY + - run: name: Import the signing key command: | @@ -2442,7 +3086,8 @@ jobs: steps: # java-test ran the suite on this revision in this workflow, so tests are # skipped; -am brings the parent pom the client's pom names, which Central - # must hold too; opensysml-conformance is not published. + # must hold too; opensysml-conformance is not published. The jar is + # rebuilt from the tree whose stamped resource was verified above. # Never run this step with -X/debug output; the settings interpolation would print the token. - run: name: Sign, upload and publish @@ -2646,9 +3291,12 @@ jobs: # Move only tarballs to a release directory mkdir -p dist/release mv dist/*.tar.gz dist/*.zip dist/release/ 2>/dev/null || true - # The opensysml wheel, the same bytes publish-pypi uploads. + # The opensysml and jupyter-opensysml-kernel wheels, the same + # bytes the PyPI jobs upload; their sdists moved with the tarballs. mv dist/*.whl dist/release/ mv dist/grpc/* dist/release/ + mv dist/jupyter/* dist/release/ + mv dist/wasm/* dist/release/ mv dist/SHA256SUMS.txt dist/release/ # The signature over that manifest, which the Python client verifies # before it trusts a digest from it. @@ -2684,13 +3332,19 @@ jobs: name: "REHEARSAL: check the release assets and the GitHub token" command: | ls dist/SHA256SUMS.txt > /dev/null + ls dist/wasm/sysml-wasm.wasm dist/wasm/wasm_exec.js > /dev/null set -- dist/opensysml-*-py3-none-any.whl if [ ! -f "$1" ] || [ $# -ne 1 ]; then echo "Error: expected exactly one dist/opensysml-*-py3-none-any.whl" exit 1 fi - ls dist/grpc/sysml-grpc-* > /dev/null - ls -la dist dist/grpc + set -- dist/jupyter_opensysml_kernel-*-py3-none-*.whl + if [ ! -f "$1" ] || [ $# -ne 5 ]; then + echo "Error: expected the five platform wheels dist/jupyter_opensysml_kernel-*-py3-none-*.whl" + exit 1 + fi + ls dist/grpc/sysml-grpc-* dist/jupyter/sysml-jupyter-kernel-* > /dev/null + ls -la dist dist/grpc dist/jupyter TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${CIRCLE_TOKEN}}}" if [ -z "$TOKEN" ]; then echo "Error: No GitHub token found. Tried GITHUB_TOKEN, GH_TOKEN, CIRCLE_TOKEN" @@ -2707,6 +3361,139 @@ jobs: - rehearsal-skip: what: "ghr (the GitHub release)" + # After the GitHub release exists, brings the committed shared table up to + # date with it: stamps the release from the manifest build-release wrote, + # syncs every client copy, and opens a pull request against develop with the + # result. The packages published beside it already carry their own pins; this + # is for a checkout, and for a later release's packages asked for this one, + # and it is what the hygiene suite holds the next release branch to. + pin-release-digests: + executor: python-executor + steps: + - checkout + # build-release's dist/SHA256SUMS.txt: the digests the table records. + - attach_workspace: + at: . + - rehearsal-tag + + - run: + name: Resolve the tag + command: | + if [ -z "${CIRCLE_TAG}" ]; then + echo "Error: CIRCLE_TAG is empty; this job only runs on a core v* tag." + exit 1 + fi + ls -l dist/SHA256SUMS.txt + echo "export PIN_BRANCH=chore/pin-${CIRCLE_TAG}" >> "$BASH_ENV" + + - run: + name: Stamp the release into the shared table on a branch from develop + command: | + # The pull request targets develop, so the stamp is made on develop's + # tree, not the tag's: what develop already pins stays as it is. + git fetch --no-tags origin develop + git checkout -B "${PIN_BRANCH}" origin/develop + # The shared table is the target, so the script syncs every client + # copy from it. A different pin already there for this tag is a + # republished release, and the script refuses it. + python3 client/python/scripts/pin_release_checksums.py \ + --version "${CIRCLE_TAG}" \ + --from-manifest dist/SHA256SUMS.txt + python3 scripts/sync-release-digests.py --check + git add client + git diff --cached --stat + if git diff --cached --quiet; then + echo "develop already pins ${CIRCLE_TAG}; there is nothing to open." + echo "export PIN_NOTHING_TO_DO=1" >> "$BASH_ENV" + fi + + - unless: + condition: << pipeline.parameters.release_rehearsal >> + steps: + - run: + name: Push the branch and open the pull request against develop + command: | + if [ -n "${PIN_NOTHING_TO_DO:-}" ]; then + echo "Nothing to push." + exit 0 + fi + # The same token publish-github-release uses. Pushing a branch + # and opening a pull request need it to write contents and pull + # requests on this repository. + TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-${CIRCLE_TOKEN}}}" + if [ -z "$TOKEN" ]; then + echo "Error: No GitHub token found. Tried GITHUB_TOKEN, GH_TOKEN, CIRCLE_TOKEN" + exit 1 + fi + repo="${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" + git config user.name "OpenSysML release" + git config user.email "opensysml-release@users.noreply.github.com" + git commit -q \ + -m "chore(release): pin ${CIRCLE_TAG} in the shared digest table" \ + -m "Stamped from the release's SHA256SUMS.txt by the tag's pipeline and synced into every client copy." + # The token travels in a header, never in a URL git would echo. + # The branch is rebuilt from develop on every run, so a run + # repeated after a failure replaces an earlier branch rather + # than failing on it. + auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n') + git -c "http.https://github.com/.extraheader=Authorization: Basic ${auth}" \ + push --force "https://github.com/${repo}.git" "HEAD:refs/heads/${PIN_BRANCH}" + PIN_TOKEN="$TOKEN" python3 - "$repo" "$PIN_BRANCH" "$CIRCLE_TAG" \<<'PY' + import json + import os + import sys + import urllib.error + import urllib.parse + import urllib.request + + repo, branch, tag = sys.argv[1:] + api = f"https://api.github.com/repos/{repo}" + headers = { + "Authorization": f"Bearer {os.environ['PIN_TOKEN']}", + "Accept": "application/vnd.github+json", + "User-Agent": "OpenSysML release (https://github.com/Open-MBEE/OpenSysML)", + } + + def call(method, url, body=None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(request) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + return error.code, json.load(error) if error.fp else {} + + owner = repo.split("/")[0] + query = urllib.parse.urlencode({"head": f"{owner}:{branch}", "base": "develop", "state": "open"}) + status, open_prs = call("GET", f"{api}/pulls?{query}") + if status != 200: + raise SystemExit(f"Error: could not list pull requests (HTTP {status}): {open_prs.get('message')}") + if open_prs: + print(f"The pull request already exists and now carries the branch: {open_prs[0]['html_url']}") + raise SystemExit(0) + status, created = call("POST", f"{api}/pulls", { + "title": f"chore(release): pin {tag} in the shared digest table", + "head": branch, + "base": "develop", + "body": ( + f"Pins the five `sysml-grpc` assets of {tag} in `client/release-digests.json` and every " + f"client copy, from the release's `SHA256SUMS.txt`, so a checkout and the packages of " + f"later releases install {tag} on a pin. Opened by the tag's release pipeline; see " + "docs/project/releasing.md § Pinned release digests." + ), + }) + if status != 201: + print(f"Error: could not open the pull request (HTTP {status}): {created.get('message')}") + if status in (403, 404): + print("The GitHub token must be able to write contents and pull requests on this") + print("repository (a classic token needs the 'repo' scope); see") + print("docs/project/releasing.md § Pinned release digests.") + raise SystemExit(1) + print(f"Opened {created['html_url']}") + PY + - rehearsal-skip: + what: "pushing the pin branch and opening its pull request" + workflows: version: 2 @@ -2726,7 +3513,12 @@ workflows: name: Go race tests filters: *integration-branches - go-coverage: + name: Go coverage shards + filters: *integration-branches + - go-coverage-merge: name: Go coverage profile + requires: + - Go coverage shards filters: *integration-branches - go-gates: name: Go gates and binaries @@ -2796,7 +3588,12 @@ workflows: name: Go race tests filters: *release-tags - go-coverage: + name: Go coverage shards + filters: *release-tags + - go-coverage-merge: name: Go coverage profile + requires: + - Go coverage shards filters: *release-tags - go-gates: name: Go gates and binaries @@ -2840,12 +3637,20 @@ workflows: - Build release binaries filters: *release-tags - # Writes and signs the one manifest over the binaries and the distribution. + # Likewise for jupyter-opensysml-kernel, from the kernel binaries. + - build-jupyter-kernel-package: + name: Build Jupyter kernel distribution + requires: + - Build release binaries + filters: *release-tags + + # Writes and signs the one manifest over the binaries and the distributions. - build-release: name: Build release artifacts requires: - Build release binaries - Build Python distribution + - Build Jupyter kernel distribution filters: *release-tags - publish-github-release: @@ -2880,6 +3685,18 @@ workflows: - Python zero-config release smoke filters: *release-tags + - publish-pypi-jupyter-kernel: + name: Publish jupyter-opensysml-kernel to PyPI + # With publish-pypi, after the GitHub release the package's pinned + # digests name; independent of it so neither project refusing a + # re-run blocks the other. Same 'PyPI' context, same token. + context: + - PyPI + requires: + - Publish GitHub release + - Python client tests + filters: *release-tags + - publish-npm: name: Publish opensysml to npm # Last of all, with publish-pypi: the npm publish cannot be undone, @@ -2924,6 +3741,14 @@ workflows: - Rust client tests filters: *release-tags + - pin-release-digests: + name: Pin the release in the shared digest table + # After the release exists: the pull request it opens against + # develop records a release a reader can download and verify. + requires: + - Publish GitHub release + filters: *release-tags + # Publishes the final release of the pre-rename PyPI name on a `pysysml-v*` # tag. It is its own workflow because the artifact shares no code with the # client: the Python suite is what proves it, and the placeholder can be @@ -2943,7 +3768,12 @@ workflows: name: Go race tests filters: *pysysml-tags - go-coverage: + name: Go coverage shards + filters: *pysysml-tags + - go-coverage-merge: name: Go coverage profile + requires: + - Go coverage shards filters: *pysysml-tags - go-gates: name: Go gates and binaries diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 7903bda725..ba155f7a9a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: 'https://numfocus.org/donate-to-openmbee' +open_collective: openmbee diff --git a/.github/ISSUE_TEMPLATE/2-spec-conformance-gap.yml b/.github/ISSUE_TEMPLATE/2-spec-conformance-gap.yml index 437bc8d632..a0df2f6310 100644 --- a/.github/ISSUE_TEMPLATE/2-spec-conformance-gap.yml +++ b/.github/ISSUE_TEMPLATE/2-spec-conformance-gap.yml @@ -5,8 +5,8 @@ body: - type: markdown attributes: value: | - Please check [the compliance record](https://opensysml.org/project/spec-compliance/) - and [the OMG-materials defect list](https://opensysml.org/project/omg-issues/) + Please check [the compliance record](https://redk.opensysml.org/project/spec-compliance/) + and [the OMG-materials defect list](https://github.com/Open-MBEE/OpenSysML/blob/develop/docs/project/omg-issues.md) first — a known divergence is usually already a row there, with the reasoning behind it. - type: textarea diff --git a/.github/ISSUE_TEMPLATE/3-ruling-objection.yml b/.github/ISSUE_TEMPLATE/3-ruling-objection.yml index 4964210012..6a38774930 100644 --- a/.github/ISSUE_TEMPLATE/3-ruling-objection.yml +++ b/.github/ISSUE_TEMPLATE/3-ruling-objection.yml @@ -8,9 +8,9 @@ body: This is for the case where the behaviour is deliberate — a maintainer ruling, a recorded divergence, or a design decision — and the argument is about whether it should be that way. The rulings live in - [spec-compliance](https://opensysml.org/project/spec-compliance/), - [OMG materials defects](https://opensysml.org/project/omg-issues/) and the - decision records under [project/](https://opensysml.org/project/). + [spec-compliance](https://redk.opensysml.org/project/spec-compliance/), + [OMG materials defects](https://github.com/Open-MBEE/OpenSysML/blob/develop/docs/project/omg-issues.md) and the + decision records under [project/](https://redk.opensysml.org/project/). - type: input id: ruling diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 902ea6aa51..61f1f73d43 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/Open-MBEE/OpenSysML/discussions about: Anything that is not a defect, a conformance gap, or an objection to a ruling starts as a discussion. - name: Documentation - url: https://opensysml.org/ + url: https://redk.opensysml.org/ about: The handbook, CLI/REPL reference, internals, and the measured compliance status. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a0dc47d6dd..5ca81ab0aa 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -1,14 +1,17 @@ name: Nightly snapshot -# Builds the newest green `develop` commit into the release assets and publishes it -# as the moving prerelease `nightly`, never marked latest. See docs/project/nightly.md. +# Builds the newest green `develop` commit into the release assets and the Python +# and Node client packages, and publishes them as the per-night prerelease +# `nightly--` (kept 14 days) and the moving alias `nightly`, +# never marked latest. The clients go to PyPI and npm as development versions +# that no default install picks up. See docs/project/nightly.md. on: schedule: - cron: '23 3 * * *' workflow_dispatch: inputs: force: - description: Rebuild and republish even when the newest green develop commit is already the published snapshot + description: Publish the newest green develop commit as tonight's snapshot even when it is already the published one (a night already published is never rebuilt) type: boolean default: false @@ -22,8 +25,15 @@ concurrency: env: BRANCH: develop TAG: nightly - # The commit is built by its own copy of this script; a green commit that predates it cannot be built. + # Per-night releases older than this are deleted, tag and all. + RETENTION_DAYS: 14 + # The commit is built by its own copies of these scripts; a green commit that predates one cannot be built. BUILD_SCRIPT: scripts/build-release-artifacts.sh + KERNEL_DIST_SCRIPT: scripts/build-jupyter-kernel-dist.sh + SNAPSHOT_SCRIPT: client/python/scripts/snapshot_version.py + PYPI_PROJECT: opensysml + KERNEL_PYPI_PROJECT: jupyter-opensysml-kernel + NPM_DIST_TAG: nightly jobs: select: @@ -32,6 +42,8 @@ jobs: timeout-minutes: 10 outputs: sha: ${{ steps.pick.outputs.sha }} + date: ${{ steps.pick.outputs.date }} + night: ${{ steps.pick.outputs.night }} publish: ${{ steps.pick.outputs.publish }} steps: - name: Check out develop @@ -62,8 +74,13 @@ jobs: sha= while read -r candidate; do state=$(gh api "repos/$GH_REPO/commits/$candidate/status" --jq .state) - if [ "$state" = success ] && ! git cat-file -e "$candidate:$BUILD_SCRIPT" 2>/dev/null; then - state="success, but has no $BUILD_SCRIPT" + if [ "$state" = success ]; then + for script in "$BUILD_SCRIPT" "$KERNEL_DIST_SCRIPT" "$SNAPSHOT_SCRIPT"; do + if ! git cat-file -e "$candidate:$script" 2>/dev/null; then + state="success, but has no $script" + break + fi + done fi echo "$candidate: $state" if [ "$state" = success ]; then @@ -83,15 +100,29 @@ jobs: echo "::error::No commit among the newest 100 on $BRANCH has a green CircleCI status; nothing to publish." exit 1 fi - echo "sha=$sha" >> "$GITHUB_OUTPUT" + date=$(date -u +%Y%m%d) + night="$TAG-$date-$(git rev-parse --short "$sha")" + { + echo "sha=$sha" + echo "date=$date" + echo "night=$night" + } >> "$GITHUB_OUTPUT" if [ "$published" = "$sha" ] && [ -n "$released" ] && [ "$FORCE" != true ]; then echo "::notice::$TAG already points at $sha, the newest green commit on $BRANCH, with its release; nothing to rebuild." echo "publish=false" >> "$GITHUB_OUTPUT" - else - echo "Publishing $sha as $TAG (previously ${published:-unpublished})." - echo "publish=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # A published night is immutable, forced or not: the packages already on PyPI and npm + # pin the digests of its binaries, and a rebuild would not reproduce them byte for byte. + # A draft is what an interrupted upload leaves behind; it pins nothing and is replaced. + if draft=$(gh release view "$night" --json isDraft --jq .isDraft 2>/dev/null) && [ "$draft" != true ]; then + echo "::notice::$night is already published and is never rebuilt; to retry its PyPI or npm publication, re-run the failed jobs of the run that published it." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 fi + echo "Publishing $sha as $night and $TAG (previously ${published:-unpublished})." + echo "publish=true" >> "$GITHUB_OUTPUT" publish: name: Build and publish the snapshot @@ -102,12 +133,17 @@ jobs: permissions: contents: write id-token: write + outputs: + tag: ${{ steps.version.outputs.tag }} + pypi: ${{ steps.version.outputs.pypi }} + npm: ${{ steps.version.outputs.npm }} steps: - name: Check out the selected commit uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: ref: ${{ needs.select.outputs.sha }} - # Full history: the notes count commits since the last `v*` tag. + # Full history: the notes count commits since the last `v*` tag, and the + # snapshot version depends on whether the declared release is tagged. fetch-depth: 0 - name: Set up Go @@ -121,21 +157,51 @@ jobs: with: node-version: '22' cache: npm - cache-dependency-path: editors/vscode/package-lock.json + cache-dependency-path: | + editors/vscode/package-lock.json + client/node/package-lock.json + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Install the Python build tooling + run: python -m pip install --only-binary ":all:" pip==26.2.1 build==1.6.1 twine==7.0.0 packaging==26.3 - - name: Derive the snapshot version + # Before the manifest is stamped: `npm ci` requires package.json and its lock + # file to agree, and the stamp pins the platform packages at the snapshot version. + - name: Install the Node client's dependencies + run: cd client/node && npm ci --ignore-scripts + + # One version per registry, derived by the commit's own script from the version + # the tree declares (see the script's docstring), and stamped into the client + # manifests of this checkout. Every binary reports the release tag. + - name: Derive the snapshot versions id: version + env: + SNAPSHOT_DATE: ${{ needs.select.outputs.date }} + NIGHT: ${{ needs.select.outputs.night }} run: | commit=$(git rev-parse --short HEAD) + versions=$(python "$SNAPSHOT_SCRIPT" --date "$SNAPSHOT_DATE" --commit "$commit" --stamp) + echo "$versions" + # The select job found no release under this name; the script must derive the same one. + if [[ "$versions" != *"tag=$NIGHT"* ]]; then + echo "::error::The snapshot is named $(grep '^tag=' <<<"$versions"), not the $NIGHT the select job checked for." + exit 1 + fi { - echo "version=nightly-$(date -u +%Y%m%d)-${commit}" - echo "date=$(date -u +%Y-%m-%d)" + echo "$versions" + echo "date=$(date -u -d "$SNAPSHOT_DATE" +%Y-%m-%d)" echo "base=$(git describe --tags --abbrev=0 --match 'v[0-9]*' HEAD)" } >> "$GITHUB_OUTPUT" + git diff --stat -- client/python/opensysml/_version.py client/node/package.json \ + client/jupyter-kernel/jupyter_opensysml_kernel/_version.py - name: Build the release artifacts env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.tag }} run: '"$BUILD_SCRIPT" dist' # The extension is side-loaded, not published to a marketplace, so the snapshot @@ -146,7 +212,7 @@ jobs: # package script rather than through a Makefile variable it may not have yet. - name: Package the VS Code extension env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.tag }} run: | base=$(node -p "require('./editors/vscode/package.json').version") expected="${base}-${VERSION}" @@ -161,6 +227,249 @@ jobs: cp editors/vscode/opensysml-sysml.vsix dist/ (cd dist && sha256sum opensysml-sysml.vsix >> SHA256SUMS.txt && cat SHA256SUMS.txt) + # The wheel installs this night's `sysml-grpc` on its own only through the digests + # it ships, so the binaries just built are pinned under the per-night tag in the + # table the distribution carries, as the release pipeline does for a release. + - name: Stamp the snapshot's service digests into the Python distribution + env: + VERSION: ${{ steps.version.outputs.tag }} + run: | + python client/python/scripts/pin_release_checksums.py \ + --version "$VERSION" \ + --from-binaries dist/grpc \ + --table client/python/opensysml/release-digests.json + git diff --stat -- client/python/opensysml/release-digests.json + + - name: Build the Python wheel and sdist + env: + PYPI_VERSION: ${{ steps.version.outputs.pypi }} + run: | + rm -rf client/python/dist + python -m build --outdir client/python/dist client/python/ + ls -l client/python/dist + # The version is PEP 440 in canonical form, so these are the names the build tools give the files. + ls client/python/dist/"opensysml-${PYPI_VERSION}-py3-none-any.whl" \ + client/python/dist/"opensysml-${PYPI_VERSION}.tar.gz" > /dev/null + python -m twine check --strict client/python/dist/* + + # A broken wheel fails here rather than on PyPI: it must import at the snapshot + # version, name this night's release as the one it was built against, and pin the + # digest of every service binary built above for that release; the sdist the same. + - name: Verify the built distribution installs this snapshot's service + env: + VERSION: ${{ steps.version.outputs.tag }} + PYPI_VERSION: ${{ steps.version.outputs.pypi }} + run: | + python -m venv /tmp/verify-wheel + /tmp/verify-wheel/bin/python -m pip install --upgrade pip + /tmp/verify-wheel/bin/python -m pip install client/python/dist/*.whl + /tmp/verify-wheel/bin/python - "$VERSION" "$PYPI_VERSION" client/python/dist/opensysml-*-py3-none-any.whl client/python/dist/opensysml-[0-9]*.tar.gz <<'EOF' + import hashlib + import json + import os + import sys + import tarfile + import zipfile + + import opensysml + from opensysml.binary import built_against_releases, pinned_digest + + tag, version, wheel_path, sdist_path = sys.argv[1:] + if opensysml.__version__ != version: + raise SystemExit(f"Error: the installed wheel reports {opensysml.__version__}, not {version}") + if built_against_releases() != (tag,): + raise SystemExit(f"Error: the installed wheel is built against {built_against_releases()}, not ({tag!r},)") + required = { + "sysml-grpc-darwin-amd64", + "sysml-grpc-darwin-arm64", + "sysml-grpc-linux-amd64", + "sysml-grpc-linux-arm64", + "sysml-grpc-windows-amd64.exe", + } + built = {} + for asset in sorted(required): + with open(os.path.join("dist", "grpc", asset), "rb") as f: + built[asset] = hashlib.sha256(f.read()).hexdigest() + if pinned_digest(tag, asset) != built[asset]: + raise SystemExit(f"Error: the installed wheel pins {pinned_digest(tag, asset)} for {asset} of {tag}, which hashes to {built[asset]}") + + def check(what, table): + digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {}) + missing = sorted(required - digests.keys()) + if missing: + raise SystemExit(f"Error: {what} has no complete digest pin for {tag}: {', '.join(missing)}") + wrong = sorted(a for a in required if digests[a] != built[a]) + if wrong: + raise SystemExit(f"Error: {what} pins a digest for {tag} other than the one its binary hashes to: {', '.join(wrong)}") + print(f"Verified {what} embeds all five service digests for {tag}.") + + with zipfile.ZipFile(wheel_path) as wheel: + with wheel.open("opensysml/release-digests.json") as member: + check("the wheel", json.load(member)) + with tarfile.open(sdist_path, "r:gz") as sdist: + name = next((n for n in sdist.getnames() if n.endswith("/opensysml/release-digests.json")), None) + if name is None: + raise SystemExit("Error: the sdist ships no opensysml/release-digests.json") + check("the sdist", json.load(sdist.extractfile(name))) + print(f"opensysml {version}, built against {tag}, imported from the built wheel") + EOF + cp client/python/dist/opensysml-*.whl client/python/dist/opensysml-*.tar.gz dist/ + (cd dist && sha256sum opensysml-*.whl opensysml-*.tar.gz >> SHA256SUMS.txt) + + # jupyter-opensysml-kernel's platform wheels bundle this night's + # `sysml-jupyter-kernel`, and its sdist downloads it by the digests it ships, so + # the kernels just built are pinned into the table every distribution carries. + - name: Stamp the snapshot's kernel digests into the Jupyter kernel distribution + env: + VERSION: ${{ steps.version.outputs.tag }} + run: | + python client/python/scripts/pin_release_checksums.py \ + --version "$VERSION" \ + --from-binaries dist/jupyter \ + --table client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json + git diff --stat -- client/jupyter-kernel/jupyter_opensysml_kernel/release-digests.json + + - name: Build the Jupyter kernel sdist and platform wheels + env: + PYPI_VERSION: ${{ steps.version.outputs.pypi }} + run: | + # The sdist, then one wheel per platform bundling the kernel from dist/jupyter; + # the script opens each and checks what it carries. + "$KERNEL_DIST_SCRIPT" dist/jupyter client/jupyter-kernel/dist + # The version is PEP 440 in canonical form and the build tools normalize the + # name, so these are the files. + ls client/jupyter-kernel/dist/"jupyter_opensysml_kernel-${PYPI_VERSION}.tar.gz" > /dev/null + set -- client/jupyter-kernel/dist/jupyter_opensysml_kernel-"${PYPI_VERSION}"-py3-none-*.whl + if [[ ! -f "$1" || $# -ne 5 ]]; then + echo "::error::expected five platform wheels for ${PYPI_VERSION}, found $#: $*" + exit 1 + fi + python -m twine check --strict client/jupyter-kernel/dist/* + + # Like the client's wheel: this platform's wheel must import at the snapshot + # version, name this night's release, register the `sysml` kernelspec under the + # virtualenv and start the kernel it bundles; every wheel and the sdist must pin + # the digest of every kernel built above. + - name: Verify the built kernel distributions install this snapshot's kernel + env: + VERSION: ${{ steps.version.outputs.tag }} + PYPI_VERSION: ${{ steps.version.outputs.pypi }} + run: | + python -m venv /tmp/verify-kernel-wheel + /tmp/verify-kernel-wheel/bin/python -m pip install --upgrade pip + /tmp/verify-kernel-wheel/bin/python -m pip install client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-manylinux*_x86_64*.whl + /tmp/verify-kernel-wheel/bin/python -m pip install jupyter_client + /tmp/verify-kernel-wheel/bin/python -m jupyter_opensysml_kernel kernelspec + /tmp/verify-kernel-wheel/bin/python - "$VERSION" "$PYPI_VERSION" client/jupyter-kernel/dist/jupyter_opensysml_kernel-[0-9]*.tar.gz client/jupyter-kernel/dist/jupyter_opensysml_kernel-*-py3-none-*.whl <<'EOF' + import hashlib + import json + import os + import subprocess + import sys + import tarfile + import zipfile + + import jupyter_opensysml_kernel + from jupyter_opensysml_kernel.binary import built_against_releases, bundled_binary, pinned_digest + + tag, version, sdist_path, *wheel_paths = sys.argv[1:] + if len(wheel_paths) != 5: + raise SystemExit(f"Error: expected five platform wheels, got {wheel_paths}") + if jupyter_opensysml_kernel.__version__ != version: + raise SystemExit(f"Error: the installed wheel reports {jupyter_opensysml_kernel.__version__}, not {version}") + if built_against_releases() != [tag]: + raise SystemExit(f"Error: the installed wheel is built against {built_against_releases()}, not [{tag!r}]") + if bundled_binary() is None: + raise SystemExit("Error: the installed wheel bundles no sysml-jupyter-kernel") + specs = json.loads(subprocess.check_output( + [sys.executable, "-m", "jupyter_client.kernelspecapp", "list", "--json"], text=True, + ))["kernelspecs"] + resource_dir = specs.get("sysml", {}).get("resource_dir", "") + wanted = os.path.join(sys.prefix, "share", "jupyter", "kernels", "sysml") + if os.path.realpath(resource_dir) != os.path.realpath(wanted): + raise SystemExit(f"Error: Jupyter lists the sysml kernelspec at {resource_dir!r}, not {wanted}") + reported = subprocess.check_output([sys.executable, "-m", "jupyter_opensysml_kernel", "-version"], text=True) + if f"version {tag}" not in reported: + raise SystemExit(f"Error: the bundled kernel reports {reported!r}, not {tag}") + required = { + "sysml-jupyter-kernel-darwin-amd64", + "sysml-jupyter-kernel-darwin-arm64", + "sysml-jupyter-kernel-linux-amd64", + "sysml-jupyter-kernel-linux-arm64", + "sysml-jupyter-kernel-windows-amd64.exe", + } + built = {} + for asset in sorted(required): + with open(os.path.join("dist", "jupyter", asset), "rb") as f: + built[asset] = hashlib.sha256(f.read()).hexdigest() + if pinned_digest(tag, asset) != built[asset]: + raise SystemExit(f"Error: the installed wheel pins {pinned_digest(tag, asset)} for {asset} of {tag}, which hashes to {built[asset]}") + + def check(what, table): + digests = table.get("Open-MBEE/OpenSysML", {}).get(tag, {}) + missing = sorted(required - digests.keys()) + if missing: + raise SystemExit(f"Error: {what} has no complete digest pin for {tag}: {', '.join(missing)}") + wrong = sorted(a for a in required if digests[a] != built[a]) + if wrong: + raise SystemExit(f"Error: {what} pins a digest for {tag} other than the one its binary hashes to: {', '.join(wrong)}") + print(f"Verified {what} embeds all five kernel digests for {tag}.") + + for wheel_path in wheel_paths: + with zipfile.ZipFile(wheel_path) as wheel: + with wheel.open("jupyter_opensysml_kernel/release-digests.json") as member: + check(os.path.basename(wheel_path), json.load(member)) + with tarfile.open(sdist_path, "r:gz") as sdist: + name = next((n for n in sdist.getnames() if n.endswith("/jupyter_opensysml_kernel/release-digests.json")), None) + if name is None: + raise SystemExit("Error: the sdist ships no jupyter_opensysml_kernel/release-digests.json") + check("the sdist", json.load(sdist.extractfile(name))) + print(f"jupyter-opensysml-kernel {version}, built against {tag}, imported from the built wheel") + EOF + cp client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.whl client/jupyter-kernel/dist/jupyter_opensysml_kernel-*.tar.gz dist/ + (cd dist && sha256sum jupyter_opensysml_kernel-*.whl jupyter_opensysml_kernel-*.tar.gz >> SHA256SUMS.txt) + + # The platform packages carry the binaries built above, checked against their + # sidecars by the generator; the client's stamped manifest pins them at the + # snapshot version. All seven are packed as the tarballs the npm job publishes. + - name: Build and pack the Node client packages + env: + NPM_VERSION: ${{ steps.version.outputs.npm }} + run: | + cd client/node + npm run build + npm run platform-packages -- --binaries ../../dist/grpc --wasm ../../dist/wasm --version "$NPM_VERSION" + generated=$(node -p "require('./packages/packages.json').version") + if [ "$generated" != "$NPM_VERSION" ]; then + echo "::error::The platform packages carry version '$generated', not the '$NPM_VERSION' being published." + exit 1 + fi + mkdir -p ../../dist/npm + npm pack --pack-destination ../../dist/npm ./packages/sysml-wasm ./packages/sysml-grpc-* . + cd ../../dist/npm + ls -l + tarballs=(./*.tgz) + if [ "${#tarballs[@]}" -ne 7 ]; then + echo "::error::Expected 7 npm tarballs (client + 5 platform + WASM), packed ${#tarballs[@]}." + exit 1 + fi + # The client tarball pins exactly what was packed beside it. + tar -xOf "openmbee-opensysml-${NPM_VERSION}.tgz" package/package.json > client-package.json + node - "$NPM_VERSION" <<'EOF' + const fs = require("node:fs"); + const [version] = process.argv.slice(2); + const pkg = JSON.parse(fs.readFileSync("client-package.json", "utf8")); + const pins = Object.entries({ ...pkg.optionalDependencies, ...pkg.peerDependencies }) + .filter(([name]) => name.startsWith(`${pkg.name}-`)); + const wrong = pins.filter(([, pinned]) => pinned !== version).map(([name, pinned]) => `${name}@${pinned}`); + if (pkg.version !== version || pins.length !== 6 || wrong.length > 0) { + console.error(`Error: ${pkg.name}@${pkg.version} pins ${pins.length} own packages, wrong: ${wrong.join(", ") || "none"}; expected 6 at ${version}`); + process.exit(1); + } + console.log(`${pkg.name}@${pkg.version} pins its six packages at ${version}`); + EOF + rm client-package.json + # Keyless, like the stable release; the identity is this workflow's OIDC identity. - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -188,7 +497,9 @@ jobs: - name: Write the release notes env: GH_REPO: ${{ github.repository }} - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.tag }} + PYPI_VERSION: ${{ steps.version.outputs.pypi }} + NPM_VERSION: ${{ steps.version.outputs.npm }} DATE: ${{ steps.version.outputs.date }} BASE: ${{ steps.version.outputs.base }} WORKFLOW_REF: ${{ github.workflow_ref }} @@ -205,11 +516,24 @@ jobs: printf '%s' " ([compare](https://github.com/$GH_REPO/compare/$BASE...$sha)), by" printf '%s\n' " [this workflow run]($RUN_URL). Every binary reports \`$VERSION\`." echo - printf '%s' "The snapshot is replaced each night and is never the \`latest\` release; Homebrew," - printf '%s' " PyPI, npm and the Windows installer follow the stable line only. The" - printf '%s' " [snapshot page](https://opensysml.org/project/nightly/) explains what it is, how" + printf '%s' "The snapshot is published twice: as \`$VERSION\`, which is kept for $RETENTION_DAYS days," + printf '%s' " and as the moving alias \`$TAG\`, which the next night's snapshot replaces. It is never" + printf '%s' " the \`latest\` release; Homebrew and the Windows installer follow the stable line only." + if [[ -f dist/wasm/sysml-wasm.wasm ]]; then + printf '%s' " The \`sysml-wasm.wasm\` module and matching \`wasm_exec.js\` are available in its assets." + fi + printf '%s' " The" + printf '%s' " [snapshot page](https://redk.opensysml.org/project/nightly/) explains what it is, how" printf '%s' " to verify it and what to expect from it; the" - printf '%s\n' " [install guide](https://opensysml.org/guide/01-install/) covers the stable releases." + printf '%s\n' " [install guide](https://redk.opensysml.org/guide/01-install/) covers the stable releases." + echo + printf '%s' "The Python and Node clients built from the same commit are published as development" + printf '%s' " versions that no default install picks up: \`pip install opensysml==$PYPI_VERSION\`" + printf '%s' " and \`npm install @openmbee/opensysml@$NPM_VERSION\` (the \`$NPM_DIST_TAG\` dist-tag)." + printf '%s' " Each starts this snapshot's \`sysml-grpc\`: the wheel pins the digests of the" + printf '%s\n' " \`$VERSION\` assets, and the npm platform packages carry the binaries." + printf '%s' " The Jupyter kernel built from it is \`pip install jupyter-opensysml-kernel==$PYPI_VERSION\`," + printf '%s\n' " whose platform wheels bundle this snapshot's \`sysml-jupyter-kernel\` and whose sdist downloads it by the same pinned digests." echo printf '%s' "\`opensysml-sysml.vsix\` is the VS Code extension built from the same commit:" printf '%s' " \`code --install-extension opensysml-sysml.vsix\`, with a snapshot's \`sysml-lsp\`" @@ -235,16 +559,121 @@ jobs: } > notes.md cat notes.md - # Recreated rather than edited, so no asset of a previous night survives. - # `gh release delete` leaves the tag behind; it is moved by hand. - - name: Publish the prerelease at the moving tag + # The distributions go up before the release does: a night whose release is published + # is never rebuilt, so the registry jobs must be able to re-run from what this run kept. + # A re-run of this job replaces what its earlier attempt kept, so it never trips over it. + - name: Keep the Python distribution for the PyPI job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-dist + path: client/python/dist/ + if-no-files-found: error + overwrite: true + retention-days: 7 + + - name: Keep the Jupyter kernel distribution for the PyPI job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: jupyter-kernel-dist + path: client/jupyter-kernel/dist/ + if-no-files-found: error + overwrite: true + retention-days: 7 + + - name: Keep the npm tarballs for the npm job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: npm-dist + path: dist/npm/*.tgz + if-no-files-found: error + overwrite: true + retention-days: 7 + + # The per-night release is the one the clients pin, so it is created here and kept. + - name: Publish the per-night prerelease env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ steps.version.outputs.tag }} DATE: ${{ steps.version.outputs.date }} run: | sha=$(git rev-parse HEAD) + assets=( + dist/*.tar.gz + dist/*.zip + dist/*.whl + dist/opensysml-sysml.vsix + dist/SHA256SUMS.txt + dist/SHA256SUMS.txt.bundle + dist/grpc/sysml-grpc-* + dist/jupyter/sysml-jupyter-kernel-* + ) + if [[ -f dist/wasm/sysml-wasm.wasm ]]; then + assets+=( + dist/wasm/sysml-wasm.wasm + dist/wasm/wasm_exec.js + dist/wasm/sysml-wasm.wasm.sha256 + dist/wasm/wasm_exec.js.sha256 + ) + fi + for asset in "${assets[@]}"; do + if [[ ! -f "$asset" ]]; then + printf '::error::Missing release asset: %s\n' "$asset" + exit 1 + fi + done + # A published night is never replaced: the packages on PyPI and npm pin its binaries. + # Only a draft, left by an interrupted upload, gives way; nothing pins a draft. + if draft=$(gh release view "$VERSION" --json isDraft --jq .isDraft 2>/dev/null); then + if [[ "$draft" != true ]]; then + echo "::error::$VERSION is already published; a night's release is never replaced." + exit 1 + fi + gh release delete "$VERSION" --yes + fi + gh release create "$VERSION" \ + --target "$sha" \ + --title "Nightly snapshot $DATE ($VERSION)" \ + --notes-file notes.md \ + --prerelease \ + --latest=false \ + "${assets[@]}" + gh release view "$VERSION" --json url,assets --jq '.url, (.assets[].name)' + + # Its own job, so that re-running the failed jobs of a run finishes a night whose release + # is published without rebuilding it: the alias is recreated from the night's own assets, + # so no asset of a previous night survives under it. `gh release delete` leaves a tag + # behind; the alias tag is moved by hand. + alias: + name: Move the alias and prune old nights + needs: [select, publish] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - name: Check out the selected commit + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + ref: ${{ needs.select.outputs.sha }} + + - name: Recreate the alias at this night, with its assets, title and notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.publish.outputs.tag }} + run: | + sha=$(git rev-parse HEAD) + expected=$(gh release view "$VERSION" --json assets --jq '.assets | length') + gh release download "$VERSION" --dir assets + assets=(assets/*) + if [[ "$expected" -eq 0 || "${#assets[@]}" -ne "$expected" ]]; then + echo "::error::$VERSION has $expected assets; downloaded ${#assets[@]}." + exit 1 + fi + title=$(gh release view "$VERSION" --json name --jq .name) + gh release view "$VERSION" --json body --jq .body > notes.md + if gh release view "$TAG" --json tagName >/dev/null 2>&1; then gh release delete "$TAG" --yes fi @@ -252,11 +681,188 @@ jobs: git push --force origin "refs/tags/$TAG" gh release create "$TAG" \ --target "$sha" \ - --title "Nightly snapshot $DATE ($VERSION)" \ + --title "$title" \ --notes-file notes.md \ --prerelease \ --latest=false \ - dist/*.tar.gz dist/*.zip dist/opensysml-sysml.vsix \ - dist/SHA256SUMS.txt dist/SHA256SUMS.txt.bundle \ - dist/grpc/sysml-grpc-* - gh release view "$TAG" --json url,assets --jq '.url, (.assets[].name)' + "${assets[@]}" + gh release view "$TAG" --json url --jq .url + + # Only per-night releases are candidates: the stable `v*` releases and the alias + # match neither the tag form nor the age test. The previous step recreated the alias + # at this night, so sparing this night by its exact tag spares the alias's night; a + # night at the same commit under an earlier date is a different night and ages out. + # Nothing is deleted on a night that publishes nothing, so the alias never outlives + # the release it names. + - name: Delete per-night releases older than the retention period + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.publish.outputs.tag }} + run: | + cutoff=$(date -u -d "$RETENTION_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ) + echo "Deleting per-night releases published before $cutoff, except $VERSION, which $TAG names." + gh api --paginate "repos/$GH_REPO/releases?per_page=100" \ + --jq '.[] | select(.tag_name | test("^nightly-[0-9]{8}-[0-9a-f]+$")) | [.tag_name, (.published_at // .created_at)] | @tsv' | + while IFS=$'\t' read -r tag published; do + if [[ "$tag" == "$VERSION" || ! "$published" < "$cutoff" ]]; then + echo "keep $tag ($published)" + continue + fi + echo "delete $tag ($published)" + gh release delete "$tag" --cleanup-tag --yes + done + + # Trusted publishing: PyPI accepts this workflow's OIDC token for the `opensysml` + # project once a maintainer registers it as a trusted publisher (repository + # Open-MBEE/OpenSysML, workflow nightly.yml, environment nightly); there is no token + # to store. A development version never becomes the project's default install. + pypi: + name: Publish the Python client to PyPI + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: nightly + permissions: + contents: read + id-token: write + steps: + - name: Fetch the Python distribution + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-dist + path: client/python/dist/ + + - name: Fetch the Jupyter kernel distribution + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: jupyter-kernel-dist + path: client/jupyter-kernel/dist/ + + # A PyPI version is immutable, and two snapshots of one day share one version, + # so a second build of a day leaves the first on PyPI rather than failing. + - name: Skip a version the index already has + id: index + env: + PYPI_VERSION: ${{ needs.publish.outputs.pypi }} + run: | + ls -l client/python/dist + status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${PYPI_PROJECT}/${PYPI_VERSION}/json") + case "$status" in + 200) + echo "::notice::${PYPI_PROJECT} ${PYPI_VERSION} is already on PyPI; today's snapshot of the Python client stands as published." + echo "publish=false" >> "$GITHUB_OUTPUT" ;; + 404) + echo "pypi.org has no ${PYPI_PROJECT} ${PYPI_VERSION}; publishing" + echo "publish=true" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::pypi.org answered HTTP ${status} for ${PYPI_PROJECT} ${PYPI_VERSION}; not publishing blind." + exit 1 ;; + esac + + - name: Publish to PyPI with trusted publishing + if: steps.index.outputs.publish == 'true' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: client/python/dist/ + print-hash: true + + # The kernel package is a second PyPI project under the same trusted publisher + # (repository, workflow and environment), checked and published the same way. + - name: Skip a kernel version the index already has + id: kernel-index + env: + PYPI_VERSION: ${{ needs.publish.outputs.pypi }} + run: | + ls -l client/jupyter-kernel/dist + status=$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/${KERNEL_PYPI_PROJECT}/${PYPI_VERSION}/json") + case "$status" in + 200) + echo "::notice::${KERNEL_PYPI_PROJECT} ${PYPI_VERSION} is already on PyPI; today's snapshot of the Jupyter kernel stands as published." + echo "publish=false" >> "$GITHUB_OUTPUT" ;; + 404) + echo "pypi.org has no ${KERNEL_PYPI_PROJECT} ${PYPI_VERSION}; publishing" + echo "publish=true" >> "$GITHUB_OUTPUT" ;; + *) + echo "::error::pypi.org answered HTTP ${status} for ${KERNEL_PYPI_PROJECT} ${PYPI_VERSION}; not publishing blind." + exit 1 ;; + esac + + - name: Publish the Jupyter kernel to PyPI with trusted publishing + if: steps.kernel-index.outputs.publish == 'true' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: client/jupyter-kernel/dist/ + print-hash: true + + # Trusted publishing: npm accepts this workflow's OIDC token for each of the seven + # packages once a maintainer registers it as a trusted publisher on each (repository + # Open-MBEE/OpenSysML, workflow nightly.yml, environment nightly), and signs + # provenance for it. The `nightly` dist-tag leaves `latest` where the release left it. + npm: + name: Publish the Node client to npm + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: nightly + permissions: + contents: read + id-token: write + steps: + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: '22' + registry-url: https://registry.npmjs.org + + # Trusted publishing needs npm 11.5.1 or later, newer than Node 22 bundles. + - name: Update npm + run: | + npm install -g --ignore-scripts npm@11.21.0 + npm --version + node -e "const [major, minor, patch] = require('child_process').execSync('npm --version').toString().trim().split('.').map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error('npm 11.5.1 or later is required for trusted publishing'); process.exit(1); }" + + - name: Fetch the npm tarballs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-dist + path: dist/npm/ + + # The platform and WASM packages go first, because the client pins them at this + # exact version. A version already on the registry is skipped, never replaced, + # so a second build of a day leaves the first published. + - name: Publish the seven packages under the nightly dist-tag + env: + NPM_VERSION: ${{ needs.publish.outputs.npm }} + run: | + cd dist/npm + ls -l + tarballs=( + openmbee-opensysml-wasm-"$NPM_VERSION".tgz + openmbee-opensysml-sysml-grpc-*-"$NPM_VERSION".tgz + openmbee-opensysml-"$NPM_VERSION".tgz + ) + if [ "${#tarballs[@]}" -ne 7 ]; then + echo "::error::Expected 7 tarballs at $NPM_VERSION, found ${#tarballs[@]}: ${tarballs[*]}" + exit 1 + fi + for tarball in "${tarballs[@]}"; do + name=$(tar -xOf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name") + if npm view "${name}@${NPM_VERSION}" version > /dev/null 2>&1; then + echo "::notice::${name}@${NPM_VERSION} is already on npm; skipping it." + continue + fi + npm publish "$tarball" --access public --tag "$NPM_DIST_TAG" + done + + - name: Verify the latest dist-tag did not move + env: + NPM_VERSION: ${{ needs.publish.outputs.npm }} + run: | + npm dist-tag ls @openmbee/opensysml + latest=$(npm view @openmbee/opensysml dist-tags.latest) + if [ "$latest" = "$NPM_VERSION" ]; then + echo "::error::@openmbee/opensysml's latest dist-tag now names the snapshot $NPM_VERSION; point it back at the stable release by hand." + exit 1 + fi + echo "latest is $latest; $NPM_DIST_TAG is $(npm view @openmbee/opensysml "dist-tags.$NPM_DIST_TAG")" diff --git a/.github/workflows/oracle-reproduction.yml b/.github/workflows/oracle-reproduction.yml index 1e39e5ac8b..2345b4e041 100644 --- a/.github/workflows/oracle-reproduction.yml +++ b/.github/workflows/oracle-reproduction.yml @@ -1,12 +1,15 @@ name: Oracle reproduction # The three oracle baselines under docs/project/ are only as true as their last -# recorded run, and re-running them needs Java, the pinned pilot jar and three -# downloaded corpora — which is why neither pr.yml nor CircleCI's build-and-test -# does it. This workflow does, on a schedule and on demand, so a baseline that -# has stopped describing reality surfaces within a day instead of at the next -# audit. It is deliberately not a required check: it downloads from upstream and -# takes minutes, so a network failure here must not block a pull request. +# recorded run. Re-running the differential and rejection oracles needs Java, +# the pinned pilot jar and the downloaded corpora — which is why neither pr.yml +# nor CircleCI's build-and-test does those. The Xpect oracle needs only the +# pinned .xt suites, so pr.yml's xpect-oracle job reproduces its baseline on +# every pull request; the Java-backed oracles are reproduced here alone, on a +# schedule and on demand, so a baseline that has stopped describing reality +# surfaces within a day instead of at the next audit. It is deliberately not a +# required check: it downloads from upstream and takes minutes, so a network +# failure here must not block a pull request. # # It lives in GitHub Actions rather than .circleci/config.yml because a # CircleCI schedule is configured outside the repository (project settings or @@ -62,6 +65,7 @@ jobs: ./scripts/download-pilot-corpora.sh ./scripts/download-pilot-xpect.sh ./scripts/download-pssm-suite.sh + ./scripts/download-sysml-v1tov2.sh # Each oracle's -check re-runs the comparison and diffs the fresh report # against its committed baseline, ignoring only the recording date. The @@ -94,6 +98,12 @@ jobs: if: ${{ !cancelled() }} run: go run -C tools ./cmd/validation-census -check -require-jar + # The transformation census's baseline lists the mapping classes the + # pinned model declares; only here is the model present to compare against. + - name: Transformation census lists what the pinned model contains + if: ${{ !cancelled() }} + run: go run -C tools ./cmd/transformation-census -check -require-xmi + # The reports themselves, so a failing run can be read without # reproducing the environment. - name: Upload the fresh reports diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f9b4ee5456..5d10ff7c5a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,12 +1,12 @@ name: Pages -# The site is docs/ rendered by MkDocs, republished on every push to main: the +# The site is docs/ rendered by MkDocs, republished on every push to develop: the # test-suite figures are counted from the whole tree as it builds, so any # fixture or test landing in a release moves them. --strict: a broken link # fails the deployment. on: push: - branches: [main] + branches: [develop] workflow_dispatch: # One deployment at a time; cancelling one in flight would leave the site behind. @@ -26,6 +26,9 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 + with: + # Full history: the in-browser engine is stamped by git describe below. + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -42,12 +45,27 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - name: Install documentation toolchain run: make docs-install - name: Configure GitHub Pages uses: actions/configure-pages@v5 + # /cli/ runs the engine in the browser; the same target a local preview uses + # compiles it into docs/assets (never vendored) for mkdocs to copy into site/. + - name: Build the in-browser engine + run: make docs-engine-assets + + - name: Build the landing-page diagram + run: make docs-landing-assets + - name: Build documentation site run: make docs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 60f8c4def8..cb74289213 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,6 +19,8 @@ env: OPENSYSML_REQUIRE_PILOT_CORPORA: "1" # Same for the pinned OMG PSSM test suite, gated by tools/referee/pssm and tools/cmd/pssm-referee. OPENSYSML_REQUIRE_PSSM_SUITE: "1" + # Same for the pinned OMG SysML v1 to v2 transformation model, gated by tools/census/transformation. + OPENSYSML_REQUIRE_SYSML_V1TOV2: "1" # Same for the pinned fUML reference-implementation suite, gated by tools/referee/fuml. OPENSYSML_REQUIRE_FUML_SUITE: "1" # Same for the pilot's XMI of the standard library, downloaded below and @@ -53,7 +55,7 @@ jobs: julia: ${{ steps.areas.outputs.julia }} matlab: ${{ steps.areas.outputs.matlab }} vscode: ${{ steps.areas.outputs.vscode }} - cameo: ${{ steps.areas.outputs.cameo }} + mdk: ${{ steps.areas.outputs.mdk }} syson: ${{ steps.areas.outputs.syson }} steps: - name: Check out repository @@ -198,6 +200,17 @@ jobs: - name: Download the OMG PSSM test suite run: ./scripts/download-pssm-suite.sh + # The SysML v1 to v2 transformation model is one pinned file; keyed on its + # download script and pin. + - name: Cache the SysML v1 to v2 transformation model + uses: actions/cache@v4 + with: + path: build/sysml-v1tov2 + key: sysml-v1tov2-${{ hashFiles('scripts/download-sysml-v1tov2.sh', 'scripts/sysml-v1tov2-pin.sh') }} + + - name: Download the SysML v1 to v2 transformation model + run: ./scripts/download-sysml-v1tov2.sh + # The fUML reference implementation's models and jar, checksummed; keyed # on its download script and pin. Never run in CI: the Go tests read the # committed expected-record (docs/project/fuml-referee.md). @@ -238,7 +251,7 @@ jobs: - name: Download the Reference-FMUs run: ./scripts/download-reference-fmus.sh - # Per-package timeout: under -race the runtime package runs 22-29 minutes on + # Per-package timeout: under -race the runtime package runs 31-47 minutes on # these runners. Matches `make test-shard`. - name: Run Go race tests env: @@ -293,7 +306,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - timeout-minutes: 20 + # The job takes 28 minutes on a hosted runner; a slow one must not cancel it. + timeout-minutes: 45 steps: - name: Check out repository uses: actions/checkout@v4 @@ -326,6 +340,10 @@ jobs: - name: Test the pilot downloader run: scripts/pilot-pin-test.sh + # install.sh and install.ps1 against a release served from localhost. + - name: Test the install scripts + run: scripts/install-test.sh + - name: Cache the OMG training corpus uses: actions/cache@v4 with: @@ -373,6 +391,15 @@ jobs: - name: Download the OMG PSSM test suite run: ./scripts/download-pssm-suite.sh + - name: Cache the SysML v1 to v2 transformation model + uses: actions/cache@v4 + with: + path: build/sysml-v1tov2 + key: sysml-v1tov2-${{ hashFiles('scripts/download-sysml-v1tov2.sh', 'scripts/sysml-v1tov2-pin.sh') }} + + - name: Download the SysML v1 to v2 transformation model + run: ./scripts/download-sysml-v1tov2.sh + - name: Cache the fUML reference-implementation suite uses: actions/cache@v4 with: @@ -407,6 +434,11 @@ jobs: go vet ./... go vet -C tools ./... + - name: Vet and test the production sysml build (-tags sysml_prod) + run: | + go vet -tags sysml_prod ./cmd/sysml/... ./internal/frontend/repl/... + go test -count=1 -tags sysml_prod ./cmd/sysml -run TestProdBuild + - name: Run static analysis (staticcheck + gosec) run: make lint @@ -484,6 +516,12 @@ jobs: - name: PSSM referee reproduces its baseline counts run: go run -C tools ./cmd/pssm-referee -check + # The transformation census's baseline lists the mapping classes the + # pinned model declares; here the model is provisioned, so the baseline + # is compared against a fresh extraction. + - name: Transformation census lists what the pinned model contains + run: go run -C tools ./cmd/transformation-census -check -require-xmi + # The migration of the PSSM suite to v2 notation on its own, so its report # totals are legible in the log and a skip must not pass; see # docs/project/pssm-migration.md. @@ -595,7 +633,7 @@ jobs: run: | set -o pipefail env -u OPENSYSML_REQUIRE_SMT OPENSYSML_SMT=/nonexistent/no-such-solver \ - go test -count=1 -v ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log + go test -count=1 -v -timeout 30m ./internal/exec/solve ./internal/frontend/repl | tee no-solver-gate.log for name in TestCheckReportsAnAbsentSolver TestExplainReportsAnAbsentSolver \ TestSolveReportsAnAbsentSolver TestConfigureReportsAnAbsentSolver \ TestOptimizeReportsAnAbsentSolver; do @@ -640,6 +678,41 @@ jobs: exit 1 fi + # The Xpect oracle's committed baseline (docs/project/pilot-xpect-baseline.json) + # used to be reproduced only by the nightly oracle-reproduction workflow, so a + # checker change that moved a verdict surfaced a day after it merged. The check + # needs no Java — only the pinned .xt suites — so it runs here on every change + # that can move it: a rule under internal/ or a re-recorded baseline under docs/. + xpect-oracle: + name: Xpect oracle reproduces its baseline + needs: changes + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.docs == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Cache the pilot Xpect suites + uses: actions/cache@v4 + with: + path: build/pilot-xpect-corpus + key: pilot-xpect-corpus-${{ hashFiles('scripts/download-pilot-xpect.sh', 'scripts/pilot-pin.sh') }} + + - name: Download the pilot Xpect suites + run: ./scripts/download-pilot-xpect.sh + + - name: Xpect oracle reproduces its baseline + run: go run -C tools ./cmd/pilot-xpect -check + # The client jobs download this binary rather than building one, so it runs # whenever the service or any client does. build: @@ -653,12 +726,12 @@ jobs: needs.changes.outputs.rust == 'true' || needs.changes.outputs.julia == 'true' || needs.changes.outputs.matlab == 'true' || - needs.changes.outputs.cameo == 'true' || + needs.changes.outputs.mdk == 'true' || needs.changes.outputs.syson == 'true' runs-on: ubuntu-latest permissions: contents: read - timeout-minutes: 10 + timeout-minutes: 15 steps: - name: Check out repository uses: actions/checkout@v4 @@ -680,6 +753,13 @@ jobs: BUILD_TIME="$(date -u '+%Y-%m-%d_%H:%M:%S')" \ GO_VERSION="$(go version | awk '{print $3}')" + - name: Build the combined WebAssembly module + run: | + mkdir -p bin/wasm + GOOS=js GOARCH=wasm go build -trimpath -ldflags="-s -w" \ + -o bin/wasm/sysml-wasm.wasm ./cmd/sysml-wasm + cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" bin/wasm/ + - name: Verify binaries run: | ./bin/sysml --version @@ -702,7 +782,7 @@ jobs: needs: - changes - build - - cameo-plugin + - mdk-plugin - conformance-pkg - docs - java-test @@ -718,6 +798,7 @@ jobs: - static-and-integrity - vscode-extension - wasm-gate + - xpect-oracle runs-on: ubuntu-latest permissions: contents: read @@ -727,7 +808,7 @@ jobs: env: CHANGES_RESULT: ${{ needs.changes.result }} BUILD_RESULT: ${{ needs.build.result }} - CAMEO_PLUGIN_RESULT: ${{ needs.cameo-plugin.result }} + MDK_PLUGIN_RESULT: ${{ needs.mdk-plugin.result }} CONFORMANCE_PKG_RESULT: ${{ needs.conformance-pkg.result }} DOCS_RESULT: ${{ needs.docs.result }} JAVA_TEST_RESULT: ${{ needs.java-test.result }} @@ -743,11 +824,12 @@ jobs: STATIC_AND_INTEGRITY_RESULT: ${{ needs.static-and-integrity.result }} VSCODE_EXTENSION_RESULT: ${{ needs.vscode-extension.result }} WASM_GATE_RESULT: ${{ needs.wasm-gate.result }} + XPECT_ORACLE_RESULT: ${{ needs.xpect-oracle.result }} run: | ok() { [ "$1" = success ] || [ "$1" = skipped ]; } if [ "$CHANGES_RESULT" != success ] || ! ok "$BUILD_RESULT" || - ! ok "$CAMEO_PLUGIN_RESULT" || + ! ok "$MDK_PLUGIN_RESULT" || ! ok "$CONFORMANCE_PKG_RESULT" || ! ok "$DOCS_RESULT" || ! ok "$JAVA_TEST_RESULT" || @@ -762,10 +844,11 @@ jobs: ! ok "$RUST_TEST_RESULT" || ! ok "$STATIC_AND_INTEGRITY_RESULT" || ! ok "$VSCODE_EXTENSION_RESULT" || - ! ok "$WASM_GATE_RESULT"; then + ! ok "$WASM_GATE_RESULT" || + ! ok "$XPECT_ORACLE_RESULT"; then echo "changes=$CHANGES_RESULT" echo "build=$BUILD_RESULT" - echo "cameo-plugin=$CAMEO_PLUGIN_RESULT" + echo "mdk-plugin=$MDK_PLUGIN_RESULT" echo "conformance-pkg=$CONFORMANCE_PKG_RESULT" echo "docs=$DOCS_RESULT" echo "java-test=$JAVA_TEST_RESULT" @@ -781,6 +864,7 @@ jobs: echo "static-and-integrity=$STATIC_AND_INTEGRITY_RESULT" echo "vscode-extension=$VSCODE_EXTENSION_RESULT" echo "wasm-gate=$WASM_GATE_RESULT" + echo "xpect-oracle=$XPECT_ORACLE_RESULT" exit 1 fi @@ -856,7 +940,8 @@ jobs: OPENSYSML_KATEX: ${{ github.workspace }}/build/doc-pdf/katex/node_modules/.bin/katex OPENSYSML_DOT: ${{ github.workspace }}/build/doc-pdf/graphviz/bin/dot OPENSYSML_PLANTUML_JAR: ${{ github.workspace }}/build/doc-pdf/plantuml/plantuml-1.2026.8.jar - run: go test -count=1 -v -run Installed ./internal/doc/docpdf ./tests/migrate + OPENSYSML_D2: ${{ github.workspace }}/build/doc-pdf/d2/d2-v0.9.0/bin/d2 + run: go test -count=1 -v -run Installed ./internal/ir/view ./internal/doc/docpdf ./tests/migrate vscode-extension: name: VS Code extension @@ -882,6 +967,10 @@ jobs: - name: Package the VS Code extension run: make vscode-package + - name: Build landing-page diagram bundle + working-directory: editors/vscode + run: npm run build:landing + - name: Upload VS Code extension artifact uses: actions/upload-artifact@v4 with: @@ -889,10 +978,10 @@ jobs: path: editors/vscode/opensysml-sysml.vsix if-no-files-found: error - cameo-plugin: - name: Cameo plugin + mdk-plugin: + name: OpenSysML MDK plugin needs: [changes, build] - if: needs.changes.outputs.cameo == 'true' + if: needs.changes.outputs.mdk == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -921,7 +1010,7 @@ jobs: # Compiles against the compile-only OpenAPI stubs and runs the unit tests plus # the pipeline test against the freshly built service. - name: Build and test the plugin - run: mvn -B -f editors/cameo/pom.xml verify -Dopensysml.requireService=true + run: mvn -B -f editors/mdk/pom.xml verify -Dopensysml.requireService=true docs: name: Documentation site @@ -948,9 +1037,19 @@ jobs: go-version-file: go.mod cache: true + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - name: Install documentation toolchain run: make docs-install + - name: Build the landing-page diagram + run: make docs-landing-assets + - name: Check documentation links run: python3 scripts/check-doc-links.py @@ -976,6 +1075,15 @@ jobs: - name: Check the validation-constraint census matches its baseline run: go run -C tools ./cmd/validation-census -check + # Same for the transformation census; this job provisions neither input, + # so the model and measurement comparisons run in the gate job that does, + # and the workflow-wide require variables are lifted here. + - name: Check the transformation census matches its baseline + env: + OPENSYSML_REQUIRE_PSSM_SUITE: "" + OPENSYSML_REQUIRE_SYSML_V1TOV2: "" + run: go run -C tools ./cmd/transformation-census -check + # Changelog entries live in changes/unreleased/ until a release folds them in. - name: Check changelog fragments run: python3 scripts/changelog-test.py && python3 scripts/changelog.py check @@ -991,6 +1099,14 @@ jobs: - name: Check the test-suite figures hook run: python3 scripts/mkdocs_suite_figures-test.py + # Sphinx roles in the Python docstrings are rewritten when the site is built. + - name: Check the Sphinx-role rewriting hook + run: python3 scripts/griffe_sphinx_roles-test.py + + # The install scripts are served from the site root, copied from the tree. + - name: Check the install-script publishing hook + run: python3 scripts/mkdocs_install_scripts-test.py + # --strict: a link or anchor a moved page left behind fails here rather than # publishing a 404 from main. - name: Build documentation site @@ -1028,6 +1144,9 @@ jobs: run: | chmod +x bin/sysml-grpc echo "OPENSYSML_BINARY=$(pwd)/bin/sysml-grpc" >> "$GITHUB_ENV" + echo "OPENSYSML_WASM=$(pwd)/bin/wasm/sysml-wasm.wasm" >> "$GITHUB_ENV" + echo "OPENSYSML_WASM_EXEC=$(pwd)/bin/wasm/wasm_exec.js" >> "$GITHUB_ENV" + echo "OPENSYSML_REQUIRE_WASM=1" >> "$GITHUB_ENV" - name: Install the client's dependencies working-directory: client/node @@ -1042,11 +1161,20 @@ jobs: npm run typecheck npm run lint + # The verification tests ask the service solver-backed questions; the + # solver is an external process the service finds on PATH. + - name: Install Z3 + run: | + sudo apt-get update + sudo apt-get install -y z3 + z3 --version + - name: Run the tests working-directory: client/node run: npm test - # --allow-skips because v1 covers 5 of the 15 RPCs; the skipped count is + # --allow-skips because three scenarios ask for a request the client's + # public API refuses before asking the service; the skipped count is # reported and the artifact records which scenarios they were. - name: Run the conformance suite through the client working-directory: client/node @@ -1137,6 +1265,10 @@ jobs: # Pinned and wheel-only: an unpinned resolve runs whatever was # published today, and a source distribution runs its own build code. pip install --only-binary :all: pytest==9.0.3 pytest-mock==3.15.1 psutil==7.2.2 fmpy==0.3.32 mypy==2.4.0 types-protobuf==7.35.1.20260906 'pyright[nodejs]==1.1.414' + # The Jupyter kernel package: its tests drive jupyter_client and build + # its wheels with python -m build. + pip install --only-binary :all: jupyter_client==8.10.0 build==1.6.1 + make jupyter-kernel-install # The FMI runner test's FMUs, same pin as the Go gate's. - name: Cache the Reference-FMUs @@ -1178,6 +1310,9 @@ jobs: OPENSYSML_REQUIRE_TYPECHECKERS: 1 run: make python-test + - name: Run the Jupyter kernel package tests + run: make jupyter-kernel-test + - name: Verify Python client import run: python3 -c "import opensysml; print(f'opensysml {opensysml.__version__} imported successfully')" @@ -1490,7 +1625,7 @@ jobs: - name: Make the service binary executable run: chmod +x bin/sysml-grpc - # SysON 2026.9.0 runs on 21; the module compiles at release 17. + # SysON 2026.9.2 runs on 21; the module compiles at release 17. - name: Set up Java uses: actions/setup-java@v4 with: diff --git a/.github/workflows/syson-artifacts.yml b/.github/workflows/syson-artifacts.yml index fb814afd4f..fb70d91054 100644 --- a/.github/workflows/syson-artifacts.yml +++ b/.github/workflows/syson-artifacts.yml @@ -12,7 +12,7 @@ jobs: timeout-minutes: 20 env: GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.SYSON_PACKAGES_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_PACKAGES_READ_TOKEN }} steps: - name: Check out repository uses: actions/checkout@v4 @@ -20,7 +20,7 @@ jobs: - name: Build the service binary run: make build-grpc - # SysON 2026.9.0 runs on 21; the module compiles at release 17. + # SysON 2026.9.2 runs on 21; the module compiles at release 17. - name: Set up Java uses: actions/setup-java@v4 with: diff --git a/.gitignore b/.gitignore index 8315ccb47b..169ea37cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,13 @@ # Rendered documentation site (make docs) /site/ +# In-browser engine assets (make docs-engine-assets); built, never vendored +/docs/assets/sysml-engine.wasm* +/docs/assets/sysml-repl.wasm* +/docs/assets/repl-examples/ +/docs/assets/wasm_exec.js +/docs/assets/landing/ + # Julia package environments resolve locally; the pinned deps are in Project.toml. client/julia/**/Manifest.toml @@ -51,12 +58,15 @@ coverage-node.lcov # Maven build output for the Java client and the Cameo plugin /client/java/**/target/ -/editors/cameo/**/target/ +/editors/mdk/**/target/ /editors/syson/**/target/ -!/editors/cameo/dist/ +!/editors/mdk/dist/ # Partial oracle record a failed `make fuml-expected` leaves for diagnosis /docs/project/fuml-referee-expected.json.failed # Windows VERSIONINFO resources, written by go-winres for GOOS=windows builds cmd/*/rsrc_windows_*.syso + +# The kernel a platform wheel bundles, staged by the release pipeline (setup.py) +/client/jupyter-kernel/jupyter_opensysml_kernel/bin/ diff --git a/AGENTS.md b/AGENTS.md index 14a324aec3..ba4f8c48be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ > **Prime directive:** If I wanted hacks, I'd write it myself. **Don't ever choose hacky over correct.** > Fix root causes upstream, not symptoms. Never weaken, skip, or delete tests to make them pass. -OpenSysML is a production-grade SysML v2 implementation in **Go 1.23+** (module `github.com/Open-MBEE/OpenSysML`). +The OpenSysML REDK (Runtime Environment and Development Kit) is a production-grade SysML v2 implementation in **Go 1.23+** (module `github.com/Open-MBEE/OpenSysML`). It provides a hand-written lexer/parser, semantic engine, execution runtime, LSP server (`sysml-lsp`), and REPL (`sysml`). --- @@ -81,6 +81,12 @@ expectation file holds no per-file counts and `-update-training` refuses to reco the other three are a **per-file ratchet** whose every movement must be adjudicated. Do not turn the assertion into a ratchet. +`sysml -render-document -doc-form pdf` drives external converters (WeasyPrint, pandoc, mermaid-cli) +that nothing else needs. `./scripts/download-doc-pdf-toolchain.sh` provisions pinned copies under +`build/doc-pdf/`; point `OPENSYSML_WEASYPRINT`, `OPENSYSML_PANDOC`, `OPENSYSML_MMDC` and +`OPENSYSML_MMDC_PUPPETEER` at them to run the PDF integration tests. Those tests skip rather than +fail when a converter is absent, so a passing run proves nothing until the converters were found. + The RDF mapping has a per-file ratchet of its own over every model under `examples/`, the downloaded corpora included: `TestCorpusRoundTrip` in `tests/corpus` converts each file notation → Turtle → notation → Turtle and pins the verdict. Run it with both require variables @@ -166,6 +172,8 @@ Then update `docs/project/spec-compliance.md` mapping: semantic rule → impleme 6. **Add/adjust tests** to lock in the fix and cover the failure mode. 7. **Verify** with the full gate in §2. Remove any temporary debug code and dead code. 8. **Commit** using Conventional Commits (see §7). Keep PRs focused (one feature/fix each). +9. **Open the PR as a draft** against `develop` and mark it ready for review once CI is green. Address + review findings as they arrive rather than waiting for CI. Merging is a maintainer's decision. --- @@ -176,7 +184,8 @@ Then update `docs/project/spec-compliance.md` mapping: semantic rule → impleme `changes/unreleased/.
.md` (`
` is `added`, `changed`, `fixed`, …; body is the list item(s) only — see the README there). Concurrent PRs then cannot conflict on the changelog; the release procedure folds fragments in. `python3 scripts/changelog.py check` validates them. -- **Comments:** don't add or remove comments/docs unrelated to your change. +- **Comments:** don't add or remove comments/docs unrelated to your change. Keep a code comment to + one or two lines; no paragraphs. - **Commit messages — Conventional Commits:** `(): ` - types: `feat`, `fix`, `docs`, `test`, `refactor`, `chore` - e.g. `fix(runtime): preserve transition effects when lowering state graph` @@ -192,6 +201,9 @@ Then update `docs/project/spec-compliance.md` mapping: semantic rule → impleme - The conformance records under `docs/project/` are the one exception: they cross-reference each other by these labels, so each opens with a **Labels** note defining them. If you add a record that uses them, add that note too. + - Nor anything only the session that produced the change could resolve: no references to a + conversation ("as discussed", "per your request"), no links to agent sessions, and no narration + of your own process ("after reviewing", "I installed"). Write for a reader who has only the tree. - Real keyboard shortcuts (`F2` to rename, `F5` in VS Code) are not internal labels — spell them `F2` so their meaning is unambiguous. - Identifiers in code (an `errata.Entry.ID`, a test name) may keep their labels; when code points diff --git a/CHANGELOG.md b/CHANGELOG.md index 5290b91d50..08788964b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,663 @@ release is described in [docs/project/releasing.md](docs/project/releasing.md). ## Unreleased +## 0.10.0 — 2026-10-08 + +### Added + +- **The action view draws the action's own parameters as pins on its frame, and its parameter bindings as edges.** An `action def` or action usage with `in`, `inout`, `out` or `return` parameters — declared, or a usage's inherited from its type — has them as pins on the frame boundary, inputs on the input side and outputs on the output side, as the SysML v2 action-flow notation shows them; a nested parameter's value naming the action's parameter (`in b = bread;`, `in bread = ToastBread::bread;`, `out x :>> x = y;`), an explicit `bind pack.boxed = toast;` and a `bind heat.t = pack.t;` between two nodes' pins are binding edges between the pins, in `dot`, `text`, `mermaid` and `plantuml` and through the service's render surfaces. The lowered `ActionGraph` carries the action's `Parameters` and its pins' `ValueBindings` for every consumer. The text form now lists each node's pins under it and names the pins an edge joins; Mermaid and PlantUML say which pins they cannot draw in a `not represented` notice. + +- **Agent skills for every OpenSysML client.** `packaging/agent-skills/` adds `opensysml-go`, + `opensysml-node`, `opensysml-java`, `opensysml-rust`, `opensysml-julia` and `opensysml-matlab` + beside `opensysml-python`, so an LLM coding agent can parse, evaluate, instantiate, run and verify + a model from any supported language: installing the client and resolving `sysml-grpc`, requiring + a clean model, the language's value types and integer pitfalls, and how its errors separate a + refused call from a reported model failure. + +- **Agent skills for modeling with OpenSysML.** `packaging/agent-skills/` holds three skills an LLM + coding agent can load to write SysML v2 that OpenSysML accepts, check and run it with `sysml`, and + drive it from Python through `opensysml`, each pointing at the handbook pages it summarizes. + +- **Load API element-form JSON files directly.** Explicitly named `.json` model files are converted to SysML notation before parsing; diagnostics refer to that notation. + +- **The CLI page runs the real `sysml` REPL in the browser, with guided walkthroughs.** The + page's toy shell over `sysml-engine` is replaced by `cmd/sysml` itself, built for js/wasm + with `-tags sysml_prod` (about 12 MB gzipped, downloaded when started), on an in-memory + filesystem holding the runtime-showcase models. Seven walkthroughs type their commands into + the prompt: units, the Saturn V mass rollup, the delta-v analysis and sweep, failing + requirements, stepping an action, a state machine on a clock and a user-written one. Up and + Down recall earlier entries, kept across visits, and Tab completes through the REPL's own + completer. `TestBrowserREPLWalkthroughs` runs every walkthrough through the page's host. +- **The landing-page diagram can be edited.** "Edit the model" opens the diagram's SysML + source; the engine re-parses and re-instantiates it as you type and redraws the parts and + interfaces, a parse error is shown with its line and column while the last good diagram + stays up, and "Run the model" executes the edited model. + +- **Add case and mixed diagrams.** Case views show use, analysis and verification cases with their actors, subjects, objectives and inclusion relationships; mixed views combine structure, state, action and case content on one canvas. + +- **`sysml -compile` compiles Strings, enumerations, mixed numbers, run-time function values and closures, and spends the step budget.** A compiled C or Go calc now takes, computes and prints Strings with the `StringFunctions` the interpreter implements, enumeration literals by identity, Real-typed values that hold Integers and mixed Integer/Real sequences as the interpreter promotes them, and function values that are chosen, stored, returned, compared, held in sequences or close over a body's bindings. A compiled program counts evaluation steps as the interpreter does and fails with its `evaluation step limit exceeded` error at the same count (`OPENSYSML_MAX_STEPS`). Records and calcs read off an object are still refused, as is, for C alone, a closure capturing a String, sequence or function. + +- **`sysml -compile` compiles records.** A compiled C or Go calc now constructs `attribute def` values (positionally, by label, with defaults), reads their scalar, sequence and nested features, compares them by identity as the interpreter does, and invokes, passes, stores and compares calcs read off a record (`s.scale`). A record as the program's result or command-line parameter is refused with a typed error naming it, since the interpreter prints a record by a session instance number; a feature read off one (`p.x`) is returned normally. A required feature a construction leaves unbound holds the interpreter's `` value: it prints ``, is not null and not empty, equals only itself, and fails with the interpreter's error where a value is required. The C target refuses a record holding a String or a collection. + +- Statements in a constraint or requirement body (`assign`, `if`, `while`/`loop`/`for`, and body-local declarations interleaved with them) now run as steps of the check's own performance before its conditions are evaluated, in declaration order: locals live in a fresh frame per check, the constraint's own parameters are copied into it, and each condition of the body is judged in the state the steps left. Assignments reaching outside the performance — a name it holds no feature for, a chained or qualified target — and effects it cannot perform (`send`, `perform`, `terminate`) are refused with the typed `ErrConstraintExternalAssignment`/`ErrConstraintEffect`, as are stated successions (`ErrStatementNotExecutable`), and a body stating steps but no result expression reports `ErrNoConditions`. The solver refuses to translate the steps of such a body rather than solving its conditions as if they ran. + +- **Convert to a compact form of API JSON over the service.** `ConvertRequest.compact` writes `api-json` from notation as one `api-json-compact/1` object in place of the element array: a table of the element ids, each written once, and the elements with every reference spelled as an index into it, unindented. `omit_derived` also leaves out the metamodel's derived properties, except the ones `keep_derived` names. Expanded it is the element array, and it composes with `documents`. Ids and references are most of the bytes of a large model, so a client that reads one receives a fraction of them. Reported as the `convert_compact` capability. + +- **Convert only some documents of a model over the service.** `ConvertRequest.documents` names documents of a `model_hash` parsed from several, and only the elements they declare are written; a reference into another document of the model links the `@id` that document's own conversion writes, and an element both declare is still refused. A client that edits one document of a large model, such as a project over the libraries it imports, converts that document again rather than the whole model, and replaces its elements by `@id`. Reported as the `convert_documents` capability. + +- **D2 is a rendering form.** `-render-form d2`, `%render d2 [palette]`, `"form": "d2"` on + `opensysml/render` and VS Code's *Export Diagram* write a tree, interconnection, state, action or + sequence rendering as a [D2](https://d2lang.com) diagram in the Pilot visualizer's B&W look — + nested containers for a part's parts and its drawn ports, pseudostate glyphs for control nodes, + D2's `sequence_diagram` for a sequence, the named palettes as fills, `-render-link` templates as + `link:` attributes — and `-render-all` writes `.d2` files. Documents take `-diagram-form d2` (`%render-document d2`, `diagramForm: + "d2"`): a ` ```d2 ` fence in Markdown, `
` in HTML, and in a PDF the figure drawn
+  by the `d2` executable that `OPENSYSML_D2` names (or `d2` on `PATH`), kept as source under a
+  notice without one. `scripts/download-doc-pdf-toolchain.sh` provisions a pinned D2.
+
+- **A data-import demo.** `examples/data-import-demo` imports a Python script's CSV, JSON, JSON Lines and TSV output into a rover model with `-import … -convert sysml -o`, a mapping file for the JSON report, `-import-dry-run` and `%import`.
+
+- **`-import` and `%import` set feature values from CSV, TSV, JSON and JSON Lines files.** Each row names an element and the values its features take, with units as `mass [kg]`; a declared value is replaced and an inherited one redefined, including a feature reached through an inherited part (`Fleet::car1::engine`) or an alias. An optional JSON mapping file maps differently named columns and nested fields, each `-import-map` and `-import-format` qualifying the `-import` before it, and `-import-dry-run`/`dry-run` previews the edit. JSON numbers and Booleans may be written as strings. The file is checked whole and applied as one edit, so a row, value or unit the model refuses leaves it unchanged; `-convert sysml -o` writes the imported model. See `docs/manual/importing-data.md`.
+
+- **`MigrationMetadata::DeferredKeeper` marks the keeping accept of a deferred signal.** The SysML v1 migrator and the PSSM referee write the accept of each keeping loop of the standard deferred-signal encoding as `#MigrationMetadata::DeferredKeeper action receive accept kept : Sig;`, so the accept that keeps the signal for the state is declared rather than recognized by its shape. An accept node takes prefix metadata like any other usage (`#M action a accept e : E;`), as the grammar's `PrefixMetadataMember` allows.
+- **The `deferred-keeper-unmarked` lint reports a keeping loop written without the marker.** An accept of a deferred signal at the root of the do action of a state annotated `MigrationMetadata::DeferredEvent` where no accept of that signal carries `MigrationMetadata::DeferredKeeper` — the shape the migrator wrote before the marker existed, not an ordinary consumer written beside a marked keeper — is reported as a warning in every mode, since the runtime now runs it as an ordinary accept that consumes the occurrence rather than keeping it; the model still analyses and runs, and re-migrating it or writing the marker on the accept clears the warning. The annotations are known by their resolved type, however the model spells them. The lint is switched off like the others, by its code.
+
+- The SysML v1 migrator writes a DocGen `TableExpressionColumn` written in an OCL subset — navigation chains, `->size()`, `->select`, `->exists`, `->collect`, `oclAsType`, `=`/`<>`, `and`/`or`/`not`, literals — as a `Column` computed over the row when the document's queries run: `_typedElementOfType->size()` counts the features typed by the row, a connector's `end…propertyPath` walks the ends' feature chains to the parts they pass through, their types and flow direction. An expression outside the subset is still refused with the expression quoted.
+- A DocGen column whose own chain collects before it reads (`CollectOwnedElements`, `CollectTypes`, a metaclass, stereotype or name filter, then `Name`/`Documentation`) is written as a `Column` applying the chain's query operations to the row and reading the attribute of every element collected; a step with no query spelling, or whose result only the elements known at migration spell (`CollectByAssociation`), keeps refusing the column with the step quoted.
+- A DocGen `StructuredQuery` or `Dynamic View` with `loop = true` is written once per element the chain collected, each pass over that element alone — one table (list, figure) per element behind its bullet, or one section per element when `createSections` is set — instead of one table over all of them.
+- `DocumentQueries::Distinct` keeps each element of a collection once, in first-seen order, and the query evaluator runs the standard library's `SequenceFunctions` and `ControlFunctions` (`size`, `last`, `excluding`, `select`, `collect`, `exists`, …) over row-relative collections in a `Column` cell — a connection's `connectorEnd`, an end's `chainingFeature`, `RelatedElements(source = row, …)`.
+
+- **The documentation site gains a Downloads page** listing release artifacts and
+  package-manager routes.
+
+- **The in-browser engine can render declared views and targeted pseudo-views, with ports and edge endpoint pins in both engine and language-server render data.** Diagram clients can draw a visitor's edited model from the same semantic rendering used by the workspace.
+
+- **A design note on exact Rational values beside a binary64 Real** (`docs/internals/design/exact-rational-literals.md`). It maps where a number is represented today — a `LiteralReal` rounds to a `float64` in `ParseReal`, `RealArith` and `IntQuotient` compute in binary64, `FormatReal` prints the shortest round-tripping decimal — against the two references, the pinned pilot (binary64, so `0.1 + 0.2` is `0.30000000000000004`) and sysml-toolkit (exact, so `0.3`), proposes a `big.Rat` value kind for literals and `Rational`-typed features with the `Real` type as the one place rounding enters, costs it against the tree, and recommends leaving the arithmetic as it is unless the project adopts toolkit parity as a conformance goal. No evaluator behavior changes.
+
+- **Return state-machine execution traces from the service, clients, and browser engine.** Opt-in traces expose typed event, state, transition, and choice records for a single run; a failed run keeps the records made before the failure on the client's execution error.
+
+- **A `GeneralView` draws the requirement, definition and usage, and package graphs its filters select.** A GeneralView whose element filters name the metaclasses the OMG library lists for a specialization — `filter @SysML::RequirementUsage;`, `expose P::**[@SysML::Definition or @SysML::Usage];`, `filter @SysML::Package;`, or an `or` of them — is drawn as a graph of nodes and edges in `text`, `mermaid`, `dot`, `plantuml` and `d2`, under the Pilot and Cameo styles and the palettes: requirements with their id and text and the satisfy, verify, derive, refine, allocate, specialization and typing edges between them; definitions and usages with specialization, typing, composition and reference edges; packages with containment and imports. An unfiltered GeneralView, and one whose filters are of any other shape, stays the containment tree it was. An opt-in `verdicts` overlay — `-render-overlay verdicts`, `%render  
verdicts`, `"overlay": "verdicts"` on `opensysml/render`, a document `Diagram`'s `overlay` — runs the verification cases verifying each drawn requirement and labels and colours it by their verdicts. A source link template (`-render-link`, `link=