diff --git a/changes/unreleased/portable-migration-inlines-libraries.added.md b/changes/unreleased/portable-migration-inlines-libraries.added.md new file mode 100644 index 0000000000..5808c58f01 --- /dev/null +++ b/changes/unreleased/portable-migration-inlines-libraries.added.md @@ -0,0 +1 @@ +- **`-portable` makes a SysML v1 migration one self-contained file.** With `-migrate sysml -portable`, the OpenSysML library packages the output refers to — `DiagramLayout`, `MigrationMetadata`, `StateMachines`, `Stochastic`, `DocumentQueries`, and whatever those refer to in turn — are appended after the model, a KerML library in its SysML spelling, so the file loads in a tool that ships only the standard library without changing what the migration writes; the report names the packages inlined. `-strict` remains the way to an output that refers to no OpenSysML library at all. diff --git a/changes/unreleased/strict-migration-standard-library.changed.md b/changes/unreleased/strict-migration-standard-library.changed.md new file mode 100644 index 0000000000..d7310c28ac --- /dev/null +++ b/changes/unreleased/strict-migration-standard-library.changed.md @@ -0,0 +1 @@ +- **A `-strict` SysML v1 migration refers to no OpenSysML library.** The output loads in a tool that ships only the standard library — the pilot implementation, or a modeling tool built on it — where it used to fail on `MigrationMetadata`, `StateMachines`, `Stochastic`, `DocumentQueries` and `DiagramLayout` references. Made-up name markers become comments, a choice or junction a plain state whose else branch is guarded by the negation of the other guards, a branch probability a comment, an interval wait its midpoint, a view carries no layout, a Monte Carlo analysis loses its `Simulation::MonteCarlo` generalization, and a history pseudostate, a document (with every view and paragraph in it) or a table is refused with a report line; a layout export is still joined to the views, the report counting the geometry it omits; the deferral encoding keeps its `MigrationMetadata::Deferred*` annotations. The default migration is unchanged. diff --git a/cmd/sysml/convert_test.go b/cmd/sysml/convert_test.go index ed26632b55..4c2a7322d8 100644 --- a/cmd/sysml/convert_test.go +++ b/cmd/sysml/convert_test.go @@ -524,6 +524,8 @@ func TestMigrateMigratesXMI(t *testing.T) { "migrate several models": {[]string{v1, v1, "-migrate", "sysml"}, "-migrate migrates one SysML v1 model per run"}, "id with migrate": {[]string{v1, "-migrate", "ttl", "-id", "uuid"}, "-id accompanies -convert"}, "image base URL with convert": {[]string{model, "-convert", "ttl", "-image-base-url", "https://ve.example.org"}, "-image-base-url accompanies -migrate"}, + "portable with convert": {[]string{model, "-convert", "ttl", "-portable"}, "-portable accompanies -migrate"}, + "portable with validate": {[]string{model, "-validate", "-portable"}, "-portable accompanies -migrate"}, "report over the model": {[]string{xmi, "-migrate", "sysml", "-o", textReport, "-migration-report", textReport}, "-migration-report and -o both name"}, "report over the model through dangling links": {[]string{xmi, "-migrate", "sysml", "-o", danglingLink(t, dir, "model-link", "shared.txt"), "-migration-report", danglingLink(t, dir, "report-link", "shared.txt")}, "-migration-report and -o both name"}, "report over the input": {[]string{xmi, "-migrate", "sysml", "-migration-report", xmi}, "names the model being migrated"}, diff --git a/cmd/sysml/features.go b/cmd/sysml/features.go index ec9732d9c2..82137d0e1f 100644 --- a/cmd/sysml/features.go +++ b/cmd/sysml/features.go @@ -53,7 +53,7 @@ func boolFlags(names ...string) []featureFlag { var ( v1Feature = &feature{ name: "v1", - flags: flags("migrate", "migration-report", "migration-results", "layout", "image-base-url"), + flags: append(flags("migrate", "migration-report", "migration-results", "layout", "image-base-url"), boolFlags("portable")...), values: map[string][]string{"from": {"xmi", "uml", "mdzip"}}, sections: []string{"Migration"}, markers: []string{"SysML v1"}, diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index e088b5bc86..0949e5d38e 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -152,6 +152,7 @@ var ( htmlMath string htmlTheme string strictMode bool + portableMode bool disabledLints lintList enabledLints lintList noRecordCache bool @@ -544,6 +545,10 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -image-base-url accompanies -migrate of a SysML v1 model; write `sysml Model.mdzip -migrate sysml -image-base-url https://ve.example.org`") return 2 } + if portableMode && migrateFormat == "" { + fmt.Fprintln(os.Stderr, "sysml: -portable accompanies -migrate of a SysML v1 model; write `sysml Model.mdzip -migrate sysml -portable -o Model.sysml`") + return 2 + } if message := importMisuse(); message != "" { fmt.Fprintf(os.Stderr, "sysml: %s\n", message) return 2 diff --git a/cmd/sysml/migrate.go b/cmd/sysml/migrate.go index c708046109..dab269107a 100644 --- a/cmd/sysml/migrate.go +++ b/cmd/sysml/migrate.go @@ -27,6 +27,7 @@ func init() { fs.StringVar(&migrationResults, "migration-results", "", "With -migrate, write the run configurations and the result snapshots the simulation tool stored for them to this JSON file, for -compare-results to read against the migrated model") fs.StringVar(&layoutPath, "layout", "", "With -migrate, read this MTIP export (HUDS XML) and write the diagram geometry it records as DiagramLayout annotations in the migrated views") fs.StringVar(&imageBaseURL, "image-base-url", "", "With -migrate, the http(s) URL a comment's relative is resolved against, such as the View Editor server") + fs.BoolVar(&portableMode, "portable", false, "With -migrate, append the OpenSysML library packages the output refers to, so the one file loads in a tool that ships only the standard library; the report names them") }) } @@ -139,7 +140,7 @@ func migrateInput(name string, data []byte, _ convert.Format, to convert.Format) // options; none were given when the flag was not passed. func migrationOptions() (convert.MigrateOptions, error) { if layoutPath == "" { - return convert.MigrateOptions{ImageBaseURL: imageBaseURL, Strict: strictMode}, nil + return convert.MigrateOptions{ImageBaseURL: imageBaseURL, Strict: strictMode, Portable: portableMode}, nil } data, err := os.ReadFile(layoutPath) if err != nil { @@ -149,7 +150,7 @@ func migrationOptions() (convert.MigrateOptions, error) { if err != nil { return convert.MigrateOptions{}, fmt.Errorf("%s: %w", layoutPath, err) } - return convert.MigrateOptions{Layout: layout, LayoutSource: layoutPath, ImageBaseURL: imageBaseURL, Strict: strictMode}, nil + return convert.MigrateOptions{Layout: layout, LayoutSource: layoutPath, ImageBaseURL: imageBaseURL, Strict: strictMode, Portable: portableMode}, nil } // writeMigrationReport writes the report to the -migration-report file (JSON when diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 51abc40e69..c5ce4c4363 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -649,7 +649,7 @@ func registerFlags(fs *flag.FlagSet) { fs.Var(&disabledLints, "disable-lint", "Leave this lint out of the model's diagnostics: undeclared-signal, port-type-mismatch, deferred-keeper-unmarked or rounded-real-literal, comma-separated or repeated") fs.Var(&enabledLints, "enable-lint", "Report this opt-in lint, off by default, in the model's diagnostics: rounded-real-literal, comma-separated or repeated; -disable-lint wins") fs.BoolVar(&noRecordCache, "no-record-cache", false, "Parse every file loaded and hold it loaded, reading no interface record from the record cache and writing none; default off, or OPENSYSML_RECORD_CACHE=0") - fs.BoolVar(&strictMode, "strict", false, "Judge the model as conforming SysML v2: notation no pinned production admits is an error, not a warning; a SysML v1 migration writes none of it") + fs.BoolVar(&strictMode, "strict", false, "Judge the model as conforming SysML v2: notation no pinned production admits is an error, not a warning; a SysML v1 migration writes none of it and refers to no OpenSysML library") fs.Var(&modelChecks.constraints, "constraint", "Evaluate this constraint and exit (repeatable)") fs.BoolVar(&modelChecks.selfCheck, "self-check", false, "Apply the SysMLValidation constraints to every reflectively classified model element and exit") fs.Var(&modelChecks.selfCheckPackages, "self-check-package", "Apply this package's constraint defs with the SysMLValidation ones, as -self-check-package Acme::ModelingRules; implies -self-check (repeatable)") @@ -826,6 +826,7 @@ func optionGroups() []usage.OptionGroup { usage.Opt("migration-results", fileArg), usage.Opt("layout", fileArg), usage.Opt("image-base-url", ""), + usage.Opt("portable", ""), usage.Opt("compare-results", fileArg), }, }, { diff --git a/docs/guide/03-command-line.md b/docs/guide/03-command-line.md index a0eddcb438..073507f213 100644 --- a/docs/guide/03-command-line.md +++ b/docs/guide/03-command-line.md @@ -226,7 +226,7 @@ friends — reported as warnings that name the replacement, so a model that uses still analyses cleanly. `-strict` promotes those warnings to errors, which turns the run into a test of whether the file is conforming SysML v2. The flag applies to `-migrate` too: a strict SysML v1 migration writes no extension notation -at all — see [Portable output with `-strict`](../reference/sysml-v1-migration.md#portable-output-with--strict). +at all and refers to no OpenSysML library — see [Portable output with `-strict`](../reference/sysml-v1-migration.md#portable-output-with--strict); `-portable` instead appends the OpenSysML library packages a default migration refers to, so the one file loads elsewhere unchanged. The state machine below uses the `choice` extension so the difference is visible: diff --git a/docs/guide/11-migrating-from-sysml-v1.md b/docs/guide/11-migrating-from-sysml-v1.md index 7c6b6c704d..34607fe499 100644 --- a/docs/guide/11-migrating-from-sysml-v1.md +++ b/docs/guide/11-migrating-from-sysml-v1.md @@ -315,24 +315,39 @@ its type: `end [0..1] driver : Driver;`. From this point `%save` in the REPL, `- LSP and the clients all take the file as they take any other; nothing remembers that it was migrated. -## Portable output with `-strict` - -Pseudostates are written through the `StateMachines` library's metadata -spellings — `#StateMachines::junction state x;` — with `private import -StateMachines::*;` added to each package that holds one, so a migrated model -reads as conforming SysML v2 either way. Pass `-strict` (see -[Strict conformance](03-command-line.md#strict-conformance)) and the migration -still writes only notation a pinned grammar admits — the pseudostates keep -their `StateMachines` metadata spellings in both modes: - -```console -$ sysml Project.xmi -strict -migrate sysml -o Project.sysml -$ sysml -strict -validate Project.sysml -✓ Project.sysml: no errors -``` - -What a strict migration refuses is described in the reference under -[Portable output with `-strict`](../reference/sysml-v1-migration.md#portable-output-with--strict). +## Portable output: `-portable` and `-strict` + +A migrated model refers to OpenSysML's own library packages where v1 has no standard v2 +form: a pseudostate is written through the `StateMachines` library's metadata spellings +(`#StateMachines::junction state x;`, with `private import StateMachines::*;` added to each +package that holds one), a branch probability through `Stochastic`, a view's geometry through +`DiagramLayout`, the names the migration made up through `MigrationMetadata`. OpenSysML ships +them, so the file loads here as it is; a tool that ships only the standard library — the pilot +implementation, or a modeling tool built on it — reports every such reference unresolved. Two +flags make the output portable to such a tool: + +- `-portable` keeps the migration as it is and appends the library packages the output refers + to, so the one file carries everything it needs; the report names them: + + ```console + $ sysml Project.xmi -migrate sysml -portable -o Project.sysml + migration: … inlined 4 OpenSysML library package(s): DiagramLayout, MigrationMetadata, StateMachines, Stochastic + ``` + +- `-strict` (see [Strict conformance](03-command-line.md#strict-conformance)) writes no + OpenSysML reference at all — a pseudostate as a plain state, a probability as a comment, a + view without geometry — at the cost of what the reference lists under + [Portable output with `-strict`](../reference/sysml-v1-migration.md#portable-output-with--strict), + so the output also reads as conforming SysML v2: + + ```console + $ sysml Project.xmi -strict -migrate sysml -o Project.sysml + $ sysml -strict -validate Project.sysml + ✓ Project.sysml: no errors + ``` + +Both are described in the reference under +[Portable output with `-portable`](../reference/sysml-v1-migration.md#portable-output-with--portable). A deferred signal is standard notation in both modes: the state gets an `item` buffer its do action fills from an accept loop while the state is active, substates included, and its exit diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d59b7cdff8..8459356425 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -249,6 +249,7 @@ the same member-path parser as `Project` and `OrderBy`. | `--migration-report ` | | With `--migrate`: write the element-by-element migration report to this file, JSON when it ends in `.json`, text otherwise. Without it the one-line summary goes to stderr | | `--migration-results ` | | With `--migrate`: write the simulation tool's run configurations (`SimulationProfile:SimulationConfig`) and the result snapshots it stored for each of them to this JSON file — the sidecar `-compare-results` reads against the migrated model. See [Comparing a migrated configuration with the tool's results](#comparing-a-migrated-configuration-with-the-tools-results) | | `--image-base-url ` | | With `--migrate`: the absolute http(s) URL a comment's relative `` — a path the View Editor serves, such as `/projects/.../png` — is resolved against, so the migrated document's `Image` block points at the server instead of losing the image (see [SysML v1 migration](sysml-v1-migration.md)) | +| `--portable` | | With `--migrate`: append the OpenSysML library packages the output refers to, so the one file loads in a tool that ships only the standard library; the report names them (see [Portable output with `--portable`](sysml-v1-migration.md#portable-output-with--portable)) | | `--render ` | | Render this view of the model (every file named, loaded as one) instead of running it, in the form its `render` member states (see [Rendering a view](#rendering-a-view)) | | `--render-all ` | | Render every declared view into the directory, one artifact per view | | `--graphs ` | | Write the lowered graph of this action or state machine, and of every behavior it performs, as the canonical `graphs:1` JSON an external analysis engine is sent ([the `graphs:1` model form](external-engines.md#the-graphs1-model-form)) to stdout, or to `--output`. The subject is a qualified name, definition or usage; a name that is no behavior, or that nothing declares, is refused with exit status 2. Cannot be combined with checks, rendering, `--convert`, `--migrate`, `--query` or `--eval` | diff --git a/docs/reference/sysml-v1-migration.md b/docs/reference/sysml-v1-migration.md index 1ac8bd2cc1..7bf43d8b11 100644 --- a/docs/reference/sysml-v1-migration.md +++ b/docs/reference/sysml-v1-migration.md @@ -38,7 +38,7 @@ sysml: Model.mdzip is a SysML v1 model, which is migrated, not converted: every ``` and `-migrate` refuses v2 input the same way, pointing at `-convert`. The companion flags — -`-migration-report`, `-migration-results`, `-layout`, `-image-base-url` and `-strict` — accompany +`-migration-report`, `-migration-results`, `-layout`, `-image-base-url`, `-portable` and `-strict` — accompany `-migrate`. The same migration is available over gRPC (`Migrate`, with `from_format: "xmi"`, `"uml"` or @@ -1976,24 +1976,68 @@ Every migrated model is gated in the test suite to: A model the reader cannot make sense of — not XMI, a zipped project container, a document without a model — is refused with an error naming the reason rather than migrated partially. +### Portable output with `-portable` + +The default migration writes what v1 has no standard v2 form for through OpenSysML's own +library packages — `StateMachines` for a pseudostate, `Stochastic` for a branch probability, +`DiagramLayout` for a view's geometry, `MigrationMetadata` for the names the migration made up, +`DocumentQueries` for a document or table, `RandomFunctions` or `Simulation` for a simulation +profile — which OpenSysML ships and a tool built on the standard library alone does not. +`-portable` leaves the migration as it is and appends those packages to the output: every +library package the notation refers to by qualified name, and every one those refer to in +turn, follows the model under a comment saying so, written as the library ships it except +that a `standard library package` becomes a `library package`, which is what a file of one's +own may declare. A KerML library (`RandomFunctions`, `OpenSysMLMathFunctions`) is written in +its SysML spelling, its functions as `calc def`s; one with no SysML spelling is left +referenced, and the report says so. The report's summary names the packages inlined — +`inlined 5 OpenSysML library package(s): DiagramLayout, DocumentQueries, MigrationMetadata, +StateMachines, Stochastic` — and its JSON form lists them under `libraries`. The one file then +loads in the pilot implementation, or a modeling tool built on it, on its own; OpenSysML loads +it too, the inlined packages standing in for its own. A migration that must also read as +standard notation, with no OpenSysML reference left in it, is `-strict`'s, below. + ### Portable output with `-strict` A migration under `-strict` writes only notation a pinned SysML v2 production admits, so the -output analyses clean under [strict conformance](../guide/03-command-line.md#strict-conformance) -and carries nothing an interchange partner could not read. The `choice`, `junction`, -`shallowHistory` and `deepHistory` pseudostates are written -`#StateMachines:: state x;` — qualified so a member named like the metadata cannot -shadow it — with `private import StateMachines::*;` added to each package holding one, -under `-strict` the same as by default, and a `deferrableTrigger` is written in the -standard notation described under [Deferred signals](#deferred-signals) in both modes, -its deferred signal named by the `@MigrationMetadata::DeferredEvent` annotation. What a -strict migration still refuses is whatever has no standard v2 form at all; a transition to -or from an unmapped vertex is refused rather than written to an undeclared name. - -To check strict output against the pinned pilot implementation, validate it together with the -whole OpenSysML library directory, `.kerml` files included (`RandomFunctions`, which migrated -Monte Carlo analyses call, is KerML); see -[Pilot differential](../project/pilot-differential.md#the-kerml-side-of-the-bridge). +output analyses clean under [strict conformance](../guide/03-command-line.md#strict-conformance), +and refers to no library beyond the standard's, so a tool without OpenSysML's libraries — the +pilot implementation, or a modeling tool built on it — loads the file on its own. What the +default migration writes through an OpenSysML library a strict one writes otherwise, or refuses +with a report line: + +- the `MigrationMetadata::SynthesizedName`, `StandIn` and `LibraryNameAvoided` markers become + comments (`// names the migration made up: a, b`), the report carrying the same information; +- a `choice` or `junction` pseudostate, by default `#StateMachines:: state x;`, is a plain + `state x;` whose guarded transitions are the pseudostate's, reported approximated since a + run no longer passes through it in one step; its `else` branch, which a plain state's + transitions would compete with rather than fall back to, is guarded by the negation of the + other guards (`if not (count < 2)`), or written unguarded with a report line saying so when + one of them is not a v2 expression; a `shallowHistory` or `deepHistory` pseudostate, + which has no standard form, is refused, with the transitions into and out of it; +- a decision node's branch probability, by default `@Stochastic::Probability`, trails the + succession as a comment (`then b; // probability 0.25`); +- a duration constraint over an interval, by default a `RandomFunctions::uniform` draw, is a + fixed wait of the interval's midpoint when both bounds are literal numbers, refused otherwise; +- a «Document», a «DiagramTable» or «InstanceTable», whose forms are `DocumentQueries` + documents, are refused, a «Document» before its content is planned, so every view and + paragraph in it is reported unmapped with it; a Monte Carlo analysis is written as the ordinary analysis without its + `Simulation::MonteCarlo` generalization, reported approximated, and a simulation configuration + with its run settings as a comment in place of `@Simulation::Configuration`; +- a view carries no `DiagramLayout` geometry; a `-layout` export and the diagrams' own symbol + streams are still joined to the views they match, the report's layout account counting the + views whose geometry is omitted rather than reporting them missing. + +The one OpenSysML reference a strict migration keeps is the deferral encoding's +`@MigrationMetadata::DeferredEvent` and `#MigrationMetadata::DeferredKeeper` annotations, +described under [Deferred signals](#deferred-signals): the encoding itself is standard notation, +and the annotations are what lets a run give a transition on the deferred signal its due; a +tool that does not know them reads the state as it is written. A transition to or from an +unmapped vertex is refused rather than written to an undeclared name. + +Strict output validates against the pinned pilot implementation with the standard library +alone; see [Pilot differential](../project/pilot-differential.md). Default output needs the +whole OpenSysML library directory beside it, `.kerml` files included (`RandomFunctions`, which +migrated Monte Carlo analyses call, is KerML). #### Deferred signals @@ -2060,12 +2104,12 @@ transition the migration writes takes a signal, whatever it is written to — a transition into a terminate pseudostate, written to `done`, or into a submachine state through a connection point reference, written to the entry point's state, counts as any other: one it refuses — into a final state of another region, a state of -another machine, a vertex whose ends it refuses, or, under `-strict`, a pseudostate with no v2 -form such as a choice — takes none, so the deferral keeps every route it would have accepted -by; nor does such a completion transition drop the deferral. This is the one point where the -modes differ: the default migration writes a `choice` and the transition into it, so that -transition wins over the deferral as any other, while `-strict` refuses both and the state -keeps the signal. An internal transition of the +another machine, a vertex whose ends it refuses, or, under `-strict`, a history pseudostate, +which has no standard v2 form — takes none, so the deferral keeps every route it would have +accepted by; nor does such a completion transition drop the deferral. This is the one point +where the modes differ: the default migration writes the history pseudostate and the +transition into it, so that transition wins over the deferral as any other, while `-strict` +refuses both and the state keeps the signal. An internal transition of the state is written as a self transition, which exits and re-enters the state where v1 stayed in it: the exit action sends the kept occurrences to self, and the accept loop, started again by the re-entry, keeps them again unless a transition then accepts them — so the buffer survives diff --git a/internal/translate/migrate/activity.go b/internal/translate/migrate/activity.go index cc7e1c3121..25a261ae1a 100644 --- a/internal/translate/migrate/activity.go +++ b/internal/translate/migrate/activity.go @@ -967,6 +967,11 @@ func (m *migration) waitOf(e *sysmlv1.Element) wait { case lo == hi: expr = lo note = joinNotes(note, "written as a fixed wait of "+lo+" s before "+describe(e)) + case m.strict && (lerr != nil || herr != nil): + return wait{dc: dc, why: noStrictDraw(lo, hi)} + case m.strict: + expr = computedLiteral((lf + hf) / 2) + note = joinNotes(note, "written as a fixed wait of "+expr+" s, the midpoint of ["+lo+", "+hi+"], before "+describe(e)+strictMidpointWhy) default: expr = "RandomFunctions::uniform(" + lo + ", " + hi + ")" note = joinNotes(note, "written as a wait drawn uniformly over ["+lo+", "+hi+"] s before "+describe(e)+"; a tool's fixed min or max mode is a run setting, not the model's") @@ -1101,16 +1106,33 @@ func (a *activity) decisionSuccessions(n *sysmlv1.Element, from string, outs []* } a.m.w.lines(guards[i].comment) tail := ";" - if weights != nil { + verdict, note := Mapped, "" + switch { + case weights == nil: + case a.m.strict: + tail = "; // probability " + weights[i] + verdict, note = Approximated, "its probability "+weights[i]+" is written as a comment: a strict migration names no OpenSysML library, and Stochastic::Probability is one" + default: tail = " { @Stochastic::Probability { p = " + weights[i] + "; } }" } a.succession([]*sysmlv1.Element{e}, from, guards[i].expr, to, tail) - if guards[i].ok { - a.m.add(e, Mapped, a.m.edgeTarget(e), "") + switch { + case guards[i].ok: + a.m.add(e, verdict, a.m.edgeTarget(e), note) + case note != "": + a.m.note(e, note) } } } +// strictMidpointWhy and noStrictDraw say why a strict migration writes a wait +// over an interval as its midpoint, or cannot. +const strictMidpointWhy = ": a strict migration names no OpenSysML library, and a draw over the interval is RandomFunctions::uniform" + +func noStrictDraw(lo, hi string) string { + return "a wait drawn over [" + lo + ", " + hi + "] is RandomFunctions::uniform, an OpenSysML library a strict migration does not name, and the bounds are no literal numbers to take the midpoint of" +} + // arbitraryChoice weights the unconditional branches of a decision equally, since // UML leaves the branch taken among several true guards unspecified. func (a *activity) arbitraryChoice(n *sysmlv1.Element, outs []*sysmlv1.Element, tos []string, guards []guardText, elseAt int) []string { diff --git a/internal/translate/migrate/diagrams.go b/internal/translate/migrate/diagrams.go index 720e7d5a2b..798b3460f9 100644 --- a/internal/translate/migrate/diagrams.go +++ b/internal/translate/migrate/diagrams.go @@ -845,12 +845,18 @@ func (m *migration) viewGeometry(v *view, x exposures, form viewForm) viewGeomet case src.export: s.DiagramsJoined++ m.layoutJoined[rec.ID] = true - if src.stream { + if src.stream && !m.strict { s.StreamSupplemented++ } default: s.StreamDiagrams++ } + if m.strict { + s.GeometryOmitted++ + // Nothing is written, so the stream supplements nothing. + source = m.layoutSourceName(layoutSources{export: src.export}) + return viewGeometry{note: "layout from " + source + " omitted: a strict migration names no OpenSysML library, and DiagramLayout is one's"} + } prefix := diagramLayoutPrefix if m.shadowsLibrary("DiagramLayout", v.host) { prefix = globalDiagramLayoutPrefix diff --git a/internal/translate/migrate/diagrams_internal_test.go b/internal/translate/migrate/diagrams_internal_test.go index b93cd7e526..e706a592a2 100644 --- a/internal/translate/migrate/diagrams_internal_test.go +++ b/internal/translate/migrate/diagrams_internal_test.go @@ -328,7 +328,7 @@ func TestDiagramNotesDoNotNameOmittedVertices(t *testing.T) { strict bool anchored bool }{ - {name: "strict", strict: true, anchored: true}, + {name: "strict", strict: true}, {name: "non-strict", anchored: true}, } { t.Run(tc.name, func(t *testing.T) { @@ -348,6 +348,13 @@ func TestDiagramNotesDoNotNameOmittedVertices(t *testing.T) { } r := FromModelOptions("diagrams.xmi", model, Options{Strict: tc.strict}) got := string(r.Notation) + if tc.strict { + // A strict migration writes no layout, DiagramLayout being OpenSysML's. + if strings.Contains(got, "DiagramLayout") { + t.Errorf("strict notation names DiagramLayout:\n%s", got) + } + return + } anchored := strings.Contains(got, "metadata DiagramLayout::Note about") if anchored != tc.anchored { t.Errorf("note anchored = %t, want %t:\n%s", anchored, tc.anchored, got) diff --git a/internal/translate/migrate/documents.go b/internal/translate/migrate/documents.go index 8645dcd504..8a1b2b9804 100644 --- a/internal/translate/migrate/documents.go +++ b/internal/translate/migrate/documents.go @@ -195,6 +195,10 @@ func (m *migration) planDocument(d *sysmlv1.DocGenDocument) { if title == "" { title = "Document" } + if m.strict { + m.strictDocument(d, host, title) + return + } dp := &docPlan{d: d, host: host, refused: map[string]bool{}} dp.root = §ionPlan{v: d.Root, title: title, names: columnNames{}} dp.root.name = m.viewName(host, title+docSuffix) @@ -203,6 +207,30 @@ func (m *migration) planDocument(d *sysmlv1.DocGenDocument) { m.extras[host] = append(m.extras[host], func() { m.writeDocument(dp) }) } +// strictDocument accounts for a document a strict migration does not write, +// before any of its content is planned: a comment where the Document would +// be, and an Unmapped entry for it and for every view and paragraph in it. +func (m *migration) strictDocument(d *sysmlv1.DocGenDocument, host *sysmlv1.Element, title string) { + note := "its v2 form is a Document of OpenSysML's DocumentQueries library, which a strict migration does not name" + m.extras[host] = append(m.extras[host], func() { + m.w.lines(commentLines("not migrated: «Document» '" + title + "' — " + note)) + }) + m.report.Entries = append(m.report.Entries, *m.docEntry(d, Unmapped, "", note)) + var walk func(v *sysmlv1.DocGenView) + walk = func(v *sysmlv1.DocGenView) { + if v.Class != d.Class { + m.report.Entries = append(m.report.Entries, *m.nodeEntry(v.Class, viewApplication(v.Class), Unmapped, "it belongs to «Document» '"+title+"', which is not migrated: "+note)) + } + for _, p := range v.Paragraphs { + m.report.Entries = append(m.report.Entries, *m.nodeEntry(p.Comment, p.Application, Unmapped, "it belongs to «Document» '"+title+"', which is not migrated: "+note)) + } + for _, c := range v.Children { + walk(c) + } + } + walk(d.Root) +} + // nameAnchors names the anchors after their definitions, clear of every // member name the document declares, so a chain from one resolves anywhere in it. func (m *migration) nameAnchors(dp *docPlan) { diff --git a/internal/translate/migrate/expression.go b/internal/translate/migrate/expression.go index a7e61dbf61..c892b97c84 100644 --- a/internal/translate/migrate/expression.go +++ b/internal/translate/migrate/expression.go @@ -50,6 +50,8 @@ type treeLowering struct { } // feature answers a placeholder for an instance operand, else asks the scope. +func (l *treeLowering) strictMigration() bool { return l.s.strictMigration() } + func (l *treeLowering) feature(path []string, write bool) (opaqueRef, *refusal) { if ref, ok := l.leaves[path[0]]; ok && len(path) == 1 { return ref, nil diff --git a/internal/translate/migrate/interaction.go b/internal/translate/migrate/interaction.go index 5347e58d93..0a47f8fc33 100644 --- a/internal/translate/migrate/interaction.go +++ b/internal/translate/migrate/interaction.go @@ -1623,6 +1623,11 @@ func (s *scenario) waitExpr(dc *sysmlv1.Element) (expr, note string, ok bool) { return "", "the interval's min " + lo + " exceeds its max " + hi, false case lo == hi: return lo, joinNotes(note, "a fixed wait of "+lo+" s"), true + case m.strict && (lerr != nil || herr != nil): + return "", noStrictDraw(lo, hi), false + case m.strict: + mid := computedLiteral((lf + hf) / 2) + return mid, joinNotes(note, "a fixed wait of "+mid+" s, the midpoint of ["+lo+", "+hi+"]"+strictMidpointWhy), true } return "RandomFunctions::uniform(" + lo + ", " + hi + ")", joinNotes(note, "a wait drawn uniformly over ["+lo+", "+hi+"] s; a tool's fixed min or max mode is a run setting, not the model's"), true diff --git a/internal/translate/migrate/migrate.go b/internal/translate/migrate/migrate.go index 6824338ed3..5c6446ecb3 100644 --- a/internal/translate/migrate/migrate.go +++ b/internal/translate/migrate/migrate.go @@ -60,11 +60,16 @@ type Options struct { // ImageBaseURL resolves a comment's relative to the server // serving it; "" leaves such images out. ImageBaseURL string - // Strict writes only notation a pinned SysML v2 production admits: a - // construct whose only v2 form is an OpenSysML extension (a deferred - // event, a choice, junction or history pseudostate) is reported unmapped - // instead of written. + // Strict writes only notation a pinned SysML v2 production admits and + // refers to no OpenSysML library: a construct whose only v2 form is one + // (a history pseudostate, a document) is reported unmapped instead of + // written, one with a standard approximation (a choice, a probability) + // written so and reported approximated. Strict bool + // Portable appends the OpenSysML library packages the output refers to, + // so the one file loads in a tool that ships only the standard library; + // what is written is otherwise unchanged. The report names the packages. + Portable bool } // Migrate reads a SysML v1 model as UML XMI, or a zip archive (such as a @@ -270,7 +275,11 @@ func FromModelOptions(name string, model *sysmlv1.Model, opts Options) *Result { m.layoutReport() m.extensions() m.report.Images = m.imagesWritten - return &Result{Notation: []byte(m.w.String()), Report: m.report, Results: m.results, Files: m.files} + notation := []byte(m.w.String()) + if opts.Portable { + notation, m.report.Libraries = inlineLibraries(notation) + } + return &Result{Notation: notation, Report: m.report, Results: m.results, Files: m.files} } // unwrittenEvents reports the events whose triggers were never written: those @@ -863,6 +872,10 @@ func (m *migration) libraryNameNotes() { prefix = "$::" } for _, r := range list { + if m.strict { + m.w.line("// " + writeName(r.fresh) + " is written for " + stringLiteral(r.src) + ", a name of the standard library") + continue + } m.w.line("metadata " + prefix + "MigrationMetadata::LibraryNameAvoided about " + writeName(r.fresh) + " { sourceName = " + stringLiteral(r.src) + "; }") } diff --git a/internal/translate/migrate/montecarlo.go b/internal/translate/migrate/montecarlo.go index 7313e2ad05..1a487a086d 100644 --- a/internal/translate/migrate/montecarlo.go +++ b/internal/translate/migrate/montecarlo.go @@ -221,7 +221,7 @@ func (m *migration) generalizesMonteCarlo(c *sysmlv1.Element) bool { // monteCarloCaseOf is the analysis def written beside a block: one that generalizes // the tool's MonteCarloAnalysis, or binds a statistic of one it inherits; nil for others. func (m *migration) monteCarloCaseOf(block *sysmlv1.Element) *monteCarloCase { - if block == nil || block.IsProxy() { + if block == nil || block.IsProxy() || m.strict { return nil } if cs, ok := m.monteCarlo[block]; ok { @@ -426,7 +426,10 @@ func (cs *monteCarloCase) statistics() []string { // MonteCarloAnalysis is written: as the sibling analysis def. func (m *migration) monteCarloGeneralization(block *sysmlv1.Element) string { cs := m.monteCarloCaseOf(block) - if cs == nil { + switch { + case cs == nil && m.strict: + return "generalization of the simulation tool's " + monteCarloAnalysisBlock + " is not written: its v2 form is an analysis def :> " + monteCarloLibraryCase + ", an OpenSysML library a strict migration does not name" + case cs == nil: return "generalization of the simulation tool's " + monteCarloAnalysisBlock + " is not written: the classifier is written as no part def for an analysis to take as its subject" } return "generalization of the simulation tool's " + monteCarloAnalysisBlock + " is written as the analysis def " + writeName(cs.name) + " :> " + monteCarloLibraryCase + " beside the part def, which is its subject" diff --git a/internal/translate/migrate/names.go b/internal/translate/migrate/names.go index 0127cd7d9f..4411b128e6 100644 --- a/internal/translate/migrate/names.go +++ b/internal/translate/migrate/names.go @@ -81,9 +81,20 @@ func (m *migration) standInNames(written []string) string { return m.migrationMarker(standInFQN, written) } +// strictMarkerText words the comment a strict migration writes instead of +// each MigrationMetadata marker, the library being OpenSysML's, not the standard's. +var strictMarkerText = map[string]string{ + synthesizedNameFQN: "names the migration made up", + standInFQN: "members standing for no source element", +} + // migrationMarker is the metadata usage of the MigrationMetadata definition -// fqn about the written names, qualified past a member shadowing the library. +// fqn about the written names, qualified past a member shadowing the library; +// a strict migration writes a comment instead. func (m *migration) migrationMarker(fqn string, written []string) string { + if m.strict { + return "// " + strictMarkerText[fqn] + ": " + strings.Join(written, ", ") + } prefix := "" if m.shadowsLibrary("MigrationMetadata", m.scope) { prefix = "$::" diff --git a/internal/translate/migrate/opaque.go b/internal/translate/migrate/opaque.go index 67228b7ef9..e72a4a0dac 100644 --- a/internal/translate/migrate/opaque.go +++ b/internal/translate/migrate/opaque.go @@ -747,6 +747,7 @@ type opaqueParser struct { i int d dialect kerml bool // a lowered Expression tree may spell the KerML connectives xor and implies + strict bool // write the standard library's functions only, never OpenSysMLMathFunctions sc featureResolver locals map[string]local // names a `var`, `let` or `const` declared assigns bool // whether `=` assigns (a statement) rather than compares @@ -771,7 +772,16 @@ func newOpaqueParser(body string, d dialect, sc featureResolver) (*opaqueParser, if err != nil { return nil, err } - return &opaqueParser{toks: toks, d: d, sc: sc, locals: map[string]local{}, assigned: map[string]bool{}, guarded: map[string][]string{}}, nil + p := &opaqueParser{toks: toks, d: d, sc: sc, locals: map[string]local{}, assigned: map[string]bool{}, guarded: map[string][]string{}} + if s, ok := sc.(strictTeller); ok { + p.strict = s.strictMigration() + } + return p, nil +} + +// strictTeller is a featureResolver telling whether the migration is strict. +type strictTeller interface { + strictMigration() bool } // local is a name a declaration introduced: the scalar it holds and whether @@ -1504,7 +1514,7 @@ func (p *opaqueParser) arithmeticOf(left translated, op string, right translated if op == "/" && p.d == dialectJava { switch { case wholeScalar(left.scalar) && wholeScalar(right.scalar): - return javaQuotient(left, right), nil + return javaQuotient(left, right, p.strict), nil case !realScalar(left.scalar) && !realScalar(right.scalar): side := left if side.scalar != "" { @@ -1520,7 +1530,12 @@ func (p *opaqueParser) arithmeticOf(left translated, op string, right translated // javaQuotient writes Java's `/` over whole numbers x and y: the exact quotient // truncated toward zero, which the extension library's quotient computes and // reports as overflow for the one pair (the least Integer by -1) outside the range. -func javaQuotient(x, y translated) translated { +// A strict migration computes it from the standard remainder, x % y having +// the sign of x: (x - x % y) / y is exact, and its floor is itself. +func javaQuotient(x, y translated, strict bool) translated { + if strict { + return translated{expr: "RealFunctions::floor((" + x.operand() + " - " + x.operand() + " % " + y.operand() + ") / " + y.operand() + ")", scalar: "Integer", atomic: true} + } return translated{expr: "OpenSysMLMathFunctions::quotient(" + x.expr + ", " + y.expr + ")", scalar: "Integer", atomic: true} } @@ -1816,6 +1831,9 @@ func (p *opaqueParser) mathRoundish(fn string, args []translated, arity func(int switch fn { case mathCeilFn: // -floor(-x) would overflow at the least Integer; the extension library's ceiling does not. + if p.strict { + return translated{expr: "-RealFunctions::floor(-" + args[0].operand() + ")", scalar: yields, atomic: true}, nil + } return translated{expr: "OpenSysMLMathFunctions::ceiling(" + args[0].expr + ")", scalar: yields, atomic: true}, nil case mathRound: // JavaScript and Java round a half toward +∞, where RealFunctions::round rounds it away from zero. diff --git a/internal/translate/migrate/portable.go b/internal/translate/migrate/portable.go new file mode 100644 index 0000000000..727d6945a0 --- /dev/null +++ b/internal/translate/migrate/portable.go @@ -0,0 +1,181 @@ +package migrate + +import ( + "bytes" + "sort" + "strings" + + "github.com/Open-MBEE/OpenSysML/internal/syntax/lexer" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +// openSysMLLibraryDir is the directory of the library source holding the +// OpenSysML extension libraries, one library package per file named after it. +const openSysMLLibraryDir = "OpenSysML Libraries/" + +// LibrarySummary accounts for the OpenSysML library packages a portable +// migration appended to its output, and those it could not. +type LibrarySummary struct { + // Inlined names the library packages appended, in the order appended. + Inlined []string `json:"inlined,omitempty"` + // NotInlined explains, by package, why a referenced library package was + // not appended, so the output still refers to it. + NotInlined map[string]string `json:"notInlined,omitempty"` +} + +// libraryText is one OpenSysML library package as the source ships it. +type libraryText struct { + name string + kerml bool + text []byte +} + +// openSysMLLibraries reads the OpenSysML extension libraries from the +// embedded library source, by package name. +func openSysMLLibraries() map[string]libraryText { + src := libs.EmbeddedSource() + out := map[string]libraryText{} + for _, path := range src.List() { + if !strings.HasPrefix(path, openSysMLLibraryDir) { + continue + } + base := strings.TrimPrefix(path, openSysMLLibraryDir) + name, kerml := strings.TrimSuffix(base, ".sysml"), false + if name == base { + name, kerml = strings.TrimSuffix(base, ".kerml"), true + if name == base { + continue + } + } + text, err := src.Read(path) + if err != nil { + continue + } + out[name] = libraryText{name: name, kerml: kerml, text: text} + } + return out +} + +// inlineLibraries appends to notation the OpenSysML library packages it +// refers to, and those they refer to in turn, so the file loads with the +// standard library alone. A KerML library is written in its SysML spelling; +// one that has none is left referenced and accounted for. +func inlineLibraries(notation []byte) ([]byte, *LibrarySummary) { + available := openSysMLLibraries() + summary := &LibrarySummary{} + seen := map[string]bool{} + var appended [][]byte + pending := referencedLibraries(notation, available) + for len(pending) > 0 { + name := pending[0] + pending = pending[1:] + if seen[name] { + continue + } + seen[name] = true + lib := available[name] + text := lib.text + if lib.kerml { + spelled, ok := sysmlSpelling(name, text) + if !ok { + if summary.NotInlined == nil { + summary.NotInlined = map[string]string{} + } + summary.NotInlined[name] = "a KerML library with no SysML spelling" + continue + } + text = spelled + } + text = unmarkedStandard(name, text) + summary.Inlined = append(summary.Inlined, name) + appended = append(appended, text) + pending = append(pending, referencedLibraries(text, available)...) + } + if len(appended) == 0 { + return notation, summary + } + var out bytes.Buffer + out.Write(notation) + if !bytes.HasSuffix(notation, []byte("\n")) { + out.WriteByte('\n') + } + out.WriteString("\n// OpenSysML library packages the model refers to, inlined so the file\n// loads with the standard library alone.\n") + for _, text := range appended { + out.WriteByte('\n') + out.Write(text) + if !bytes.HasSuffix(text, []byte("\n")) { + out.WriteByte('\n') + } + } + return out.Bytes(), summary +} + +// referencedLibraries names the OpenSysML library packages notation refers +// to, each as the first segment of a qualified name, sorted. +func referencedLibraries(notation []byte, available map[string]libraryText) []string { + found := map[string]bool{} + lx := lexer.New(source.New("migration.sysml", notation)) + var prev lexer.Token + for tok := lx.Next(); tok.Kind != lexer.EOF; tok = lx.Next() { + if tok.IsTrivia() { + continue + } + if tok.Kind == lexer.ColonColon && prev.Kind == lexer.Identifier { + if name := string(notation[prev.Span.Offset : prev.Span.Offset+prev.Span.Len]); available[name].text != nil { + found[name] = true + } + } + prev = tok + } + names := make([]string, 0, len(found)) + for name := range found { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// unmarkedStandard is a library package declared `standard library package` +// without the `standard`, which only the standard library's own files may carry. +func unmarkedStandard(name string, text []byte) []byte { + lx := lexer.New(source.New(name+".sysml", text)) + var prev lexer.Token + for tok := lx.Next(); tok.Kind != lexer.EOF; tok = lx.Next() { + if tok.IsTrivia() { + continue + } + if tok.Kind == lexer.Keyword && string(text[tok.Span.Offset:tok.Span.Offset+tok.Span.Len]) == "library" && prev.Kind == lexer.Keyword && string(text[prev.Span.Offset:prev.Span.Offset+prev.Span.Len]) == "standard" { + out := append([]byte(nil), text[:prev.Span.Offset]...) + return append(out, text[tok.Span.Offset:]...) + } + prev = tok + } + return text +} + +// sysmlSpelling writes a KerML library in SysML notation: its functions as +// calc defs. ok is false when a word of it is KerML's alone, as `feature` or +// `featured by` are, so no SysML file can say what it says. +func sysmlSpelling(name string, kerml []byte) (spelled []byte, ok bool) { + var out bytes.Buffer + lx := lexer.New(source.New(name+".kerml", kerml)) + pos := 0 + for tok := lx.Next(); tok.Kind != lexer.EOF; tok = lx.Next() { + if tok.Kind != lexer.Keyword { + continue + } + end := tok.Span.Offset + tok.Span.Len + word := string(kerml[tok.Span.Offset:end]) + switch { + case word == "function": + out.Write(kerml[pos:tok.Span.Offset]) + out.WriteString("calc def") + pos = end + case !source.IsKeywordIn(word, source.KindSysML): + return nil, false + } + } + out.Write(kerml[pos:]) + return out.Bytes(), true +} diff --git a/internal/translate/migrate/portable_test.go b/internal/translate/migrate/portable_test.go new file mode 100644 index 0000000000..612919d35b --- /dev/null +++ b/internal/translate/migrate/portable_test.go @@ -0,0 +1,46 @@ +package migrate + +import ( + "strings" + "testing" +) + +// Inlining follows what an inlined library refers to in turn: MOSA imports +// DocumentQueries, so both are appended. +func TestInlineLibrariesFollowsWhatALibraryRefersTo(t *testing.T) { + out, s := inlineLibraries([]byte("package P { private import MOSA::*; }\n")) + if want := []string{"MOSA", "DocumentQueries"}; strings.Join(s.Inlined, ",") != strings.Join(want, ",") { + t.Errorf("inlined %v, want %v", s.Inlined, want) + } + for _, decl := range []string{"library package MOSA {", "library package DocumentQueries {"} { + if !strings.Contains(string(out), decl) { + t.Errorf("output lacks %q", decl) + } + } + if len(s.NotInlined) > 0 { + t.Errorf("not inlined: %v", s.NotInlined) + } +} + +// A KerML library with no SysML spelling is left referenced and accounted for. +func TestInlineLibrariesLeavesAKerMLLibraryWithNoSysMLSpelling(t *testing.T) { + notation := []byte("part p { ref s : StateActivity::StateAction; }\n") + out, s := inlineLibraries(notation) + if string(out) != string(notation) { + t.Errorf("output changed:\n%s", out) + } + if len(s.Inlined) > 0 || s.NotInlined["StateActivity"] == "" { + t.Errorf("inlined %v, not inlined %v; want StateActivity not inlined", s.Inlined, s.NotInlined) + } + if summary := (&Report{Libraries: s}).Summary(); !strings.Contains(summary, "StateActivity not inlined: a KerML library with no SysML spelling") { + t.Errorf("summary = %s", summary) + } +} + +// A name that is a library's only as a nested member does not call for the library. +func TestInlineLibrariesIgnoresAnUnqualifiedName(t *testing.T) { + out, s := inlineLibraries([]byte("package Stochastic;\npart p;\n")) + if len(s.Inlined) > 0 || strings.Contains(string(out), "library package") { + t.Errorf("inlined %v for a package merely named like a library", s.Inlined) + } +} diff --git a/internal/translate/migrate/report.go b/internal/translate/migrate/report.go index c3969206cf..f90e5343e2 100644 --- a/internal/translate/migrate/report.go +++ b/internal/translate/migrate/report.go @@ -84,6 +84,9 @@ type Report struct { // Images counts the attached image files the migration wrote beside the // notation for its Image blocks. Images int `json:"images,omitempty"` + // Libraries accounts for the OpenSysML library packages a portable + // migration appended to the output; nil when the migration was not portable. + Libraries *LibrarySummary `json:"libraries,omitempty"` } // LayoutSummary accounts for what an MTIP export and the diagrams' own symbol @@ -103,6 +106,9 @@ type LayoutSummary struct { DiagramsUnmatched int `json:"diagramsUnmatched"` // ViewsWithoutLayout counts the written views the export does not cover. ViewsWithoutLayout int `json:"viewsWithoutLayout"` + // GeometryOmitted counts the joined or stream-laid-out views whose + // geometry a strict migration leaves unwritten, DiagramLayout being OpenSysML's. + GeometryOmitted int `json:"geometryOmitted,omitempty"` // Placements counts the shown elements the export positions; // PlacementsWritten those positioned into a view, PlacementsUnexposed // those the view neither exposes nor draws, PlacementsDangling those @@ -184,13 +190,27 @@ func (r *Report) Summary() string { total, c[Mapped], c[Approximated], c[Unmapped], c[Skipped]-unreferenced, unreferenced) if l := r.Layout; l != nil { laidOut := l.DiagramsJoined + l.StreamDiagrams - s += fmt.Sprintf("; laid out %d of %d diagrams from %s: %s elements positioned, %s connectors routed, %s styled, %s notes", - laidOut, laidOut+l.ViewsWithoutLayout, l.Source, - commas(l.PlacementsWritten), commas(l.RoutesWritten), commas(l.StylesWritten), commas(l.Notes)) + if l.GeometryOmitted > 0 { + s += fmt.Sprintf("; matched %d of %d diagrams to %s, whose geometry the strict migration omits", laidOut, laidOut+l.ViewsWithoutLayout, l.Source) + } else { + s += fmt.Sprintf("; laid out %d of %d diagrams from %s: %s elements positioned, %s connectors routed, %s styled, %s notes", + laidOut, laidOut+l.ViewsWithoutLayout, l.Source, + commas(l.PlacementsWritten), commas(l.RoutesWritten), commas(l.StylesWritten), commas(l.Notes)) + } } if r.Images > 0 { s += fmt.Sprintf("; wrote %d image file(s)", r.Images) } + if l := r.Libraries; l != nil { + if len(l.Inlined) == 0 { + s += "; refers to no OpenSysML library, none inlined" + } else { + s += fmt.Sprintf("; inlined %d OpenSysML library package(s): %s", len(l.Inlined), strings.Join(l.Inlined, ", ")) + } + for _, name := range sortedKeys(l.NotInlined) { + s += fmt.Sprintf("; %s not inlined: %s", name, l.NotInlined[name]) + } + } return s } @@ -277,10 +297,17 @@ func (l *LayoutSummary) writeText(b *strings.Builder) { } else { b.WriteString("# ") } - fmt.Fprintf(b, "%d views laid out from their own symbol stream, %d without layout\n", l.StreamDiagrams, l.ViewsWithoutLayout) + streamVerb := "laid out from" + if l.GeometryOmitted > 0 { + streamVerb = "matched to" + } + fmt.Fprintf(b, "%d views %s their own symbol stream, %d without layout\n", l.StreamDiagrams, streamVerb, l.ViewsWithoutLayout) if l.StreamSupplemented > 0 { fmt.Fprintf(b, "# %d joined views supplemented from their own symbol stream where the record placed or routed nothing\n", l.StreamSupplemented) } + if l.GeometryOmitted > 0 { + fmt.Fprintf(b, "# %d views' geometry omitted by the strict migration\n", l.GeometryOmitted) + } fmt.Fprintf(b, "# placements: %d of %d written (%d not exposed, %d resolving to no element); routes: %d of %d written (%d not pinned, %d resolving to no element); malformed: %d\n", l.PlacementsWritten, l.Placements, l.PlacementsUnexposed, l.PlacementsDangling, l.RoutesWritten, l.Routes, l.RoutesUnexposed, l.RoutesDangling, l.Malformed) diff --git a/internal/translate/migrate/results.go b/internal/translate/migrate/results.go index 38909a0bf9..beaa4460c6 100644 --- a/internal/translate/migrate/results.go +++ b/internal/translate/migrate/results.go @@ -206,7 +206,7 @@ func (m *migration) snapshotNotes(r *simresults.ConfigurationResults, scan *snap } // sortedKeys is the keys of counts, sorted. -func sortedKeys(counts map[string]int) []string { +func sortedKeys[V any](counts map[string]V) []string { keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) diff --git a/internal/translate/migrate/simconfig.go b/internal/translate/migrate/simconfig.go index 6313e06a82..ccfd922c07 100644 --- a/internal/translate/migrate/simconfig.go +++ b/internal/translate/migrate/simconfig.go @@ -144,12 +144,20 @@ func (m *migration) simulationConfig(e *sysmlv1.Element, header, note string) { results.Notes = append(results.Notes, lost...) notes = append(notes, lost...) note = joinNotes(note, strings.Join(notes, "; ")) + if m.strict { + note = joinNotes(note, "its run settings are written as a comment: a strict migration names no OpenSysML library, and Simulation::Configuration is one's") + } m.add(e, verdictFor(note), m.v2Name(e), note) m.w.block(header, func() { saved := m.scope m.scope = e m.comments(e) - m.w.block("@Simulation::Configuration", func() { m.w.lines(settings.lines) }) + if m.strict { + m.w.lines(commentLines("simulation run settings, a comment since their v2 form is an OpenSysML library's: " + + strings.ReplaceAll(strings.Join(settings.lines, " "), "Simulation::DrawPolicy::", ""))) + } else { + m.w.block("@Simulation::Configuration", func() { m.w.lines(settings.lines) }) + } if target.element != nil { part := m.freshName(e, "target") results.Target = part diff --git a/internal/translate/migrate/states.go b/internal/translate/migrate/states.go index b2b06658ac..cc9f824a52 100644 --- a/internal/translate/migrate/states.go +++ b/internal/translate/migrate/states.go @@ -33,18 +33,42 @@ func theTransition(t *sysmlv1.Element) string { return "the transition " + descr func deferredBy(v *sysmlv1.Element) string { return "deferred by " + describe(v) } -// pseudostateLine spells the member a pseudostate of kind kw writes as: a -// StateMachines metadata usage for choice/junction/history — qualified so a -// member named like the metadata cannot shadow it — the standard literal for -// fork/join. -func pseudostateLine(prefix, kw, name string) string { +// pseudostateLine spells the member a pseudostate of kind kw writes as in +// scope: a StateMachines metadata usage for choice/junction/history — qualified +// so a member named like the metadata cannot shadow it, importing the library — +// the standard literal for fork/join. A strict migration, naming no OpenSysML +// library, writes a choice or junction as a plain state. +func (m *migration) pseudostateLine(scope *sysmlv1.Element, kw, name string) string { switch kw { case "choice", "junction", "shallowHistory", "deepHistory": - return "#" + prefix + "StateMachines::" + kw + " state " + name + ";" + if m.strict { + return "state " + name + ";" + } + m.useStateMachines() + return "#" + m.stateMachinesPrefix(scope) + "StateMachines::" + kw + " state " + name + ";" } return kw + " " + name + ";" } +// junctionSpelling says how a junction is written, for a report note. +func (m *migration) junctionSpelling() string { + if m.strict { + return "plain `state` standing for a junction, a strict migration naming no OpenSysML library," + } + return "`#StateMachines::junction state`" +} + +// historyWords says what each history pseudostate re-enters, for a report note. +var historyWords = map[string]string{ + "shallowHistory": "shallow history, which re-enters the substate active when its state was last left", + "deepHistory": "deep history, which re-enters the innermost states active when its state was last left", +} + +// noStrictHistory says why a strict migration refuses a history pseudostate. +func noStrictHistory(what string) string { + return what + " has no standard v2 form: a history pseudostate is written only through the OpenSysML StateMachines library, which a strict migration does not name" +} + // stateMachinesPrefix is "$::" when a member written as `StateMachines` hides // the library package from the scope a clause is written in, so the metadata // reference names it from the root. @@ -59,6 +83,9 @@ func (m *migration) stateMachinesPrefix(scope *sysmlv1.Element) string { // innermost package enclosing the current scope, once, for the StateMachines // metadata member being written. func (m *migration) useStateMachines() { + if m.strict { + return + } for e := m.scope; e != nil; e = e.Parent { switch e.Type { case "Package", "Model", "Profile": @@ -353,7 +380,7 @@ func (m *migration) entryPointForm(v, owner *sysmlv1.Element) pointForm { } regions := m.regionsCrossed(out, owner, "target") if len(out) < 2 || len(regions) < 2 { - return pointForm{kw: "junction", note: "written as a `#StateMachines::junction state` of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction"} + return pointForm{kw: "junction", note: "written as a " + m.junctionSpelling() + " of its state; a transition entering through it runs the state's entry behavior, then the transition leaving the junction"} } if len(regions) < len(out) { return pointForm{why: "the entry point starts several regions of " + describe(owner) + ", as a fork does, but two of its outgoing transitions enter the same region"} @@ -416,7 +443,7 @@ func (m *migration) exitPointForm(v, owner *sysmlv1.Element) pointForm { } regions := m.regionsCrossed(in, owner, "source") if len(in) < 2 || len(regions) < 2 { - return pointForm{kw: "junction", note: "written as a `#StateMachines::junction state` of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it"} + return pointForm{kw: "junction", note: "written as a " + m.junctionSpelling() + " of its state; a transition leaving through it runs the transition into the junction, the state's exit behavior, then the transition leaving it"} } joinBut := "several regions of " + describe(owner) + " leave through the exit point, as through a join, but " if len(regions) < len(in) { @@ -1118,28 +1145,29 @@ func (s *stateRegion) vertex(v *sysmlv1.Element) { switch pseudoKind(v) { case "initial": case "choice", "junction": - name := writeName(s.name(v)) - s.m.useStateMachines() - s.m.w.line(pseudostateLine(s.m.stateMachinesPrefix(v.Parent), pseudoKind(v), name)) + name, kind := writeName(s.name(v)), pseudoKind(v) + s.m.w.line(s.m.pseudostateLine(v.Parent, kind, name)) s.m.madeUp(v, name) - s.m.add(v, Mapped, name, "written as a #StateMachines::"+pseudoKind(v)+" state pseudostate, whose guarded transitions the runtime reads when it is reached; an unguarded one is its else branch") + if s.m.strict { + s.m.add(v, Approximated, name, "written as a plain state standing for the "+kind+" pseudostate, since a strict migration names no OpenSysML library and #StateMachines::"+kind+" is one's; its guarded transitions are the pseudostate's, but a runtime no longer passes through it in one step") + } else { + s.m.add(v, Mapped, name, "written as a #StateMachines::"+kind+" state pseudostate, whose guarded transitions the runtime reads when it is reached; an unguarded one is its else branch") + } case "fork", "join": name := writeName(s.name(v)) s.m.w.line(pseudoKind(v) + " " + name + ";") s.m.madeUp(v, name) s.m.add(v, Mapped, name, "written as a "+pseudoKind(v)+" pseudostate, whose segments the runtime fires together") - case "shallowHistory": - name := writeName(s.name(v)) - s.m.useStateMachines() - s.m.w.line(pseudostateLine(s.m.stateMachinesPrefix(v.Parent), "shallowHistory", name)) - s.m.madeUp(v, name) - s.m.add(v, Mapped, name, "written as a `#StateMachines::shallowHistory state` shallow history, which re-enters the substate active when its state was last left") - case "deepHistory": + case "shallowHistory", "deepHistory": + kind := pseudoKind(v) + if s.m.strict { + s.m.unmapped(v, noStrictHistory("the "+kind+" pseudostate")) + return + } name := writeName(s.name(v)) - s.m.useStateMachines() - s.m.w.line(pseudostateLine(s.m.stateMachinesPrefix(v.Parent), "deepHistory", name)) + s.m.w.line(s.m.pseudostateLine(v.Parent, kind, name)) s.m.madeUp(v, name) - s.m.add(v, Mapped, name, "written as a `#StateMachines::deepHistory state` deep history, which re-enters the innermost states active when its state was last left") + s.m.add(v, Mapped, name, "written as a `#StateMachines::"+kind+" state` "+historyWords[kind]) case "terminate": s.m.add(v, Approximated, "done", "a terminate pseudostate ends the machine; a transition to it is written to done, which ends its region") case "entryPoint", "exitPoint": @@ -1188,10 +1216,7 @@ func (m *migration) statePoints(v *sysmlv1.Element) int { m.add(cp, Mapped, m.vertexNames[v], f.note) default: name := writeName(m.vertexNames[cp]) - if f.kw == "junction" { - m.useStateMachines() - } - m.w.line(pseudostateLine(m.stateMachinesPrefix(m.scope), f.kw, name)) + m.w.line(m.pseudostateLine(m.scope, f.kw, name)) m.madeUp(cp, name) m.add(cp, Mapped, name, f.note) written++ @@ -1877,7 +1902,9 @@ func (s *stateRegion) transitionWritten(t *sysmlv1.Element) bool { return named(pointOwner(tgt)) } return named(tgt) - case "choice", "junction", "shallowHistory", "deepHistory", "fork", "join": + case "shallowHistory", "deepHistory": + return !s.m.strict && named(tgt) + case "choice", "junction", "fork", "join": return named(tgt) } case "ConnectionPointReference": @@ -2391,7 +2418,13 @@ func (s *stateRegion) target(t, v *sysmlv1.Element) (string, bool) { return p, ok } return s.endpoint(t, v, "target") - case "choice", "junction", "shallowHistory", "deepHistory": + case "shallowHistory", "deepHistory": + if s.m.strict { + s.m.add(t, Unmapped, "", noStrictHistory("its target "+describe(v))) + return "", false + } + return s.endpoint(t, v, "target") + case "choice", "junction": return s.endpoint(t, v, "target") case "fork", "join": return s.endpoint(t, v, "target") @@ -2418,7 +2451,13 @@ func (s *stateRegion) source(t, v *sysmlv1.Element) (string, bool) { return "", false } return s.endpoint(t, v, "source") - case "choice", "junction", "shallowHistory", "deepHistory": + case "shallowHistory", "deepHistory": + if s.m.strict { + s.m.add(t, Unmapped, "", noStrictHistory("its source "+describe(v))) + return "", false + } + return s.endpoint(t, v, "source") + case "choice", "junction": return s.endpoint(t, v, "source") case "fork", "join": return s.endpoint(t, v, "source") @@ -2970,6 +3009,9 @@ func (s *stateRegion) guard(t, src *sysmlv1.Element) (string, string) { return "", "" } if k := pseudoKind(src); (k == "choice" || k == "junction") && isElseGuard(spec) { + if s.m.strict { + return s.strictElse(g, t, src, k) + } s.m.add(g, Mapped, "", "an else guard is written as the unguarded transition out of the "+k+", which is taken when no guarded one holds") return "", "" } @@ -2983,6 +3025,47 @@ func (s *stateRegion) guard(t, src *sysmlv1.Element) (string, string) { return " if " + expr, note } +// strictElse writes an else guard out of a choice or junction a strict migration +// writes as a plain state, whose transitions compete rather than yield to a +// holding guard: the negation of the other written transitions' guards when +// each is a v2 expression, else unguarded with a note on what that risks. +func (s *stateRegion) strictElse(g, t, src *sysmlv1.Element, kind string) (string, string) { + var others []string + for _, o := range s.m.outgoing[src] { + if o == t || !s.transitionWritten(o) { + continue + } + og := s.m.guardOf(o) + spec := firstOwned(og, "specification") + switch { + case og == nil, spec == nil, trueLiteral(spec): + others = append(others, "true") + continue + case isElseGuard(spec): + continue + } + expr, ok, _ := s.m.behaviorValue(spec, o) + if !ok { + note := "the else guard is written as an unguarded transition, since the guard [" + describeValue(spec) + "] of the transition " + describe(o) + " is not a v2 expression to negate; the " + kind + " is a plain state whose transitions compete, so this branch may be taken while that guard holds" + s.m.add(g, Approximated, "", note) + return "", note + } + others = append(others, expr) + } + if len(others) == 0 { + s.m.add(g, Mapped, "", "an else guard beside no guarded transition out of the "+kind+" is not written") + return "", "" + } + for i, o := range others { + if len(others) > 1 { + others[i] = "(" + o + ")" + } + } + note := "the else guard is written as the negation of the other guards out of the " + kind + ", a plain state whose transitions compete rather than fall back to the else branch" + s.m.add(g, Approximated, "", note) + return " if not (" + strings.Join(others, " or ") + ")", note +} + // isElseGuard reports whether a guard's specification is the word else, which // a v1 tool writes on the branch a choice takes when no other guard holds. func isElseGuard(spec *sysmlv1.Element) bool { diff --git a/internal/translate/migrate/tables.go b/internal/translate/migrate/tables.go index 9b646d3d65..1335bfc387 100644 --- a/internal/translate/migrate/tables.go +++ b/internal/translate/migrate/tables.go @@ -201,6 +201,10 @@ func (m *migration) lowerTable(td *tableDoc) { t, host := td.t, td.v.host l := &lowered{} td.l = l + if m.strict { + l.refuse("its v2 form is a document of OpenSysML's DocumentQueries library, which a strict migration does not name") + return + } if len(t.Malformed) > 0 { l.refuse(strings.Join(t.Malformed, "; ")) } diff --git a/internal/translate/migrate/translate.go b/internal/translate/migrate/translate.go index e6db49bece..2c5a61f220 100644 --- a/internal/translate/migrate/translate.go +++ b/internal/translate/migrate/translate.go @@ -89,6 +89,8 @@ func (m *migration) lanesAround(e *sysmlv1.Element) (*lanes, *sysmlv1.Element) { // first, then what the scope sees, each further step a feature of the last. // The result is plural once any object the name reads through is, and a // write through a collection is refused: it would reach several objects. +func (s *bodyScope) strictMigration() bool { return s.m.strict } + func (s *bodyScope) feature(path []string, write bool) (opaqueRef, *refusal) { m := s.m full := strings.Join(path, ".") @@ -325,6 +327,8 @@ type nameReads struct { names []string } +func (r *nameReads) strictMigration() bool { return r.s.strictMigration() } + func (r *nameReads) feature(path []string, write bool) (opaqueRef, *refusal) { if r.s.m.pinCalled(r.s.scope, path[0]) != nil { return opaqueRef{expr: strings.Join(path, ".")}, nil diff --git a/packaging/man/man1/sysml.1 b/packaging/man/man1/sysml.1 index 53fb8c3625..3166bc9d2e 100644 --- a/packaging/man/man1/sysml.1 +++ b/packaging/man/man1/sysml.1 @@ -39,7 +39,8 @@ Report the model's diagnostics and exit, nonzero on an error; .TP .B \-strict Judge the model as conforming SysML v2: notation no pinned production admits -is an error, not a warning; a SysML v1 migration writes none of it +is an error, not a warning; a SysML v1 migration writes none of it and refers +to no OpenSysML library .TP .BR \-disable\-lint " \fIcode\fP" Leave this lint out of the model's diagnostics: undeclared\-signal, @@ -281,6 +282,11 @@ geometry it records as DiagramLayout annotations in the migrated views With \-migrate, the http(s) URL a comment's relative is resolved against, such as the View Editor server .TP +.B \-portable +With \-migrate, append the OpenSysML library packages the output refers to, so +the one file loads in a tool that ships only the standard library; the report +names them +.TP .BR \-compare\-results " \fIfile\fP" Run every configuration this \-migration\-results file indexes \(em or those \-action names \(em with its recorded runs and duration mode, or the \-runs diff --git a/tests/migrate/behavior_more_test.go b/tests/migrate/behavior_more_test.go index b28907cbdd..581a9828a6 100644 --- a/tests/migrate/behavior_more_test.go +++ b/tests/migrate/behavior_more_test.go @@ -174,11 +174,14 @@ func TestStateMachineWithOrthogonalRegionsAndGuards(t *testing.T) { wantNote(t, r, "_dNoon", migrate.Unmapped, "only a signal event can be deferred, not a TimeEvent") // Strict output carries the deferral in the same standard notation; the - // choice pseudostate is written as metadata in both modes. + // choice pseudostate is written as a plain state, StateMachines being + // OpenSysML's library. strict := migrateDocumentOptions(t, ovenMachine, ovenApplications, migrate.Options{Strict: true}) wantDeferredDoorEncoding(t, strict) wantNote(t, strict, "_dNoon", migrate.Unmapped, "only a signal event can be deferred, not a TimeEvent") - wantNote(t, strict, "_pick", migrate.Mapped, "written as a #StateMachines::choice state pseudostate") + wantNote(t, strict, "_pick", migrate.Approximated, "written as a plain state standing for the choice pseudostate") + wantLine(t, strict.Notation, "state choice;") + wantNoLine(t, strict.Notation, "StateMachines::choice") wantNoLine(t, strict.Notation, "choice choice;") noExtensionStatement(t, strict.Notation) wantNote(t, strict, "_gWorn", migrate.Mapped, "") @@ -259,14 +262,14 @@ func wantDeferredDoorEncoding(t *testing.T, r *migrate.Result) { "#MigrationMetadata::DeferredKeeper action receive accept kept : Door;", "then action keep { assign deferred := SequenceFunctions::including(deferred, receive.kept); }", "then receive;", - "metadata MigrationMetadata::SynthesizedName about receive, keep;", "exit action flush {", "for kept in deferred { send kept to self; }", "then action clear { assign deferred := (); }", - "metadata MigrationMetadata::SynthesizedName about deferred, buffer, flush;", } { wantLine(t, r.Notation, line) } + wantMadeUp(t, r.Notation, "receive, keep") + wantMadeUp(t, r.Notation, "deferred, buffer, flush") wantNote(t, r, "_dDoor", migrate.Approximated, "kept in the item deferred by the accept loop of the do action buffer while the state is active, and sent to self by the exit action flush: the standard SysML v2 encoding of a deferred signal, which the state's @MigrationMetadata::DeferredEvent annotation records") wantNote(t, r, "_doorEv", migrate.Approximated, "deferred by 'Off' through the standard SysML v2 encoding, an accept loop keeping the signal while the state is active and an exit action sending it to self") } @@ -363,8 +366,8 @@ func TestStrictDeferredSignalsAreKeptAndReplayed(t *testing.T) { checkDeferredSignalsAreKeptAndReplayed(t, migrateDocumentOptions(t, deferringMachine, deferringApplications, mode.opts)) }) } - if d, s := migrateDocument(t, deferringMachine, deferringApplications), migrateDocumentOptions(t, deferringMachine, deferringApplications, migrate.Options{Strict: true}); string(d.Notation) != string(s.Notation) { - t.Errorf("default and strict migrations of the deferring machine differ:\n--- default\n%s\n--- strict\n%s", d.Notation, s.Notation) + if d, s := migrateDocument(t, deferringMachine, deferringApplications), migrateDocumentOptions(t, deferringMachine, deferringApplications, migrate.Options{Strict: true}); withoutNameMarkers(d.Notation) != withoutNameMarkers(s.Notation) { + t.Errorf("default and strict migrations of the deferring machine differ past the made-up name markers:\n--- default\n%s\n--- strict\n%s", d.Notation, s.Notation) } } @@ -397,7 +400,6 @@ func checkDeferredSignalsAreKeptAndReplayed(t *testing.T, r *migrate.Result) { "#MigrationMetadata::DeferredKeeper action receiveBeep accept keptBeep : Beep;", "then action keepBeep { assign deferredBeep := SequenceFunctions::including(deferredBeep, receiveBeep.keptBeep); }", "then receiveBeep;", - "metadata MigrationMetadata::SynthesizedName about split, run, receiveAlarm, keepAlarm, receiveBeep, keepBeep;", "exit action flush {", "action leave {", "assign context.exits := context.exits + 1;", @@ -405,14 +407,15 @@ func checkDeferredSignalsAreKeptAndReplayed(t *testing.T, r *migrate.Result) { "then action clearAlarm { assign deferredAlarm := (); }", "then for keptBeep in deferredBeep { send keptBeep to self; }", "then action clearBeep { assign deferredBeep := (); }", - "metadata MigrationMetadata::SynthesizedName about clearAlarm, clearBeep;", - "metadata MigrationMetadata::SynthesizedName about deferredAlarm, deferredBeep, buffer, flush;", "transition first Waiting accept Go then Working;", "transition first Busy accept Alarm then Alarmed;", "transition first Working accept Beep then Done;", } { wantLine(t, r.Notation, line) } + wantMadeUp(t, r.Notation, "split, run, receiveAlarm, keepAlarm, receiveBeep, keepBeep") + wantMadeUp(t, r.Notation, "clearAlarm, clearBeep") + wantMadeUp(t, r.Notation, "deferredAlarm, deferredBeep, buffer, flush") wantNote(t, r, "_dGo", migrate.Approximated, "the transition (_tGo) out of the state accepts the signal, which in v1 takes precedence over deferring it, so the state does not keep it; its @MigrationMetadata::DeferredEvent annotation records the deferral") wantNoLine(t, r.Notation, "item deferredGo : Go[*] ordered;") wantNote(t, r, "_goDeferredEv", migrate.Approximated, "deferred by 'Waiting', which the state's @MigrationMetadata::DeferredEvent annotation records; the state does not keep the signal, the transition (_tGo) accepting it") @@ -1047,7 +1050,7 @@ func TestDeferralYieldsToChoiceTransitionOnlyWhereWritten(t *testing.T) { wantNoLine(t, strict.Notation, "choice pick;") wantNoLine(t, strict.Notation, "item deferred : Door[*] ordered;") for _, line := range []string{ - "#StateMachines::choice state pick;", + "state pick;", "transition first Off accept Door then pick;", "transition first pick then Idle;", "@MigrationMetadata::DeferredEvent { ref :>> signal : Door; }", @@ -2437,3 +2440,26 @@ func TestDeferralCountsTransitionsToUnnamedTargets(t *testing.T) { t.Errorf("Idle did not complete into the submachine state:\n%s", out) } } + +// withoutNameMarkers is the notation less the markers of made-up names, which +// a default migration writes as MigrationMetadata and a strict one as comments. +func withoutNameMarkers(notation []byte) string { + var kept []string + for _, line := range strings.Split(string(notation), "\n") { + if strings.Contains(line, "SynthesizedName about") || strings.Contains(line, "// names the migration made up") { + continue + } + kept = append(kept, line) + } + return strings.Join(kept, "\n") +} + +// wantMadeUp checks the marker of the made-up names, which a default migration +// writes as MigrationMetadata and a strict one as a comment. +func wantMadeUp(t *testing.T, notation []byte, names string) { + t.Helper() + got := string(notation) + if !strings.Contains(got, "metadata MigrationMetadata::SynthesizedName about "+names+";") && !strings.Contains(got, "// names the migration made up: "+names) { + t.Errorf("notation marks no made-up names %q:\n%s", names, got) + } +} diff --git a/tests/migrate/behavior_test.go b/tests/migrate/behavior_test.go index 750aa1b1cf..c86791fede 100644 --- a/tests/migrate/behavior_test.go +++ b/tests/migrate/behavior_test.go @@ -458,8 +458,8 @@ func TestPropertyBackedProbabilitiesAreReferences(t *testing.T) { func TestStrictPropertyProbabilityUsesOnlyLiteralDefaults(t *testing.T) { r := migrateFixtureFileOptions(t, "decision_property_probability", migrate.Options{Strict: true}) for _, line := range []string{ - "first 'with default' then defaulted { @Stochastic::Probability { p = 0.25; } }", - "first 'with default' then remainder { @Stochastic::Probability { p = 0.75; } }", + "first 'with default' then defaulted; // probability 0.25", + "first 'with default' then remainder; // probability 0.75", } { wantLine(t, r.Notation, line) } @@ -1018,3 +1018,10 @@ func TestNestedDefProbabilityReadsThroughContext(t *testing.T) { run(t, r) }) } + +func TestStrictProbabilityNoteReachesAnEdgeWhoseGuardIsNotMigrated(t *testing.T) { + r := migrateDocumentOptions(t, missionActivity, missionApplications, migrate.Options{Strict: true}) + wantNote(t, r, "_e8", migrate.Approximated, "its probability 0.25 is written as a comment") + wantNote(t, r, "_e9", migrate.Approximated, "its probability 0.75 is written as a comment") + wantNoLine(t, r.Notation, "Stochastic::Probability") +} diff --git a/tests/migrate/layout_test.go b/tests/migrate/layout_test.go index d5c97ab2d4..40d69f56c3 100644 --- a/tests/migrate/layout_test.go +++ b/tests/migrate/layout_test.go @@ -115,6 +115,24 @@ func TestGoldenLayout(t *testing.T) { for _, d := range errorsMode(t, "layout.sysml", strict.Notation, diag.ConformanceStrict) { t.Errorf("%v", d) } + // The strict migration joins the same records to the same views; only + // their geometry is left unwritten, which the summary says instead of + // reporting the views as missing. + l, sl := r.Report.Layout, strict.Report.Layout + if sl.DiagramsJoined != l.DiagramsJoined || sl.DiagramsUnmatched != l.DiagramsUnmatched || sl.ViewsWithoutLayout != l.ViewsWithoutLayout || sl.GeometryOmitted != l.DiagramsJoined || sl.PlacementsWritten != 0 { + t.Errorf("strict layout summary: %+v\nwant joined %d, unmatched %d, without %d, all geometry omitted", sl, l.DiagramsJoined, l.DiagramsUnmatched, l.ViewsWithoutLayout) + } + if l.GeometryOmitted != 0 { + t.Errorf("default layout summary omits geometry: %+v", l) + } + for _, e := range strict.Report.Entries { + if e.Kind == "Layout" && strings.Contains(e.Note, "does not write as a view") { + t.Errorf("strict migration reports a joined view missing: %+v", e) + } + } + if !strings.Contains(strict.Report.Summary(), "geometry the strict migration omits") { + t.Errorf("strict summary = %q", strict.Report.Summary()) + } } func TestGoldenLayoutExposedMetadataAttribute(t *testing.T) { diff --git a/tests/migrate/migrate_test.go b/tests/migrate/migrate_test.go index 94d039855e..ef2a65a406 100644 --- a/tests/migrate/migrate_test.go +++ b/tests/migrate/migrate_test.go @@ -438,6 +438,21 @@ func TestGoldenConstructFixtures(t *testing.T) { // A strict migration writes only notation a pinned SysML v2 production admits: // every fixture's strict output analyses with zero error diagnostics in strict // conformance mode, where extension notation is an error. +// A strict migration refuses a DocGen document before planning its content, +// so the report holds an Unmapped row for the document and each view and +// paragraph in it, and no Mapped row for content no Document holds. +func TestStrictMigrationRefusesDocumentsBeforePlanningThem(t *testing.T) { + r := migrateFixtureFileOptions(t, "montecarlo_docgen_scoped", migrate.Options{Strict: true}) + wantLine(t, r.Notation, "/* not migrated: «Document» 'Statistics Report' — its v2 form is a Document of OpenSysML's DocumentQueries library, which a strict migration does not name */") + if strings.Contains(string(r.Notation), "DocumentQueries::") { + t.Errorf("strict notation names DocumentQueries:\n%s", r.Notation) + } + wantNote(t, r, "_s8", migrate.Unmapped, "its v2 form is a Document of OpenSysML's DocumentQueries library") + for _, id := range []string{"_s4", "_s5"} { + wantNote(t, r, id, migrate.Unmapped, "it belongs to «Document» 'Statistics Report', which is not migrated") + } +} + func TestStrictMigrationAnalysesCleanUnderStrictConformance(t *testing.T) { for _, name := range append([]string{"vehicle"}, constructFixtures...) { t.Run(name, func(t *testing.T) { diff --git a/tests/migrate/portable_test.go b/tests/migrate/portable_test.go new file mode 100644 index 0000000000..39fd6acdd1 --- /dev/null +++ b/tests/migrate/portable_test.go @@ -0,0 +1,83 @@ +package migrate_test + +import ( + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/translate/migrate" +) + +var standardDeclaration = regexp.MustCompile(`(?m)^standard library package`) + +const inlinedLibrariesMark = "\n// OpenSysML library packages the model refers to" + +// A portable migration appends every OpenSysML library package its notation +// refers to, as a library package a user file may declare, and accounts for +// them; the default migration appends none and accounts for none. +func TestPortableMigrationInlinesTheLibrariesItRefersTo(t *testing.T) { + fixtures, err := filepath.Glob(filepath.Join("testdata", "xmi", "*.xmi")) + if err != nil { + t.Fatal(err) + } + if len(fixtures) == 0 { + t.Fatal("no fixture found") + } + libs := openSysMLLibraries(t) + for _, f := range fixtures { + name := strings.TrimSuffix(filepath.Base(f), ".xmi") + t.Run(name, func(t *testing.T) { + r := migrateFixtureFileOptions(t, name, migrate.Options{Portable: true}) + model, appended, _ := strings.Cut(string(r.Notation), inlinedLibrariesMark) + if r.Report.Libraries == nil { + t.Fatal("report accounts for no libraries") + } + got := r.Report.Libraries.Inlined + var want []string + for _, lib := range libs { + if strings.Contains(model, lib+"::") { + want = append(want, lib) + } + } + for _, lib := range want { + if !slices.Contains(got, lib) { + t.Errorf("%s referred to but not accounted for in %v", lib, got) + } + if !strings.Contains(appended, "library package "+lib+" {") { + t.Errorf("%s referred to but not inlined", lib) + } + } + if len(want) == 0 && (appended != "" || len(got) > 0) { + t.Errorf("nothing referred to, yet inlined %v", got) + } + if standardDeclaration.MatchString(appended) { + t.Error("an inlined package is marked standard") + } + if summary := r.Report.Summary(); len(want) > 0 && !strings.Contains(summary, "inlined "+itoa(len(got))+" OpenSysML library package(s): ") { + t.Errorf("summary does not account for the libraries: %s", summary) + } + wantClean(t, name+".sysml", r) + }) + } + if r := migrateFixtureFile(t, "vehicle"); r.Report.Libraries != nil || strings.Contains(string(r.Notation), inlinedLibrariesMark) { + t.Error("a default migration inlined libraries") + } +} + +func itoa(n int) string { return strconv.Itoa(n) } + +// A KerML library is inlined in its SysML spelling, its functions as calc defs. +func TestPortableMigrationSpellsAKerMLLibraryInSysML(t *testing.T) { + r := migrateDocumentOptions(t, missionActivity, missionApplications, migrate.Options{Portable: true}) + wantLine(t, r.Notation, "action wait2 accept after RandomFunctions::uniform(1.0, 8.0) [SI::s];") + wantLine(t, r.Notation, "library package RandomFunctions {") + wantLine(t, r.Notation, "calc def uniform {") + wantNoLine(t, r.Notation, "function uniform {") + if got := r.Report.Libraries.Inlined; !slices.Contains(got, "RandomFunctions") { + t.Errorf("inlined %v, want RandomFunctions among them", got) + } + wantClean(t, "t.sysml", r) +} diff --git a/tests/migrate/states_test.go b/tests/migrate/states_test.go index 00269b9041..70b351da45 100644 --- a/tests/migrate/states_test.go +++ b/tests/migrate/states_test.go @@ -2,6 +2,8 @@ package migrate_test import ( "fmt" + "os" + "path/filepath" "strings" "testing" @@ -1627,50 +1629,52 @@ func noExtensionStatement(t *testing.T, notation []byte) { func TestStrictMigrationWritesNoExtensionNotation(t *testing.T) { r := migrateFixtureFileOptions(t, "plant_states", migrate.Options{Strict: true}) noExtensionStatement(t, r.Notation) + noOpenSysMLLibrary(t, r.Notation) for _, line := range []string{ - "private import StateMachines::*;", - "#StateMachines::junction state route;", - "#StateMachines::shallowHistory state last;", - "#StateMachines::deepHistory state deepest;", + "state route;", "fork spread;", "join gather;", + "transition first route if context.count < 2 then Work;", + "transition first route if not (context.count < 2) then done;", } { wantLine(t, r.Notation, line) } - wantNote(t, r, "_junc", migrate.Mapped, "written as a #StateMachines::junction state pseudostate") - wantNote(t, r, "_hist", migrate.Mapped, "written as a `#StateMachines::shallowHistory state` shallow history") - wantNote(t, r, "_deep", migrate.Mapped, "written as a `#StateMachines::deepHistory state` deep history") - for _, id := range []string{"_tRoute", "_tBusy", "_tSpent", "_tResume", "_tHist"} { + wantNote(t, r, "_junc", migrate.Approximated, "written as a plain state standing for the junction pseudostate") + wantNote(t, r, "_hist", migrate.Unmapped, "the shallowHistory pseudostate has no standard v2 form") + wantNote(t, r, "_deep", migrate.Unmapped, "the deepHistory pseudostate has no standard v2 form") + for _, id := range []string{"_tRoute", "_tBusy"} { wantNote(t, r, id, migrate.Mapped, "") } - wantNote(t, r, "_gSpent", migrate.Mapped, "an else guard is written as the unguarded transition out of the junction") - wantNote(t, r, "_resumeEv", migrate.Mapped, "written where a trigger refers to it, as accept Resume") + wantNote(t, r, "_tResume", migrate.Unmapped, "its target 'last' has no standard v2 form") + wantNote(t, r, "_tHist", migrate.Unmapped, "its source 'last' has no standard v2 form") + wantNote(t, r, "_tSpent", migrate.Approximated, "the else guard is written as the negation of the other guards out of the junction") + wantNote(t, r, "_gSpent", migrate.Approximated, "the else guard is written as the negation of the other guards out of the junction, a plain state whose transitions compete rather than fall back to the else branch") wantNote(t, r, "_fork", migrate.Mapped, "written as a fork pseudostate") wantNote(t, r, "_join", migrate.Mapped, "written as a join pseudostate") r = migrateFixtureFileOptions(t, "station_points", migrate.Options{Strict: true}) noExtensionStatement(t, r.Notation) + noOpenSysMLLibrary(t, r.Notation) for _, line := range []string{ - "private import StateMachines::*;", - "#StateMachines::junction state Start;", - "#StateMachines::junction state Leave;", - "#StateMachines::junction state Deep;", - "#StateMachines::junction state Out;", - "#StateMachines::shallowHistory state H;", + "state Start;", + "state Leave;", + "state Deep;", + "state Out;", } { wantLine(t, r.Notation, line) } - wantNote(t, r, "_start", migrate.Mapped, "written as a `#StateMachines::junction state` of its state; a transition entering through it runs the state's entry behavior") - wantNote(t, r, "_leave", migrate.Mapped, "written as a `#StateMachines::junction state` of its state; a transition leaving through it") - wantNote(t, r, "_deep", migrate.Mapped, "written as a `#StateMachines::junction state` of its state") - wantNote(t, r, "_out", migrate.Mapped, "written as a `#StateMachines::junction state` of its state") - wantNote(t, r, "_hist", migrate.Mapped, "written as a `#StateMachines::shallowHistory state` shallow history") + wantNote(t, r, "_start", migrate.Mapped, "written as a plain `state` standing for a junction, a strict migration naming no OpenSysML library, of its state; a transition entering through it runs the state's entry behavior") + wantNote(t, r, "_leave", migrate.Mapped, "written as a plain `state` standing for a junction, a strict migration naming no OpenSysML library, of its state; a transition leaving through it") + wantNote(t, r, "_deep", migrate.Mapped, "written as a plain `state` standing for a junction") + wantNote(t, r, "_out", migrate.Mapped, "written as a plain `state` standing for a junction") + wantNote(t, r, "_hist", migrate.Unmapped, "the shallowHistory pseudostate has no standard v2 form") wantNote(t, r, "_both", migrate.Mapped, "written as a fork of its state") wantNote(t, r, "_gather", migrate.Mapped, "written as a join of its state") wantNote(t, r, "_plain", migrate.Mapped, "no transition leaves the entry point") - for _, id := range []string{"_tGo", "_tDive", "_tDeep", "_tBack", "_tFinish", "_tLeave", "_tOut", "_tResume", "_tStart"} { + for _, id := range []string{"_tGo", "_tDive", "_tDeep", "_tBack", "_tFinish", "_tLeave", "_tOut", "_tStart"} { wantNote(t, r, id, migrate.Mapped, "") } + wantNote(t, r, "_tResume", migrate.Unmapped, "its target 'H' has no standard v2 form") // The pass-through route through 'Through' is still refused: it is not the // metadata spelling's doing but an unwritable route shape. wantNote(t, r, "_through", migrate.Unmapped, "leads from the entry point straight to the exit point") @@ -1724,3 +1728,160 @@ func TestStateMachinesPackageShadowsTheLibrary(t *testing.T) { wantLine(t, r.Notation, "private import $::StateMachines::*;") wantLine(t, r.Notation, "#$::StateMachines::choice state pick;") } + +// openSysMLLibraries names the library packages OpenSysML ships beyond the +// standard's, which a strict migration must not refer to. +func openSysMLLibraries(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join("..", "..", "internal", "workspace", "libs", "stdlib", "OpenSysML Libraries")) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + if name := strings.TrimSuffix(strings.TrimSuffix(e.Name(), ".sysml"), ".kerml"); name != e.Name() { + names = append(names, name) + } + } + if len(names) == 0 { + t.Fatal("no OpenSysML library found") + } + return names +} + +// noOpenSysMLLibrary fails on a line of notation referring to an OpenSysML +// library, past the deferral markers a strict migration keeps: they annotate +// standard notation the runtime reads them to schedule. +func noOpenSysMLLibrary(t *testing.T, notation []byte) { + t.Helper() + libs := openSysMLLibraries(t) + for _, line := range strings.Split(string(notation), "\n") { + if strings.Contains(line, "MigrationMetadata::DeferredEvent") || strings.Contains(line, "MigrationMetadata::DeferredKeeper") { + continue + } + for _, lib := range libs { + if strings.Contains(line, lib+"::") || strings.Contains(line, "import "+lib+";") { + t.Errorf("strict notation refers to the OpenSysML library %s:\n%s", lib, line) + } + } + } +} + +// TestStrictMigrationRefersToNoOpenSysMLLibrary migrates every fixture under +// strict and checks the notation names only the standard library, so a tool +// without OpenSysML's libraries loads it. +func TestStrictMigrationRefersToNoOpenSysMLLibrary(t *testing.T) { + fixtures, err := filepath.Glob(filepath.Join("testdata", "xmi", "*.xmi")) + if err != nil { + t.Fatal(err) + } + if len(fixtures) == 0 { + t.Fatal("no fixture found") + } + for _, f := range fixtures { + name := strings.TrimSuffix(filepath.Base(f), ".xmi") + t.Run(name, func(t *testing.T) { + r := migrateFixtureFileOptions(t, name, migrate.Options{Strict: true}) + noOpenSysMLLibrary(t, r.Notation) + }) + } +} + +// A choice whose other guard is not a v2 expression leaves a strict +// migration nothing to negate: the else branch is written unguarded and the +// report says it may be taken while that guard holds. +const opaqueChoiceMachine = ` + + + + + + + + + + + + + + + Focus lost + + + + + else + + + + + ` + +// historyChoiceMachine is a choice whose guarded branch targets a shallow +// history pseudostate, which a strict migration refuses, beside an else branch. +const historyChoiceMachine = ` + + + + + + + + + + + + + + + + + + + + n < 2 + + + + + else + + + + + ` + +// A strict else guard negates only the guards of branches the strict migration +// writes: one into a history pseudostate is refused, so it does not block the fallback. +func TestStrictChoiceElseIgnoresRefusedBranches(t *testing.T) { + r := migrateDocumentOptions(t, historyChoiceMachine, "", migrate.Options{Strict: true}) + noOpenSysMLLibrary(t, r.Notation) + wantLine(t, r.Notation, "state pick;") + wantLine(t, r.Notation, "transition first pick then B;") + wantNoLine(t, r.Notation, "if not") + if es := entriesFor(r, "_htA"); len(es) != 1 || es[0].Verdict != migrate.Unmapped { + t.Errorf("_htA entries = %+v, want unmapped", es) + } + wantNote(t, r, "_hgB", migrate.Mapped, "an else guard beside no guarded transition out of the choice is not written") + + r = migrateDocument(t, historyChoiceMachine, "") + wantLine(t, r.Notation, "#StateMachines::shallowHistory state back;") + wantNote(t, r, "_hgB", migrate.Mapped, "an else guard is written as the unguarded transition out of the choice") +} + +func TestStrictChoiceElseBesideAnUnmigratedGuardIsReported(t *testing.T) { + r := migrateDocumentOptions(t, opaqueChoiceMachine, "", migrate.Options{Strict: true}) + noOpenSysMLLibrary(t, r.Notation) + for _, line := range []string{ + "state pick;", + "/* guard not migrated: [Focus lost] — not v2 expression syntax */", + "transition first pick then A;", + "transition first pick then B;", + } { + wantLine(t, r.Notation, line) + } + wantNote(t, r, "_ogB", migrate.Approximated, "the else guard is written as an unguarded transition, since the guard [Focus lost] of the transition (_otA) is not a v2 expression to negate; the choice is a plain state whose transitions compete, so this branch may be taken while that guard holds") + + r = migrateDocument(t, opaqueChoiceMachine, "") + wantNote(t, r, "_ogB", migrate.Mapped, "an else guard is written as the unguarded transition out of the choice") +} diff --git a/tests/migrate/streams_test.go b/tests/migrate/streams_test.go index ef2c9c4a22..9480777186 100644 --- a/tests/migrate/streams_test.go +++ b/tests/migrate/streams_test.go @@ -362,6 +362,30 @@ func TestExportPrecedesStream(t *testing.T) { "_diag_modes", "laid out from modes.layout.xml supplemented by the diagram's own symbol stream: 2 of 3 shown elements positioned") } +// A strict migration joins the export's record to its view but writes no +// geometry, so the report counts the omission, not a supplementation. +func TestStrictExportIsJoinedButNotSupplemented(t *testing.T) { + layout, err := mtip.Parse([]byte(modesExport)) + if err != nil { + t.Fatalf("mtip.Parse: %v", err) + } + r, err := migrate.MigrateOptions("figures.mdzip", mdzip(t, figureStreams), + migrate.Options{Layout: layout, LayoutSource: "modes.layout.xml", Strict: true}) + if err != nil { + t.Fatalf("MigrateOptions: %v", err) + } + wantNoLine(t, r.Notation, "DiagramLayout") + s := r.Report.Layout + if s == nil || s.DiagramsJoined != 1 || s.StreamDiagrams != 6 || s.StreamSupplemented != 0 || s.GeometryOmitted != 7 { + t.Fatalf("layout summary = %+v; want 1 joined, 6 from streams, none supplemented, 7 omitted", s) + } + report := reportText(t, r) + if strings.Contains(report, "supplemented") { + t.Errorf("the strict report claims a supplementation it did not write:\n%s", report) + } + wantInOrder(t, "omission", report, "_diag_modes", "layout from modes.layout.xml omitted") +} + // An MTIP export placing and routing everything the Pump Modes stream draws // leaves the stream nothing to supplement, yet its frame still sizes the canvas. func TestFrameSurvivesCompleteExport(t *testing.T) {