From a436e4ba85d40b4899d09a448fb1411f39747be3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:36:13 +0000 Subject: [PATCH 01/53] feat(repl): analyze each loaded file as a workspace document of its own Files loaded from the command line or by %load were joined into the transcript document, so a root-level import in one file served the others and two files declaring one root package were reported as duplicates. Each loaded file is now a workspace document under its own name, indexed with the others and analyzed on its own, as the editor and the corpus gates analyze it; the typed transcript stays one joined document. A differential test runs every multi-file directory of the fixtures and the OMG corpora through the command line and a workspace and asserts the same diagnostics. Co-Authored-By: jason.han --- README.md | 2 +- .../unreleased/per-file-documents.changed.md | 1 + cmd/sysml/check.go | 4 +- docs/guide/04-repl.md | 27 +- docs/project/spec-compliance.md | 2 +- docs/reference/cli.md | 11 + internal/repl/analysis.go | 5 +- internal/repl/filedocs_test.go | 222 ++++++++++++++++ internal/repl/meta.go | 40 ++- internal/repl/print.go | 2 +- internal/repl/render.go | 36 ++- internal/repl/run.go | 5 +- internal/repl/session.go | 237 +++++++++++------- internal/repl/sweep.go | 5 +- internal/repl/view.go | 18 +- 15 files changed, 459 insertions(+), 158 deletions(-) create mode 100644 changes/unreleased/per-file-documents.changed.md create mode 100644 internal/repl/filedocs_test.go diff --git a/README.md b/README.md index 6510b47f17..3f91e4245a 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,369 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 917 conformance cases, 235 golden traces, 459 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,373 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 917 conformance cases, 235 golden traces, 459 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (917/917 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/changes/unreleased/per-file-documents.changed.md b/changes/unreleased/per-file-documents.changed.md new file mode 100644 index 0000000000..e2861aa57e --- /dev/null +++ b/changes/unreleased/per-file-documents.changed.md @@ -0,0 +1 @@ +- **Every file the command line or `%load` reads is a document of its own.** `sysml -validate`, `-satisfy`, `-e` and the REPL's `%load` used to join the files they were given into one buffer with the typed transcript, so a model split over files was analysed as if it were one file; each file is now a workspace document, indexed with the others and analysed on its own, exactly as the editor and the OMG corpus gates analyse it. Two things a reader will observe: a root-level import in one file (`private import ScalarValues::*;`) no longer serves the other files on the command line or the prompt after `%load` — a KerML root import surfaces its names in its own document's root namespace only, as `docs/project/spec-compliance.md` records — and two files that both declare `package A` are no longer reported as `Duplicate of other owned member name`: they are two root namespaces of one name, and a reference to `A` resolves to the first declaration, as the pilot implementation resolves it. Root packages stay reachable from every file and from the prompt through the global namespace. A differential test runs every multi-file directory of the fixtures and of the four OMG corpora through the command line and through a workspace and asserts the same diagnostics. diff --git a/cmd/sysml/check.go b/cmd/sysml/check.go index 00853c6074..b206b5d708 100644 --- a/cmd/sysml/check.go +++ b/cmd/sysml/check.go @@ -405,8 +405,8 @@ func runChecks(files []string, exprs []string, c checks) int { return rep.finish() } - // The files are loaded as one submission, indexed and analyzed once, and - // each is still summarized on its own. + // The files are loaded as one submission, each a document of its own indexed + // with the others, and each is summarized on its own. loaded, err := sess.LoadFilesSummary(paths) if err != nil { rep.failed(err.Error()) diff --git a/docs/guide/04-repl.md b/docs/guide/04-repl.md index 9f72eef2a2..22c609434c 100644 --- a/docs/guide/04-repl.md +++ b/docs/guide/04-repl.md @@ -123,20 +123,23 @@ session, so the next submission is parsed against the model as it stood before t non-interactive use, a load's diagnostics are errors, so a script that loads a malformed file fails rather than continuing against an empty session. -Two loaded files that both open `package P` declare two packages of that name, and the load -reports this: +Each loaded file is a document of its own, analysed as the editor and the checker analyse it, +while everything typed at the prompt forms one transcript document. Two consequences follow. + +A root-level import serves the file it is written in and no other: after `%load a.sysml`, a +`private import ScalarValues::*;` at the top of `a.sysml` does not make `Real` resolvable in +another loaded file or at the prompt. Write the import where it is used — in each file, or at +the prompt. The packages a file declares stay reachable from every other file and from the +prompt, since root packages share the global namespace. + +Two loaded files that both open `package P` declare two root packages of that name. That is not +a duplicate, and the load reports it as a note rather than a warning: ``` sysml> %load a.sysml b.sysml loaded 2 files: a.sysml b.sysml -a.sysml:1:9: warning: Duplicate of other owned member name -package P { part def A; } - ^ -b.sysml:1:9: warning: Duplicate of other owned member name -package P { part def B; } - ^ ✓ package P ✓ package P note: P is opened by more than one loaded file; each opening stays a declaration of its own, so a member of one is not visible unqualified in the other — qualify it (P::member) @@ -144,10 +147,10 @@ note: P is opened by more than one loaded file; each opening stays a declaration Each file keeps its own identity, which is what lets you reload one of them and replace only its own contribution. If the two openings were merged into a single namespace, an edit to one -file could silently delete the other file's members. The members of both openings are -declared and reachable when qualified (`P::Wheel`, `P::Axle`), but an unqualified reference -from one to the other does not resolve. Entering a package at the prompt is unaffected: it still -merges into the package already in the session. +file could silently delete the other file's members. A qualified reference to `P` resolves to +the first declaration loaded, so `P::A` resolves while `P::B` does not; an unqualified reference +from one opening to the other does not resolve either. Entering a package at the prompt is +unaffected: it still merges into the package already in the session. ## Finding what a build offers diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 874b447d20..deec450f59 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 235 golden execution traces under the default schedule (state×86, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 48 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,369 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,373 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1f4b98c7e3..0036384930 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -89,6 +89,17 @@ Load multiple files before evaluating: sysml -e "result" types.sysml instances.sysml ``` +Every file named on the command line is a document of its own, analysed as the editor and the +corpus gates analyse it, and the files are indexed together so that one file's reference to a +package another declares resolves. Two consequences follow: + +- A root-level import serves only the file it is written in. `private import ScalarValues::*;` + at the top of `types.sysml` does not make `Real` resolvable in `instances.sysml`; each file + imports what it uses. +- Two files that both declare `package A` are two root packages of that name, not a duplicate. + A reference to `A` resolves to the first declaration on the command line, so `A::x` resolves + where `x` is a member of that first declaration. + ## Real-World Examples ### 1. Quick Calculation diff --git a/internal/repl/analysis.go b/internal/repl/analysis.go index 0bf4112930..a1916b151f 100644 --- a/internal/repl/analysis.go +++ b/internal/repl/analysis.go @@ -247,8 +247,7 @@ func (s *Session) runAnalysis(inv analysisInvocation) (caseRun, error) { // analysisSymbol resolves the case an invocation names. It is resolved before the // runtime is built, so a misspelling is reported as one whatever the session holds. func (s *Session) analysisSymbol(inv analysisInvocation) (*symbols.Symbol, string, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return nil, "", errors.New("no declarations loaded") } return s.lookupSymbolOfKinds(inv.name, @@ -291,7 +290,7 @@ func (s *Session) runAnalysisIn(x execution, ctx *runtime.Context, inv analysisI // A usage owned by a type is a feature of an object of that type, which the // session holds when one was created; a package-level case has no such owner. self := nestedCaseOwner(sym, fqn, objects) - runScope := declaringScope(sym, s.ws.Document(docName).Scope) + runScope := declaringScope(sym, s.rootScopeOf(sym)) // A verification case runs the same body; asking the run for its verdict too // reports it beside what the run computed. diff --git a/internal/repl/filedocs_test.go b/internal/repl/filedocs_test.go new file mode 100644 index 0000000000..7e91bb756d --- /dev/null +++ b/internal/repl/filedocs_test.go @@ -0,0 +1,222 @@ +package repl + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/core/source" +) + +// A file loaded from the command line is a document of its own: a root-level +// import in one file surfaces its names in that file's root namespace only, so +// the other files on the command line do not see them, exactly as the editor +// and the workspace report it. +func TestLoadedFilesDoNotShareRootImports(t *testing.T) { + dir := t.TempDir() + paths := []string{ + writeFile(t, filepath.Join(dir, "a.sysml"), "import ScalarValues::*;\npackage A { attribute x : Real; }\n"), + writeFile(t, filepath.Join(dir, "b.sysml"), "package B { attribute y : Real; }\n"), + } + s := NewSession() + if _, err := s.LoadFilesSummary(paths); err != nil { + t.Fatal(err) + } + var inB []string + for _, d := range s.LocatedDiagnostics() { + if d.File == paths[1] { + inB = append(inB, d.Message) + } + } + if len(inB) != 1 || !strings.Contains(inB[0], "unresolved reference: Real") { + t.Errorf("b.sysml should not see a.sysml's root import; its diagnostics: %q", inB) + } + if got, want := cliDiagnostics(t, paths), workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// Two files declaring the same root package are two root namespaces of one name, +// not a duplicate; a reference resolves to the first declaration. +func TestLoadedFilesDeclaringOneRootPackageAreNotDuplicates(t *testing.T) { + dir := t.TempDir() + paths := []string{ + writeFile(t, filepath.Join(dir, "a.sysml"), "package A { part def X; }\n"), + writeFile(t, filepath.Join(dir, "b.sysml"), "package A { part def Y; }\n"), + writeFile(t, filepath.Join(dir, "c.sysml"), "package C { part x : A::X; }\n"), + } + s := NewSession() + out, err := s.LoadFilesSummary(paths) + if err != nil { + t.Fatal(err) + } + if s.HasErrors() { + t.Errorf("the files did not validate clean:\n%s", strings.Join(s.DiagnosticLines(), "\n")) + } + for _, line := range out { + if strings.Contains(line, "Duplicate") { + t.Errorf("a repeated root package was reported as a duplicate: %s", line) + } + } + if got, want := cliDiagnostics(t, paths), workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// The prompt's transcript is a document of its own too: a root-level import in +// a loaded file does not serve what is typed after %load, though the file's +// root packages are reachable through the global namespace as before. +func TestPromptDoesNotSeeALoadedFilesRootImports(t *testing.T) { + s := NewSession() + path := tempFile(t, "a.sysml", "import ScalarValues::*;\npackage A { attribute x : Real; }\n") + if _, _, err := s.runMeta("%load " + path); err != nil { + t.Fatal(err) + } + res := s.Submit("package P { attribute y : Real; part a : A; }") + var messages []string + for _, d := range res.Diagnostics { + if res.mine(d.Span) { + messages = append(messages, d.Message) + } + } + if len(messages) != 1 || !strings.Contains(messages[0], "unresolved reference: Real") { + t.Errorf("the prompt should resolve A but not the file's import of Real; it reported %q", messages) + } +} + +// Every multi-file directory of the fixtures and of the OMG corpora reports the +// same diagnostics loaded from the command line as opened in a workspace. +func TestCommandLineLoadMatchesWorkspace(t *testing.T) { + roots := []struct { + dir string + require string // set in CI, where an absent corpus fails instead of skipping + fetch string + }{ + {dir: "../../testdata"}, + {dir: "../../examples"}, + { + dir: "../../examples/sysml-v2-training", + require: "OPENSYSML_REQUIRE_TRAINING_CORPUS", + fetch: "./scripts/download-training-examples.sh", + }, + { + dir: "../../examples/pilot-corpora/kerml-examples", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + { + dir: "../../examples/pilot-corpora/sysml-examples", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + { + dir: "../../examples/pilot-corpora/sysml-validation", + require: "OPENSYSML_REQUIRE_PILOT_CORPORA", + fetch: "./scripts/download-pilot-corpora.sh", + }, + } + seen := map[string]bool{} + for _, root := range roots { + if _, err := os.Stat(root.dir); os.IsNotExist(err) { + if os.Getenv(root.require) != "" { + t.Fatalf("%s is missing and %s is set; fetch it with %s", root.dir, root.require, root.fetch) + } + t.Logf("%s is absent (fetch it with %s), so this run proves nothing about it", root.dir, root.fetch) + continue + } + for _, files := range modelDirectories(t, root.dir) { + dir := filepath.Dir(files[0]) + if seen[dir] { + continue + } + seen[dir] = true + t.Run(filepath.ToSlash(dir), func(t *testing.T) { + got, want := cliDiagnostics(t, files), workspaceDiagnostics(t, files) + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + }) + } + } +} + +// modelDirectories walks root and returns the model files of every directory +// holding more than one, each directory's files sorted, the corpora's directories +// under a root that contains them included. +func modelDirectories(t *testing.T, root string) [][]string { + t.Helper() + byDir := map[string][]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || source.KindOf(path) == source.KindUnknown { + return nil + } + byDir[filepath.Dir(path)] = append(byDir[filepath.Dir(path)], path) + return nil + }) + if err != nil { + t.Fatalf("scan %s: %v", root, err) + } + dirs := make([]string, 0, len(byDir)) + for dir, files := range byDir { + if len(files) > 1 { + dirs = append(dirs, dir) + } + } + sort.Strings(dirs) + out := make([][]string, 0, len(dirs)) + for _, dir := range dirs { + files := byDir[dir] + sort.Strings(files) + out = append(out, files) + } + return out +} + +// cliDiagnostics loads the files as the command line does and returns what it +// reports, one sorted line per diagnostic. +func cliDiagnostics(t *testing.T, paths []string) []string { + t.Helper() + s := NewSession() + if _, err := s.LoadFilesSummary(paths); err != nil { + t.Fatal(err) + } + var out []string + for _, d := range s.LocatedDiagnostics() { + out = append(out, fmt.Sprintf("%s:%d:%d: %s: %s [%s]", filepath.Base(d.File), d.Line, d.Column, d.Severity, d.Message, d.Code)) + } + sort.Strings(out) + return out +} + +// workspaceDiagnostics opens the files in one workspace, as the editor and the +// corpus gates do, and returns what it reports in the same form as cliDiagnostics. +func workspaceDiagnostics(t *testing.T, paths []string) []string { + t.Helper() + ws := model.NewWorkspace() + contents := make(map[string][]byte, len(paths)) + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + contents[path] = content + ws.Open(path, content, 1) + } + var out []string + for _, path := range paths { + lines := source.New(path, contents[path]).Lines() + for _, d := range ws.Diagnostics(path) { + p := lines.PosAt(d.Span.Offset) + out = append(out, fmt.Sprintf("%s:%d:%d: %s: %s [%s]", filepath.Base(path), p.Line, p.Col, d.Severity, d.Message, d.Code)) + } + } + sort.Strings(out) + return out +} diff --git a/internal/repl/meta.go b/internal/repl/meta.go index d050aa5785..c425567f8f 100644 --- a/internal/repl/meta.go +++ b/internal/repl/meta.go @@ -5,7 +5,6 @@ import ( "fmt" "math" "slices" - "sort" "strconv" "strings" "unicode" @@ -716,7 +715,7 @@ func (s *Session) evalIn(name, expr string) ([]string, error) { // contextScope is the namespace a pinned context evaluates in: the element's own // scope, so its members are named without qualification, else the scope it was -// declared in, searched through both session documents. +// declared in, searched through every session document. func (s *Session) contextScope(sym *symbols.Symbol) *symbols.Scope { if sym == nil { return nil @@ -770,14 +769,14 @@ func (s *Session) evalExpr(expr string) ([]string, error) { return literalResult, litErr } - doc := s.ws.Document(docName) + declared := s.hasDeclarations() // The library is indexed with or without session declarations, so a name it // declares is answered from it; only compound expressions, handled below, - // need the session's own document. + // need the session's own documents. ctx, err := s.getOrCreateRuntime() if err != nil { - if doc == nil || doc.Scope == nil { + if !declared { return nil, s.errWithoutDeclarations(expr) } return nil, err @@ -871,7 +870,7 @@ func (s *Session) evalExpr(expr string) ([]string, error) { // A compound expression is evaluated in the session's own namespace; an empty // session has none, so only the library answers there. - if doc == nil || doc.Scope == nil { + if !declared { return s.evalWithoutDeclarations(ctx, expr) } @@ -1764,8 +1763,7 @@ func (s *Session) evalCalc(calcName, argText string) ([]string, []NamedValue, *a // calcSymbol resolves the calc %calc names. It is resolved before the runtime is // built, so a misspelling is reported as one whatever the session holds. func (s *Session) calcSymbol(calcName string) (*symbols.Symbol, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return nil, errors.New("no declarations loaded") } sym, _, lerr := s.lookupSymbolOfKinds(calcName, symbols.SymbolCalcDef, symbols.SymbolCalcUsage) @@ -2069,31 +2067,21 @@ func (s *Session) doConstraint(name string) ([]string, bool, error) { // promptScope is the namespace a prompt expression is evaluated in: the last // namespace the session declared, whose imports are then visible to it exactly // as they are to a member written there (KerML 8.2.3.5.3). A session that -// declared no namespace evaluates at the document root. Both session documents -// are read, in buffer order, so a namespace loaded from a .kerml file counts. +// declared no namespace evaluates at the document root. Every session document +// is read, in buffer order, so a namespace a loaded file declares counts. func (s *Session) promptScope() *symbols.Scope { docs := s.sessionDocs() if len(docs) == 0 { return nil } - type entry struct { - member ast.Node - scope *symbols.Scope - } - var members []entry - for _, doc := range docs { - if doc.AST == nil || doc.Scope == nil { - continue - } - for _, m := range doc.AST.Members { - members = append(members, entry{m, doc.Scope}) + var members []Member + for _, m := range s.sessionMembers() { + if m.scope != nil { + members = append(members, m) } } - sort.SliceStable(members, func(i, j int) bool { - return members[i].member.Span().Offset < members[j].member.Span().Offset - }) for i := len(members) - 1; i >= 0; i-- { - member := members[i].member + member := members[i].Node if mem, ok := member.(*ast.Membership); ok { member = mem.Member } @@ -2115,7 +2103,7 @@ func (s *Session) promptScope() *symbols.Scope { } } // No namespace to work in: the root holding the last declaration, so a - // top-level member loaded from a .kerml file is still in reach. + // top-level member of the last loaded file is still in reach. if len(members) > 0 { return members[len(members)-1].scope } diff --git a/internal/repl/print.go b/internal/repl/print.go index 4803977c0b..6db80d307c 100644 --- a/internal/repl/print.go +++ b/internal/repl/print.go @@ -54,7 +54,7 @@ func (s *Session) printElement(name string) ([]string, bool, error) { shown = name } var doc *model.Document - if sym != nil && (sym.DocName == docName || sym.DocName == kermlDocName) { + if sym != nil { doc = s.ws.Document(sym.DocName) } if doc == nil || sym == nil || sym.Decl == nil { diff --git a/internal/repl/render.go b/internal/repl/render.go index 10da8ba262..c561c2fe7d 100644 --- a/internal/repl/render.go +++ b/internal/repl/render.go @@ -12,13 +12,14 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/lexer" "github.com/Open-MBEE/OpenSysML/internal/core/passes" "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/core/symbols" ) -// Result is the outcome of one Submit: the top-level members parsed from the -// accumulated buffer (for the success summary), the names this submission -// declared, and any analysis diagnostics over the whole document. +// Result is the outcome of one Submit: the top-level members of the session's +// documents (for the success summary), the names this submission declared, and +// any analysis diagnostics over the whole buffer. type Result struct { - Members []ast.Node // top-level members of the AST (Task 5 renders these) + Members []Member // top-level members of the session documents, in buffer order Declared []string // names introduced by THIS submission Diagnostics []passes.Diagnostic // eager analysis over the whole buffer Source string // the full joined content (Task 6 caret rendering) @@ -41,6 +42,15 @@ type Result struct { masked []source.Span } +// Member is one top-level member of a session document; Offset is where the +// member begins in the buffer, a loaded file's document having offsets of its own. +type Member struct { + Node ast.Node + Offset int + // scope is the root scope of the document declaring the member. + scope *symbols.Scope +} + // Origin locates one file of a submission in the buffer, so a diagnostic is // reported against that file and its own line numbering. type Origin struct { @@ -111,10 +121,10 @@ func (r Result) holdsMine(span source.Span) bool { } // renderSummary returns one accepted line per top-level member: "✓ ". -func renderSummary(members []ast.Node) []string { +func renderSummary(members []Member) []string { out := make([]string, 0, len(members)) for _, m := range members { - if line := renderMember(m); line != "" { + if line := renderMember(m.Node); line != "" { out = append(out, "✓ "+line) } } @@ -506,13 +516,13 @@ func hasError(diags []passes.Diagnostic) bool { // within narrows the result to one span of the submission — one file of a load // of several — so what is reported as its own is scoped to that text alone. A -// member is the file's when it begins there: the last member of a document runs -// on over the other language's text masked out after it. +// member is the file's when it begins there: the transcript's last member runs +// on over the files' text masked out after it. func (r Result) within(span source.Span) Result { r.own = []source.Span{span} - members := make([]ast.Node, 0, len(r.Members)) + members := make([]Member, 0, len(r.Members)) for _, m := range r.Members { - if at := m.Span().Offset; at >= span.Offset && at < span.End() { + if m.Offset >= span.Offset && m.Offset < span.End() { members = append(members, m) } } @@ -522,10 +532,10 @@ func (r Result) within(span source.Span) Result { // ownMembers returns the top-level members this submission contributed, so a // summary does not re-announce everything typed earlier in the session. -func (r Result) ownMembers() []ast.Node { - out := make([]ast.Node, 0, len(r.Members)) +func (r Result) ownMembers() []Member { + out := make([]Member, 0, len(r.Members)) for _, m := range r.Members { - if r.holdsMine(m.Span()) { + if r.holdsMine(source.Span{Offset: m.Offset, Len: m.Node.Span().Len}) { out = append(out, m) } } diff --git a/internal/repl/run.go b/internal/repl/run.go index 27053950ab..0166e0ee0e 100644 --- a/internal/repl/run.go +++ b/internal/repl/run.go @@ -73,8 +73,9 @@ func (s *Session) LoadFileSummary(path string) ([]string, error) { return s.LoadFilesSummary([]string{path}) } -// LoadFilesSummary is LoadFileSummary over every path as one submission, indexed and -// analyzed once, each file still summarized on its own; a read failure is a *ReadError. +// LoadFilesSummary is LoadFileSummary over every path as one submission, each +// file a document of its own, indexed together and each summarized on its own; +// a read failure is a *ReadError. func (s *Session) LoadFilesSummary(paths []string) ([]string, error) { defer s.enter()() files := make([]SourceFile, 0, len(paths)) diff --git a/internal/repl/session.go b/internal/repl/session.go index 87a744db45..ccde4a347e 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -10,7 +10,6 @@ import ( "sync" "github.com/Open-MBEE/OpenSysML/internal/core/analysis" - "github.com/Open-MBEE/OpenSysML/internal/core/ast" "github.com/Open-MBEE/OpenSysML/internal/core/engines" "github.com/Open-MBEE/OpenSysML/internal/core/lexer" "github.com/Open-MBEE/OpenSysML/internal/core/libs" @@ -25,23 +24,17 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/symbols" ) -// docName is the in-memory workspace key for the accumulated REPL buffer. -// Text loaded from a .kerml file keeps that file's language: it is masked out -// of docName and analyzed in kermlDocName, whose name carries the KerML kind -// the parser's file-kind gates read. Both documents span the same joined -// buffer byte for byte, so every offset locates the same snippet in either. +// docName is the workspace key of the transcript: the typed submissions, joined, +// with each loaded file masked out — a file is a workspace document of its own. const docName = "" -// kermlDocName is the workspace key for the buffer's KerML text. -const kermlDocName = ".kerml" - // parseDocName is the document a snippet from origin is parsed and analyzed -// in, which carries the kind of the file it was loaded from. +// in: the file itself when it was loaded from one, else the transcript. func parseDocName(origin string) string { - if source.KindOf(origin) == source.KindKerML { - return kermlDocName + if origin == "" { + return docName } - return docName + return origin } // snippet is one accepted submission source, the top-level names it declares, @@ -73,7 +66,8 @@ type snippet struct { diags []passes.Diagnostic } -// Session accumulates submissions into a single implicit document. +// Session accumulates submissions: what is typed into the transcript document, +// and each loaded file into a document of its own. type Session struct { // mu serializes commands; state guards the session for readers beside one // (Complete answers Tab while a line evaluates). Exported commands take both, @@ -94,9 +88,10 @@ type Session struct { // replaced is a context a debugging session still runs against, whose identity // sequence the context built next takes over. replaced *runtime.Context - idx *symbols.Index // index over the session document, shared by lookup and runtime + idx *symbols.Index // index over the session documents, shared by lookup and runtime libSource libs.Source // the library files idx holds, for their spans' text - idxVersion int // document version idx holds, 0 when it holds none + idxVersion int // session version idx holds, 0 when it holds none + idxDocs []string // the session documents idx holds, taken back when they go names *nameTable // simple names of the documents, rebuilt when their scope trees change instances map[string]*runtime.Instance // FQN -> instance for %instantiate tracking unnamed []unnamedObject // objects a later %instantiate of their name displaced, still addressed by id @@ -606,15 +601,13 @@ func (s *Session) joined() string { return strings.Join(parts, "\n") } -// joinedFor is the buffer one session document analyzes: joined, with the -// snippets of the other language masked out too, so each document parses its -// own snippets as the kind its name carries while keeping every offset. The -// second result reports whether any snippet of that language survives. -func (s *Session) joinedFor(name string) (string, bool) { +// transcript is the buffer the transcript document analyzes: joined, with the +// loaded files masked out too; the second result reports whether any typed text remains. +func (s *Session) transcript() (string, bool) { parts := make([]string, len(s.snippets)) found := false for i, sn := range s.snippets { - if sn.open || parseDocName(sn.origin) != name { + if sn.open || sn.origin != "" { parts[i] = maskedText(sn.src) continue } @@ -624,6 +617,31 @@ func (s *Session) joinedFor(name string) (string, bool) { return strings.Join(parts, "\n"), found } +// openDocuments brings the workspace to the session's documents: the transcript, +// and one document per loaded file that parses, gone when its snippet goes. +func (s *Session) openDocuments() { + if typed, found := s.transcript(); found { + s.ws.Open(docName, []byte(typed), s.version) + } else { + s.ws.Remove(docName) + } + live := make(map[string]bool, len(s.snippets)) + for _, sn := range s.snippets { + if sn.origin == "" || sn.open { + continue + } + live[sn.origin] = true + if doc := s.ws.Document(sn.origin); doc == nil || doc.Version != sn.gen { + s.ws.Open(sn.origin, []byte(sn.src), sn.gen) + } + } + for _, name := range s.ws.DocumentNames() { + if name != docName && !live[name] { + s.ws.Remove(name) + } + } +} + // text is the buffer as it was submitted, masking nothing: what %save writes // back, so work the parser could not read is not lost. func (s *Session) text() string { @@ -675,39 +693,25 @@ func (s *Session) maskedSpans() []source.Span { return out } -// openDiagnostics reports the findings of the masked submissions, located in the -// session buffer so every surface places them in the file they came from. -func (s *Session) openDiagnostics() []passes.Diagnostic { - var out []passes.Diagnostic +// diagnostics reports the analysis of every session document and the syntax errors +// of the masked submissions, each moved to where its text sits in the session buffer. +func (s *Session) diagnostics() []passes.Diagnostic { + out := append([]passes.Diagnostic{}, s.ws.Diagnostics(docName)...) acc := 0 for _, sn := range s.snippets { - if sn.open { - for _, d := range sn.diags { - d.Span.Offset += acc - out = append(out, d) - } + var own []passes.Diagnostic + switch { + case sn.open: + own = sn.diags + case sn.origin != "": + own = s.ws.Diagnostics(sn.origin) + } + for _, d := range own { + d.Span.Offset += acc + out = append(out, d) } acc += len(sn.src) + 1 // the newline joined() writes between snippets } - return out -} - -// diagnostics reports the analysis of the buffer together with the syntax errors -// of the submissions masked out of it. The masked text is blanked rather than -// removed, so what the analysis finds is about the submissions that did parse -// and is reported as it stands. Both session documents share the buffer's -// coordinates, so their findings interleave by offset. -func (s *Session) diagnostics() []passes.Diagnostic { - analyzed := append([]passes.Diagnostic{}, s.ws.Diagnostics(docName)...) - analyzed = append(analyzed, s.ws.Diagnostics(kermlDocName)...) - open := s.openDiagnostics() - if len(open) == 0 { - sort.SliceStable(analyzed, func(i, j int) bool { return analyzed[i].Span.Offset < analyzed[j].Span.Offset }) - return analyzed - } - out := make([]passes.Diagnostic, 0, len(analyzed)+len(open)) - out = append(out, analyzed...) - out = append(out, open...) sort.SliceStable(out, func(i, j int) bool { return out[i].Span.Offset < out[j].Span.Offset }) return out } @@ -835,14 +839,8 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, // before the new text replaces that resolution, so what the new document does // not change can be told apart from what it does. over := s.recordCarryover() - sysml, _ := s.joinedFor(docName) - s.ws.Open(docName, []byte(sysml), s.version) - if kerml, found := s.joinedFor(kermlDocName); found { - s.ws.Open(kermlDocName, []byte(kerml), s.version) - } else { - s.ws.Remove(kermlDocName) - } - // The document is a new AST and scope tree, so the context derived from the + s.openDocuments() + // The documents are new ASTs and scope trees, so the context derived from the // previous one is replaced; the objects it holds are carried into the new one // where the declarations they were materialized against are unchanged. The // index is re-used and brought up to date on the next lookup instead, which is @@ -1108,15 +1106,18 @@ func (s *Session) Clear() []string { // goes is reported and recorded rather than silently emptied. func (s *Session) clear() []string { notices, lost := s.resetLoss() - s.ws.Remove(docName) - s.ws.Remove(kermlDocName) + for _, name := range s.ws.DocumentNames() { + s.ws.Remove(name) + } s.snippets = nil s.version = 0 s.rtCtx, s.replaced = nil, nil if s.idx != nil { // Drop the documents, keep the library the index was built with. - s.idx.RemoveDocument(docName) - s.idx.RemoveDocument(kermlDocName) + for _, name := range s.idxDocs { + s.idx.RemoveDocument(name) + } + s.idxDocs = nil s.idxVersion = 0 } s.instances = make(map[string]*runtime.Instance) @@ -1217,48 +1218,112 @@ func (s *Session) newRuntimeOver(model *runtime.Model) (*runtime.Context, error) // and the ones its wildcard imports surfaced, so a submission costs its own // document rather than a reload of the library. func (s *Session) symbolIndex() *symbols.Index { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + docs := s.sessionDocs() + if !hasScope(docs) { return nil } if s.idx == nil { s.idx, s.libSource = model.NewIndexWithStdlib() - } else if s.idxVersion == doc.Version { + } else if s.idxVersion == s.version { return s.idx } - s.idx.AddDocument(docName, doc.AST) - if kdoc := s.ws.Document(kermlDocName); kdoc != nil { - s.idx.AddDocument(kermlDocName, kdoc.AST) - } else { - s.idx.RemoveDocument(kermlDocName) + live := make(map[string]bool, len(docs)) + for _, doc := range docs { + live[doc.Name] = true + } + for _, name := range s.idxDocs { + if !live[name] { + s.idx.RemoveDocument(name) + } + } + s.idxDocs = s.idxDocs[:0] + for _, doc := range docs { + s.idx.AddDocument(doc.Name, doc.AST) + s.idxDocs = append(s.idxDocs, doc.Name) } s.idx.ExpandWildcardImports() - s.idxVersion = doc.Version + s.idxVersion = s.version return s.idx } -// sessionDocs returns the session's open documents, the SysML buffer first, -// so a caller reading the whole session reads both languages. -func (s *Session) sessionDocs() []*model.Document { - var out []*model.Document - for _, name := range []string{docName, kermlDocName} { - if doc := s.ws.Document(name); doc != nil { - out = append(out, doc) +// hasScope reports whether any of the documents built a scope tree. +func hasScope(docs []*model.Document) bool { + for _, doc := range docs { + if doc.Scope != nil { + return true } } - return out + return false +} + +// hasDeclarations reports whether the session holds a document with a scope tree. +func (s *Session) hasDeclarations() bool { + return hasScope(s.sessionDocs()) } -// sessionMembers returns the top-level members of both session documents in -// buffer order, which their shared coordinates make the span order. -func (s *Session) sessionMembers() []ast.Node { - var out []ast.Node +// rootScopeOf is the root scope of the session document declaring sym, and for a +// symbol the session declares nowhere that of its first document with one. +func (s *Session) rootScopeOf(sym *symbols.Symbol) *symbols.Scope { + if doc := s.ws.Document(sym.DocName); doc != nil && doc.Scope != nil { + return doc.Scope + } for _, doc := range s.sessionDocs() { - if doc.AST != nil { - out = append(out, doc.AST.Members...) + if doc.Scope != nil { + return doc.Scope + } + } + return nil +} + +// locatedDoc is a session document with the buffer offset its text begins at. +type locatedDoc struct { + doc *model.Document + base int +} + +// locatedDocs returns the transcript, whose offsets are the buffer's, then each +// loaded file's document at the offset its text sits in the buffer. +func (s *Session) locatedDocs() []locatedDoc { + var out []locatedDoc + if doc := s.ws.Document(docName); doc != nil { + out = append(out, locatedDoc{doc: doc}) + } + acc := 0 + for _, sn := range s.snippets { + if sn.origin != "" { + if doc := s.ws.Document(sn.origin); doc != nil { + out = append(out, locatedDoc{doc: doc, base: acc}) + } + } + acc += len(sn.src) + 1 // the newline joined() writes between snippets + } + return out +} + +// sessionDocs returns the session's documents, the transcript first and then the +// loaded files in buffer order. +func (s *Session) sessionDocs() []*model.Document { + located := s.locatedDocs() + out := make([]*model.Document, len(located)) + for i, l := range located { + out[i] = l.doc + } + return out +} + +// sessionMembers returns the top-level members of every session document in +// buffer order, each offset by where its document's text sits. +func (s *Session) sessionMembers() []Member { + var out []Member + for _, l := range s.locatedDocs() { + if l.doc.AST == nil { + continue + } + for _, m := range l.doc.AST.Members { + out = append(out, Member{Node: m, Offset: l.base + m.Span().Offset, scope: l.doc.Scope}) } } - sort.SliceStable(out, func(i, j int) bool { return out[i].Span().Offset < out[j].Span().Offset }) + sort.SliceStable(out, func(i, j int) bool { return out[i].Offset < out[j].Offset }) return out } diff --git a/internal/repl/sweep.go b/internal/repl/sweep.go index 05d9bef5bb..f331b98705 100644 --- a/internal/repl/sweep.go +++ b/internal/repl/sweep.go @@ -150,8 +150,7 @@ func sweepLabel(inv analysisInvocation, draws sweepDraws) string { // row per value in a context of its own, held objects made there from their declarations or // from one image of the held graph; the session's state is released while the rows run. func (s *Session) runSweep(inv analysisInvocation, specs []sweepSpec, draws sweepDraws) (runtime.SweepTable, *analysis.Plan, error) { - doc := s.ws.Document(docName) - if doc == nil || doc.Scope == nil { + if !s.hasDeclarations() { return runtime.SweepTable{}, nil, errors.New("no declarations loaded") } sym, fqn, err := s.lookupSymbolOfKinds(inv.name, @@ -206,7 +205,7 @@ func (s *Session) runSweep(inv analysisInvocation, specs []sweepSpec, draws swee if err != nil { return runtime.SweepTable{}, nil, err } - runScope := declaringScope(sym, doc.Scope) + runScope := declaringScope(sym, s.rootScopeOf(sym)) run := func(rt *runtime.Context, bindings []runtime.SweepBinding) (runtime.SweepRunResult, error) { row, err := s.rowObjects(rt, args.objects, image) diff --git a/internal/repl/view.go b/internal/repl/view.go index b26830ba69..75b27d3347 100644 --- a/internal/repl/view.go +++ b/internal/repl/view.go @@ -218,14 +218,16 @@ func (s *Session) Views() ([]model.ViewInfo, error) { func (s *Session) symbolsInLoadOrder(in func(*symbols.Scope) []*symbols.Symbol) []*symbols.Symbol { idx := s.browseIndex() var out []*symbols.Symbol - for _, doc := range s.sessionDocs() { - out = append(out, in(idx.DocumentRoot(doc.Name))...) - } - // The language documents are masked copies of one joined buffer, so their - // spans share coordinates and sorting restores submission order. - sort.SliceStable(out, func(i, j int) bool { - return out[i].DeclSpan.Offset < out[j].DeclSpan.Offset - }) + // Each document's symbols are placed where its text sits in the buffer, so + // sorting restores submission order across the documents. + at := make(map[*symbols.Symbol]int) + for _, l := range s.locatedDocs() { + for _, sym := range in(idx.DocumentRoot(l.doc.Name)) { + at[sym] = l.base + sym.DeclSpan.Offset + out = append(out, sym) + } + } + sort.SliceStable(out, func(i, j int) bool { return at[out[i]] < at[out[j]] }) return out } From 715418454800f15f5e7b0f3229877f0fc6ff110c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:47:09 +0000 Subject: [PATCH 02/53] refactor(repl): parse a compound prompt expression after the transcript alone Co-Authored-By: jason.han --- internal/repl/meta.go | 6 ++++-- internal/repl/session.go | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/repl/meta.go b/internal/repl/meta.go index c425567f8f..689a4ddb8b 100644 --- a/internal/repl/meta.go +++ b/internal/repl/meta.go @@ -874,8 +874,10 @@ func (s *Session) evalExpr(expr string) ([]string, error) { return s.evalWithoutDeclarations(ctx, expr) } - // Complex expression with feature refs - inject into session context - tempSrc := s.joined() + fmt.Sprintf("\nattribute __eval__ = %s;", expr) + // Complex expression with feature refs - parsed after the transcript, the + // loaded files masked out of it as they are out of the transcript document + typed, _ := s.transcript() + tempSrc := typed + fmt.Sprintf("\nattribute __eval__ = %s;", expr) p := parser.New(source.New("eval", []byte(tempSrc))) root := p.ParseFile() diff --git a/internal/repl/session.go b/internal/repl/session.go index ccde4a347e..6320d3379a 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -585,7 +585,7 @@ func isCommentOnly(src string) bool { // belongs to no file on disk. const sessionOrigin = "" -// joined is the buffer the session analyzes: every accepted submission, with a +// joined is the buffer the session presents: every accepted submission, with a // submission that does not close its own text masked out so it cannot change how // the others parse. Masking is byte for byte, so every offset still locates the // snippet and line it came from. From fb004ca06b943d1df96a6b23c2309ea7263f6d1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:01:40 +0000 Subject: [PATCH 03/53] docs(skills): probes for per-file document isolation in the REPL testing skill Co-Authored-By: jason.han --- .agents/skills/testing-sysml-repl/SKILL.md | 33 +++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 762a4b3ef1..090055dad9 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -2464,9 +2464,40 @@ its output rather than in an exit code — so assert on the exact rendered text: ## Multi-file projects: `%load ...` and positional dirs/globs (PR #146) +### Per-file document isolation probes + +- Give only one file a root-level `private import ScalarValues::*;`. A second + file's bare `Real` must stay unresolved, as must a later prompt declaration's + bare `Real`. A qualified expression such as `%eval A::x + 1.0` should still + work, proving isolation did not remove the loaded package from the index. +- Two loaded files declaring the same root package are two root namespaces, not + a duplicate, and a reference to the name resolves to the first declaration in + load order. Use separate `A::X` and `A::Y` files and reverse their load order + to prove that behavior. +- For rendering order, `%view` takes a **view**, not an ordinary package. + `%render #table` renders the loaded documents without a declared view; reverse + two nonalphabetical package names and assert their member groups reverse. + A declared view with `render asElementTable;` needs `private import Views::*;` + in its scope. +- `%save` passes notation through the formatter. Test source retention separately + from byte equality: tabs can become four spaces even while comments, members, + file order and typed declarations survive. Compare with a `develop` build + before attributing such formatting to a load-path regression. +- Both debugger fixtures in `internal/repl/testdata/` are load-ready: + `action_debug.sysml` (`%action Debug::tally`, `%step`, type `part def Z;`, + `%continue`) ends at `total = 5`; `state_debug.sysml` (`%state Debug::Cycle`, + `%advance 1`, type `part def Z;`, `%advance 9`, `%advance 5`) reaches working + at t=10 and done at t=15. This tests symbol rebinding across prompt edits. + +#### Devin Secrets Needed + +None for local multi-file CLI/REPL tests. + `sysml ...` and `%load ...` expand to model files via `internal/core/project.Expand`, and every file is accepted before one analysis pass -(`Session.SubmitAll`), so load order does not affect name resolution. Shapes to expect: +(`Session.SubmitAll`), each file a workspace document of its own indexed with the +others, so load order does not affect name resolution except between root namespaces of +one name (the first wins). Shapes to expect: - More than one file prints a `loaded N files:` header listing each path (a single file prints no header — a good tell that the multi-file path was taken). From bfaa7162e646bdfd5d36184d910490707553cedc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:26:26 +0000 Subject: [PATCH 04/53] perf(model): parse and analyze the files of one load on a pool of workers A workspace opens a batch of documents in one step: the files are parsed and their scope trees built on workers, added to the one index in order, and the wildcard imports expanded once for the batch. Their diagnostics are computed on workers too, each with a context of its own over the index; before the pool starts, the metadata body scopes of the batch are linked to their owners, which resolving would otherwise write into the shared tree on first use. The results come back in the order asked, the same at any worker count. The document's own scope tree is the one the index holds, so a document is built once rather than twice. The REPL loads files through the batch; -workers and OPENSYSML_WORKERS set the count, one per CPU by default. The stress generator gains -split-planes. Co-Authored-By: jason.han --- cmd/stress-model/main.go | 33 ++- cmd/sysml/main.go | 42 ++++ cmd/sysml/usage.go | 3 +- internal/core/model/batch.go | 176 +++++++++++++ internal/core/model/batch_test.go | 219 ++++++++++++++++ internal/core/model/workspace.go | 20 +- internal/core/passes/analyze.go | 25 ++ internal/core/passes/pass.go | 11 + internal/core/resolve/document.go | 46 +++- internal/core/resolve/link_metadata_test.go | 158 ++++++++++++ internal/core/symbols/builder.go | 16 +- internal/core/symbols/index.go | 16 +- internal/core/symbols/scope.go | 5 + internal/repl/session.go | 54 +++- internal/repl/workers.go | 15 ++ internal/stressmodel/satnet.go | 265 +++++++++++++------- internal/usage/environment.go | 8 + man/man1/sysml.1 | 10 + 18 files changed, 992 insertions(+), 130 deletions(-) create mode 100644 internal/core/model/batch.go create mode 100644 internal/core/model/batch_test.go create mode 100644 internal/core/resolve/link_metadata_test.go create mode 100644 internal/repl/workers.go diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index ce3618c283..2245cff289 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -1,13 +1,15 @@ // Command stress-model writes a large generated SysML v2 model of a stated shape -// and size to stdout, for measuring how the toolchain scales. The one shape today -// is a satellite network — a constellation of fully modeled spacecraft and the -// ground stations they downlink to. See docs/project/satellite-network-stress-test.md. +// and size to stdout, or one file per orbital plane to a directory, for measuring +// how the toolchain scales. The one shape today is a satellite network — a +// constellation of fully modeled spacecraft and the ground stations they downlink +// to. See docs/project/satellite-network-stress-test.md. package main import ( "flag" "fmt" "os" + "path/filepath" "github.com/Open-MBEE/OpenSysML/internal/stressmodel" ) @@ -17,6 +19,7 @@ func main() { perPlane := flag.Int("satellites", 8, "satellites in each orbital plane") stations := flag.Int("ground-stations", 3, "ground stations the constellation downlinks to") stats := flag.Bool("stats", false, "report on stderr what the model declares") + split := flag.String("split-planes", "", "write one .sysml per orbital plane, beside the library and the constellation, into this directory instead of stdout") flag.Parse() if flag.NArg() != 0 { fmt.Fprintf(os.Stderr, "stress-model: unexpected argument %q\n", flag.Arg(0)) @@ -27,7 +30,15 @@ func main() { os.Exit(2) } n := stressmodel.SatelliteNetwork{Planes: *planes, Satellites: *perPlane, GroundStations: *stations} - s, err := n.Generate(os.Stdout) + var ( + s stressmodel.Stats + err error + ) + if *split != "" { + s, err = writeSplit(n, *split) + } else { + s, err = n.Generate(os.Stdout) + } if err != nil { fmt.Fprintf(os.Stderr, "stress-model: %v\n", err) os.Exit(1) @@ -37,3 +48,17 @@ func main() { s.Satellites, s.GroundStations, s.Components, s.Connections, s.Requirements, s.Elements, s.Bytes) } } + +// writeSplit writes the network one file per plane into dir, creating it. +func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { + files, stats := n.Split() + if err := os.MkdirAll(dir, 0o755); err != nil { + return stats, err + } + for _, f := range files { + if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o644); err != nil { + return stats, err + } + } + return stats, nil +} diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 6d55f4a749..263f368568 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -17,6 +17,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/docrender" engineset "github.com/Open-MBEE/OpenSysML/internal/core/engines" "github.com/Open-MBEE/OpenSysML/internal/core/export" + "github.com/Open-MBEE/OpenSysML/internal/core/model" "github.com/Open-MBEE/OpenSysML/internal/core/runtime" "github.com/Open-MBEE/OpenSysML/internal/repl" "github.com/Open-MBEE/OpenSysML/internal/usage" @@ -117,6 +118,7 @@ var ( probeEngines bool engine engineSelection jobsFlag jobsSetting + workersFlag workersSetting convertFormat string queryText string outputPath string @@ -201,6 +203,28 @@ func (j *jobsSetting) Set(value string) error { return nil } +// workers is how many files of one load are parsed and analyzed at once: -workers +// when given, else OPENSYSML_WORKERS, else one per CPU; read once at startup. +var workers = model.DefaultWorkers() + +// workersSetting is -workers as written, rejected where it is parsed so a value +// below one is reported at startup rather than at the first load. +type workersSetting struct { + value int + text string +} + +func (w *workersSetting) String() string { return w.text } + +func (w *workersSetting) Set(value string) error { + n, err := model.ParseWorkers("-workers", value) + if err != nil { + return err + } + w.value, w.text = n, value + return nil +} + // schedulePolicy is -schedule as written: the policy every run resolves its // choice points under, rejected where it is parsed so a misspelling is reported // at startup rather than run under the default. @@ -262,6 +286,15 @@ func resolveJobs() (int, error) { return analysis.JobsFromEnv() } +// resolveWorkers is the workers a load goes under: -workers when given, else what +// OPENSYSML_WORKERS holds, reported when that is not a positive integer. +func resolveWorkers() (int, error) { + if flagGiven("workers") { + return workersFlag.value, nil + } + return model.WorkersFromEnv() +} + // flagGiven reports whether the run named this flag, which an empty value // cannot be told apart from otherwise. func flagGiven(name string) bool { @@ -641,6 +674,10 @@ func resolveRunBounds() int { fmt.Fprintln(os.Stderr, errPrefix, err) return 2 } + if workers, err = resolveWorkers(); err != nil { + fmt.Fprintln(os.Stderr, errPrefix, err) + return 2 + } return 0 } @@ -682,6 +719,11 @@ func newSession() *repl.Session { fmt.Fprintln(os.Stderr, errPrefix, err) os.Exit(2) } + if err := sess.SetWorkers(workers); err != nil { + // Unreachable: workers were validated in main before any session exists. + fmt.Fprintln(os.Stderr, errPrefix, err) + os.Exit(2) + } sess.SetConformanceMode(conformance.ModeOf(strictMode)) sess.SetRenderWidth(terminalWidth()) return sess diff --git a/cmd/sysml/usage.go b/cmd/sysml/usage.go index 3f74abc664..c6c1163828 100644 --- a/cmd/sysml/usage.go +++ b/cmd/sysml/usage.go @@ -309,7 +309,7 @@ func doc() usage.Doc { }, { Title: "Environment", ManOnly: true, - Items: append(append(append(usage.BudgetEnvironment(), usage.JobsEnvironment()...), usage.ToolEnvironment()...), solverEnvironment()...), + Items: append(append(append(append(usage.BudgetEnvironment(), usage.JobsEnvironment()...), usage.WorkersEnvironment()...), usage.ToolEnvironment()...), solverEnvironment()...), Paragraphs: []string{usage.LegacyPrefixNote, usage.BudgetScopeNote}, }, { Title: "Files", @@ -359,6 +359,7 @@ func registerFlags(fs *flag.FlagSet) { fs.BoolVar(&probeEngines, "probe", false, "With -engines, also start each external engine once, check its describe against its manifest entry field by field and report the outcome as its status") fs.Var(&engine, "engine", "Analysis engine every check is put to: auto (default) picks the strongest engine covering the question, all puts it to every covering engine in name order and composes their answers, or an engine by name (run, explore, check, smt, sweep, solve, tool: from OPENSYSML_TOOLS, or an external engine from OPENSYSML_ENGINES), whose refusal is then the answer; -engine explore is -schedule explore, -engine check searches every schedule of each -action for a violation, deadlock, failure or divergence, and -engine smt decides each -check-property over every schedule and every value of the action's free inputs through an SMT solver") fs.Var(&jobsFlag, "jobs", "Runs of one check that may go concurrently — the linearizations of an exploration, the engines -engine all consults — each on a worker of its own over the shared model; the result is the same at any count. Default OPENSYSML_JOBS, else the number of CPUs") + fs.Var(&workersFlag, "workers", "Files of one load that are parsed and analyzed at once, each on a worker of its own over the shared index; the diagnostics are the same at any count. Default OPENSYSML_WORKERS, else the number of CPUs") fs.StringVar(&convertFormat, "convert", "", "Convert the model to this format instead of running it: sysml, kerml, ttl, turtle or rdf (RDF is experimental)") fs.StringVar(&queryText, "query", "", "Evaluate OSLC Query text against the model instead of running the REPL") fs.StringVar(&outputPath, "output", "", "Write conversion output to this file (default: stdout)") diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go new file mode 100644 index 0000000000..b5ca0541dd --- /dev/null +++ b/internal/core/model/batch.go @@ -0,0 +1,176 @@ +package model + +import ( + "bytes" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/Open-MBEE/OpenSysML/internal/core/passes" +) + +// Input is one document a batch opens: the name it is indexed under, its text +// and its version. +type Input struct { + Name string + Content []byte + Version int +} + +// WorkersEnvVar names the environment variable that sets how many documents a +// batch parses and analyzes at once when no setting of the caller's does. +const WorkersEnvVar = "OPENSYSML_WORKERS" + +// DefaultWorkers is the worker count a workspace starts with: one per CPU the +// process may run on. +func DefaultWorkers() int { return runtime.GOMAXPROCS(0) } + +// WorkersError reports a worker count that is not a positive integer, naming +// the setting it came from. +type WorkersError struct { + Source string + Value string +} + +// Error names the source and the value, and what a usable one is. +func (e *WorkersError) Error() string { + return fmt.Sprintf("%s=%q is not a positive integer: set it to how many documents may be parsed and analyzed at once (default %d, one per CPU)", e.Source, e.Value, DefaultWorkers()) +} + +// ParseWorkers reads a worker count written at source: a positive integer, else +// a WorkersError. +func ParseWorkers(source, text string) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(text)) + if err != nil || n < 1 { + return 0, &WorkersError{Source: source, Value: text} + } + return n, nil +} + +// WorkersFromEnv is the worker count OPENSYSML_WORKERS asks for, DefaultWorkers +// when it is unset or empty; a value that is not a positive integer is an error. +func WorkersFromEnv() (int, error) { + return workersFromLookup(os.Getenv) +} + +// workersFromLookup is WorkersFromEnv over an explicit lookup, so the parsing is +// testable without the process environment. +func workersFromLookup(lookup func(string) string) (int, error) { + raw := lookup(WorkersEnvVar) + if strings.TrimSpace(raw) == "" { + return DefaultWorkers(), nil + } + return ParseWorkers(WorkersEnvVar, raw) +} + +// Workers reports how many documents a batch parses and analyzes at once. +func (w *Workspace) Workers() int { + w.mu.RLock() + defer w.mu.RUnlock() + return w.workers +} + +// SetWorkers sets how many documents a batch parses and analyzes at once. The +// result of a batch is the same at any count; a count below one is an error. +func (w *Workspace) SetWorkers(n int) error { + if n < 1 { + return &WorkersError{Source: "workers", Value: strconv.Itoa(n)} + } + w.mu.Lock() + defer w.mu.Unlock() + w.workers = n + return nil +} + +// OpenAll opens every input as an authoritative buffer in one batch: the +// documents are parsed and their scope trees built on the workers, then added +// to the index in the order given, and the wildcard imports of the whole batch +// are expanded once. It leaves the workspace as opening the inputs one by one +// would, at a cost that does not grow with the number already open. +func (w *Workspace) OpenAll(inputs []Input) { + docs := make([]*Document, len(inputs)) + ParallelFor(w.Workers(), len(inputs), func(i int) { + in := inputs[i] + docs[i] = newDocument(in.Name, bytes.Clone(in.Content), in.Version) + }) + w.mu.Lock() + defer w.mu.Unlock() + for _, doc := range docs { + w.open[doc.Name] = true + w.docs[doc.Name] = doc + w.index.AddBuiltDocument(doc.Name, doc.AST, doc.Scope) + } + w.index.ExpandWildcardImports() + w.invalidateLocked() +} + +// DiagnosticsAll returns the diagnostics of the named documents, in the order +// named, with nil for a name the workspace does not hold. The documents not yet +// analyzed since the last change are analyzed on the workers, each with a +// context of its own over the index, which nothing writes meanwhile; the +// results are cached as Diagnostics caches them. +func (w *Workspace) DiagnosticsAll(names []string) [][]passes.Diagnostic { + w.mu.Lock() + defer w.mu.Unlock() + out := make([][]passes.Diagnostic, len(names)) + var pending []string + queued := map[string]bool{} + for i, name := range names { + if w.docs[name] == nil { + continue + } + if cached, ok := w.diagCache[name]; ok { + out[i] = cached + } else if !queued[name] { + queued[name] = true + pending = append(pending, name) + } + } + batch := &passes.Batch{Documents: pending} + passes.PrepareBatch(w.index, batch) + analyzed := make([][]passes.Diagnostic, len(pending)) + ParallelFor(w.workers, len(pending), func(i int) { + analyzed[i] = w.analyze(pending[i], w.docs[pending[i]], batch) + }) + for i, name := range pending { + w.diagCache[name] = analyzed[i] + } + for i, name := range names { + if out[i] == nil && w.docs[name] != nil { + out[i] = w.diagCache[name] + } + } + return out +} + +// ParallelFor runs fn(i) for every i below n on up to workers goroutines, and +// returns once every call has; it is how a batch spreads its documents. +func ParallelFor(workers, n int, fn func(i int)) { + workers = min(workers, n) + if workers <= 1 { + for i := 0; i < n; i++ { + fn(i) + } + return + } + var next atomic.Int64 + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + for { + i := int(next.Add(1) - 1) + if i >= n { + return + } + fn(i) + } + }() + } + wg.Wait() +} diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go new file mode 100644 index 0000000000..586fc559c4 --- /dev/null +++ b/internal/core/model/batch_test.go @@ -0,0 +1,219 @@ +package model + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/passes" + "github.com/Open-MBEE/OpenSysML/internal/core/source" +) + +// The model roots a batch is checked against: the fixtures, the shipped examples +// and the four OMG corpora, the latter skipped when absent unless CI requires them. +var batchRoots = []struct { + dir string + require string + fetch string +}{ + {dir: "../../../testdata"}, + {dir: "../../../examples"}, + {dir: trainingGate.roots[0].dir, require: trainingGate.requireEnv, fetch: trainingGate.fetch}, + {dir: pilotCorporaGate.roots[0].dir, require: pilotCorporaGate.requireEnv, fetch: pilotCorporaGate.fetch}, + {dir: pilotCorporaGate.roots[1].dir, require: pilotCorporaGate.requireEnv, fetch: pilotCorporaGate.fetch}, + {dir: pilotCorporaGate.roots[2].dir, require: pilotCorporaGate.requireEnv, fetch: pilotCorporaGate.fetch}, +} + +// Every directory of models, opened as one batch, reports the same diagnostics +// in the same order on one worker as on many, and the same as opening its files +// one by one does: the parallel pipeline changes when the work is done, not what +// it finds. +func TestParallelBatchValidationMatchesSerial(t *testing.T) { + seen := map[string]bool{} + for _, root := range batchRoots { + if _, err := os.Stat(root.dir); os.IsNotExist(err) { + if os.Getenv(root.require) != "" { + t.Fatalf("%s is missing and %s is set; fetch it with %s", root.dir, root.require, root.fetch) + } + t.Logf("%s is absent (fetch it with %s), so this run proves nothing about it", root.dir, root.fetch) + continue + } + for _, files := range modelDirectories(t, root.dir) { + dir := filepath.Dir(files[0]) + if seen[dir] { + continue + } + seen[dir] = true + t.Run(filepath.ToSlash(dir), func(t *testing.T) { + inputs := readInputs(t, files) + serial := serialDiagnostics(inputs) + for _, workers := range []int{1, 2, 8} { + if got := batchDiagnostics(t, inputs, workers); got != serial { + t.Errorf("a batch on %d workers reported:\n%s\nwant, as opening the files one by one does:\n%s", workers, got, serial) + } + } + }) + } + } +} + +// A batch analyzes only what is not cached, answers every name asked for in the +// order asked, repeats included, and nil for a name the workspace does not hold. +func TestDiagnosticsAllAnswersInTheOrderAsked(t *testing.T) { + ws := NewWorkspace() + ws.OpenAll([]Input{ + {Name: "a.sysml", Content: []byte("package A { part def X; }"), Version: 1}, + {Name: "b.sysml", Content: []byte("package B { part x : A::X; part y : Missing; }"), Version: 1}, + }) + if diags := ws.Diagnostics("a.sysml"); len(diags) != 0 { + t.Fatalf("a.sysml should analyze cleanly, got %+v", diags) + } + got := ws.DiagnosticsAll([]string{"b.sysml", "none.sysml", "a.sysml", "b.sysml"}) + if len(got) != 4 || got[1] != nil || len(got[2]) != 0 { + t.Fatalf("DiagnosticsAll = %v, want b, nil, none, b", got) + } + if len(got[0]) != 1 || got[0][0].Message != "unresolved reference: Missing" || len(got[3]) != 1 { + t.Fatalf("b.sysml reported %v, want one unresolved reference to Missing", got[0]) + } + if &got[0][0] != &got[3][0] { + t.Errorf("the two answers for b.sysml should be the one cached slice") + } +} + +// A batch opened over documents already there replaces them as Open does, so +// the index holds each name once. +func TestOpenAllReplacesEarlierDocuments(t *testing.T) { + ws := NewWorkspace() + ws.Open("a.sysml", []byte("package A { part def Old; }"), 1) + ws.OpenAll([]Input{{Name: "a.sysml", Content: []byte("package A { part def New; }"), Version: 2}}) + if syms := ws.LookupQualified("A::Old"); len(syms) != 0 { + t.Errorf("A::Old should be gone, found %d", len(syms)) + } + if syms := ws.LookupQualified("A::New"); len(syms) != 1 { + t.Errorf("A::New should be indexed once, found %d", len(syms)) + } + if doc := ws.Document("a.sysml"); doc == nil || doc.Version != 2 || !ws.IsOpen("a.sysml") { + t.Errorf("a.sysml should be the open version 2 document, got %+v", doc) + } +} + +func TestWorkersSetting(t *testing.T) { + ws := NewWorkspace() + if ws.Workers() != DefaultWorkers() || DefaultWorkers() < 1 { + t.Fatalf("Workers() = %d, want the default %d", ws.Workers(), DefaultWorkers()) + } + if err := ws.SetWorkers(0); err == nil { + t.Error("SetWorkers(0) should be an error") + } + if err := ws.SetWorkers(3); err != nil || ws.Workers() != 3 { + t.Errorf("SetWorkers(3) = %v, Workers() = %d", err, ws.Workers()) + } + env := map[string]string{} + lookup := func(key string) string { return env[key] } + if n, err := workersFromLookup(lookup); err != nil || n != DefaultWorkers() { + t.Errorf("unset: %d, %v; want the default", n, err) + } + env[WorkersEnvVar] = " 4 " + if n, err := workersFromLookup(lookup); err != nil || n != 4 { + t.Errorf("4: %d, %v", n, err) + } + for _, bad := range []string{"0", "-2", "many", "1.5"} { + env[WorkersEnvVar] = bad + _, err := workersFromLookup(lookup) + var we *WorkersError + if err == nil || !strings.Contains(err.Error(), WorkersEnvVar) || !errors.As(err, &we) { + t.Errorf("%q: err = %v, want a WorkersError naming %s", bad, err, WorkersEnvVar) + } + } +} + +// modelDirectories walks root and returns the model files of every directory +// holding one or more, each directory's files sorted. +func modelDirectories(t *testing.T, root string) [][]string { + t.Helper() + byDir := map[string][]string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || source.KindOf(path) == source.KindUnknown { + return nil + } + byDir[filepath.Dir(path)] = append(byDir[filepath.Dir(path)], path) + return nil + }) + if err != nil { + t.Fatalf("scan %s: %v", root, err) + } + dirs := make([]string, 0, len(byDir)) + for dir := range byDir { + dirs = append(dirs, dir) + } + sort.Strings(dirs) + out := make([][]string, 0, len(dirs)) + for _, dir := range dirs { + files := byDir[dir] + sort.Strings(files) + out = append(out, files) + } + return out +} + +func readInputs(t *testing.T, paths []string) []Input { + t.Helper() + inputs := make([]Input, 0, len(paths)) + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + inputs = append(inputs, Input{Name: path, Content: content, Version: 1}) + } + return inputs +} + +// serialDiagnostics opens the inputs one by one and diagnoses each in turn, the +// path the editor takes, and renders the result for comparison. +func serialDiagnostics(inputs []Input) string { + ws := NewWorkspace() + for _, in := range inputs { + ws.Open(in.Name, in.Content, in.Version) + } + var b strings.Builder + for _, in := range inputs { + renderDiagnostics(&b, in.Name, ws.Diagnostics(in.Name)) + } + return b.String() +} + +// batchDiagnostics opens the inputs as one batch on the given workers and +// diagnoses them as one batch, rendering the result as serialDiagnostics does. +func batchDiagnostics(t *testing.T, inputs []Input, workers int) string { + t.Helper() + ws := NewWorkspace() + if err := ws.SetWorkers(workers); err != nil { + t.Fatal(err) + } + ws.OpenAll(inputs) + names := make([]string, len(inputs)) + for i, in := range inputs { + names[i] = in.Name + } + var b strings.Builder + for i, diags := range ws.DiagnosticsAll(names) { + renderDiagnostics(&b, names[i], diags) + } + return b.String() +} + +// renderDiagnostics writes every field of each diagnostic, in the order given, +// so a comparison sees content and order alike. +func renderDiagnostics(b *strings.Builder, name string, diags []passes.Diagnostic) { + for _, d := range diags { + fmt.Fprintf(b, "%s: %+v\n", filepath.Base(name), d) + } +} diff --git a/internal/core/model/workspace.go b/internal/core/model/workspace.go index 2418b3d85a..f2c75a721e 100644 --- a/internal/core/model/workspace.go +++ b/internal/core/model/workspace.go @@ -39,6 +39,9 @@ type Workspace struct { // nil when the index came without one; libDocs caches them parsed. libSource libs.Source libDocs map[string]*Document + + // workers is how many documents OpenAll and DiagnosticsAll work on at once. + workers int } // Option configures a workspace at construction. @@ -78,6 +81,7 @@ func NewWorkspaceWithIndex(idx *symbols.Index, opts ...Option) *Workspace { index: idx, diagCache: map[string][]passes.Diagnostic{}, libDocs: map[string]*Document{}, + workers: DefaultWorkers(), } for _, opt := range opts { opt(w) @@ -202,8 +206,8 @@ func (w *Workspace) Remove(name string) { func (w *Workspace) reindexLocked(name string, content []byte, version int) { doc := newDocument(name, content, version) w.docs[name] = doc - w.index.AddDocument(name, doc.AST) // AddDocument removes stale entries first - w.index.ExpandWildcardImports() // Expand new document's wildcard imports + w.index.AddBuiltDocument(name, doc.AST, doc.Scope) // removes stale entries first + w.index.ExpandWildcardImports() w.invalidateLocked() } @@ -255,6 +259,14 @@ func (w *Workspace) diagnosticsLocked(name string, doc *Document) []passes.Diagn if cached, ok := w.diagCache[name]; ok { return cached } + diags := w.analyze(name, doc, nil) + w.diagCache[name] = diags + return diags +} + +// analyze runs the passes over doc with a context of its own, reading the index, +// the analysis options and the batch only, so documents can be analyzed at once. +func (w *Workspace) analyze(name string, doc *Document, batch *passes.Batch) []passes.Diagnostic { parseDiags := make([]passes.Diagnostic, 0, len(doc.ParseDiagnostics)+len(doc.ParseWarnings)) for _, pd := range doc.ParseDiagnostics { parseDiags = append(parseDiags, passes.Diagnostic{ @@ -276,9 +288,7 @@ func (w *Workspace) diagnosticsLocked(name string, doc *Document) []passes.Diagn Fixes: pw.Fixes, }) } - diags := passes.AnalyzeWithOptions(name, source.KindOf(name), doc.AST, parseDiags, w.index, w.analysis) - w.diagCache[name] = diags - return diags + return passes.AnalyzeInBatch(name, source.KindOf(name), doc.AST, parseDiags, w.index, w.analysis, batch) } // LookupQualified resolves a fully-qualified name against the global index under diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index 9228e0563a..270e9b37fe 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -112,7 +112,32 @@ func dropEscalatedWarnings(diags []Diagnostic) []Diagnostic { // AnalyzeWithOptions validates a document under explicit analysis options. func AnalyzeWithOptions(name string, kind source.Kind, root *ast.RootNamespace, parseDiags []Diagnostic, idx *symbols.Index, opts Options) []Diagnostic { + return AnalyzeInBatch(name, kind, root, parseDiags, idx, opts, nil) +} + +// PrepareBatch readies the index for the documents of batch to be analyzed at +// once: what resolving each would otherwise link into its scope tree on first use +// is linked now, so the contexts of the batch only read it. Call it before the +// first AnalyzeInBatch of the batch, with nothing else using the index. +func PrepareBatch(idx *symbols.Index, batch *Batch) { + if idx == nil || batch == nil { + return + } + linker := NewContext("", idx, nil) + _ = linker.Model() + for _, name := range batch.Documents { + linker.Resolver().LinkMetadataBodies(name) + } +} + +// AnalyzeInBatch validates one document of a batch, with a context of its own +// over an index nothing writes while the batch runs (see PrepareBatch); the +// result does not depend on which documents share the batch or on how many are +// analyzed at once. +func AnalyzeInBatch(name string, kind source.Kind, root *ast.RootNamespace, + parseDiags []Diagnostic, idx *symbols.Index, opts Options, batch *Batch) []Diagnostic { ctx := NewContextWithOptions(name, kind, idx, parseDiags, opts) + ctx.Batch = batch diags := dropEscalatedWarnings(DefaultRegistry().Run(ctx, name, root)) sort.SliceStable(diags, func(i, j int) bool { a, b := diags[i], diags[j] diff --git a/internal/core/passes/pass.go b/internal/core/passes/pass.go index 0008902253..3c8dca610c 100644 --- a/internal/core/passes/pass.go +++ b/internal/core/passes/pass.go @@ -55,6 +55,9 @@ type Context struct { // Options is what the caller asked for, fixed at construction: a pass reads // it, and nothing mutates it during a run. Options Options + // Batch is the batch this document is analyzed in, nil when it is analyzed + // alone; every context of a batch reads the one value and none writes it. + Batch *Batch resolver *resolve.Resolver model *semantics.Model @@ -65,6 +68,14 @@ type Context struct { failures []source.Span } +// Batch is what a batch of analyses computes once, before its documents are +// analyzed at the same time, for the passes that judge a document against the +// whole workspace; anything a pass would gather over every document belongs here. +type Batch struct { + // Documents names the documents the batch analyzes, in the order asked for. + Documents []string +} + // Options is the analysis configuration of one run. The zero value is what // every existing caller gets: today's behavior, unchanged. type Options struct { diff --git a/internal/core/resolve/document.go b/internal/core/resolve/document.go index 12fa3c1735..41ab000e9b 100644 --- a/internal/core/resolve/document.go +++ b/internal/core/resolve/document.go @@ -546,13 +546,10 @@ func (r *Resolver) resolveMetadataPrefix(names, parent *symbols.Scope, prefix *a for _, a := range prefix.About { r.ResolveQualified(names, a) } - owner, ok := r.ResolveQualified(names, prefix.Type) - if !ok || owner == nil || len(prefix.Body) == 0 { + owner := r.metadataBodyOwner(names, prefix) + if owner == nil { return } - if target, aliasOK := r.ResolveAliasTarget(owner); aliasOK { - owner = target - } body := parent.ChildFor(prefix) if body == nil { return @@ -564,6 +561,45 @@ func (r *Resolver) resolveMetadataPrefix(names, parent *symbols.Scope, prefix *a r.resolveMetadataBody(body, prefix.Body) } +// metadataBodyOwner is the metadata definition the body of prefix resolves +// against, its type read in names; nil when the type does not resolve or the +// annotation has no body. +func (r *Resolver) metadataBodyOwner(names *symbols.Scope, prefix *ast.PrefixMetadata) *symbols.Symbol { + owner, ok := r.ResolveQualified(names, prefix.Type) + if !ok || owner == nil || len(prefix.Body) == 0 { + return nil + } + if target, aliasOK := r.ResolveAliasTarget(owner); aliasOK { + return target + } + return owner +} + +// LinkMetadataBodies gives every annotation body scope of the document the +// owner resolving the document would; afterwards resolving it writes nothing +// to the scope tree, so documents of one index can be resolved concurrently. +func (r *Resolver) LinkMetadataBodies(name string) { + rootScope := r.idx.DocumentRoot(name) + if rootScope == nil { + return + } + saved := r.document + r.document = name + defer func() { r.document = saved }() + r.linkMetadataBodies(rootScope) +} + +func (r *Resolver) linkMetadataBodies(scope *symbols.Scope) { + for _, child := range scope.Children() { + if prefix, ok := child.Node().(*ast.PrefixMetadata); ok && child.Owner() == nil { + if owner := r.metadataBodyOwner(r.bodyScope(scope, child.Annotated()), prefix); owner != nil { + child.SetOwner(owner) + } + } + r.linkMetadataBodies(child) + } +} + func (r *Resolver) resolveMetadataBody(scope *symbols.Scope, members []ast.Node) { for _, member := range members { decl, _ := unwrapForResolve(member) diff --git a/internal/core/resolve/link_metadata_test.go b/internal/core/resolve/link_metadata_test.go new file mode 100644 index 0000000000..2d9311e2fa --- /dev/null +++ b/internal/core/resolve/link_metadata_test.go @@ -0,0 +1,158 @@ +package resolve_test + +import ( + "reflect" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/core/parser" + "github.com/Open-MBEE/OpenSysML/internal/core/resolve" + "github.com/Open-MBEE/OpenSysML/internal/core/semantics" + "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +) + +// linkedMetadataBodies covers the ways an annotation body finds its owner: +// inside a definition, at the root, about an element, through an alias, nested +// in another body, and a type that does not resolve, whose body stays unowned. +const linkedMetadataBodies = `package P { + attribute def T { attribute b; } + metadata def M { attribute a : T; attribute n; } + alias N for M; + part def C { + @M { n = 1; a { b = 2; } } + @N { n = 3; } + @Missing { n = 4; } + } + @M about C { n = 5; } + @N { n = 6; } +}` + +// ownersOf renders the owner of every scope of the tree in tree order, "" for none. +func ownersOf(idx *symbols.Index, root *symbols.Scope) []string { + var out []string + var visit func(*symbols.Scope) + visit = func(s *symbols.Scope) { + owner := "" + if s.Owner() != nil { + owner = idx.GetFQN(s.Owner()) + } + out = append(out, owner) + for _, c := range s.Children() { + visit(c) + } + } + visit(root) + return out +} + +func indexedDoc(t *testing.T, name, src string) (*symbols.Index, *ast.RootNamespace, *resolve.Resolver) { + t.Helper() + p := parser.New(source.New(name, []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + idx := symbols.NewIndexFromDoc(name, root) + idx.ExpandWildcardImports() + r := resolve.New(idx) + r.SetModel(semantics.NewModel(r)) + return idx, root, r +} + +// Linking a document's metadata bodies sets exactly the owners resolving it +// sets, so resolving a linked document changes nothing in its scope tree and +// reports what it would have. +func TestLinkMetadataBodiesSetsWhatResolvingWould(t *testing.T) { + const name = "linked.sysml" + for _, src := range []string{linkedMetadataBodies, nestedMetadataBodies["nested.sysml"], nestedMetadataBodies["nested.kerml"]} { + idx, root, r := indexedDoc(t, name, src) + r.ResolveDocument(name, root) + want := ownersOf(idx, idx.DocumentRoot(name)) + + linkedIdx, linkedRoot, linker := indexedDoc(t, name, src) + linker.LinkMetadataBodies(name) + linked := ownersOf(linkedIdx, linkedIdx.DocumentRoot(name)) + if !reflect.DeepEqual(linked, want) { + t.Errorf("linked owners\n%q\nwant those resolving sets\n%q", linked, want) + } + resolver := resolve.New(linkedIdx) + resolver.SetModel(semantics.NewModel(resolver)) + resolver.ResolveDocument(name, linkedRoot) + if got := ownersOf(linkedIdx, linkedIdx.DocumentRoot(name)); !reflect.DeepEqual(got, linked) { + t.Errorf("resolving a linked document changed owners to\n%q\nfrom\n%q", got, linked) + } + if !reflect.DeepEqual(resolver.Diagnostics, r.Diagnostics) { + t.Errorf("diagnostics after linking %v, want %v", resolver.Diagnostics, r.Diagnostics) + } + } +} + +// A prefix carrying a body, which the AST allows though the parser writes +// bodies only on `@` usages, resolves its type from the annotated declaration's +// own scope; the linker does the same, so a type visible only there is found. +func TestLinkMetadataBodiesResolvesAPrefixBodyFromTheAnnotatedScope(t *testing.T) { + const name = "prefixed.sysml" + const src = `package P { + metadata def M { attribute n; } + part def C { alias Local for M; } + @M { n = 1; } +}` + build := func() (*symbols.Index, *ast.RootNamespace, *resolve.Resolver) { + p := parser.New(source.New(name, []byte(src))) + root := p.ParseFile() + pkg := root.Members[0].(*ast.Membership).Member.(*ast.Package) + c := pkg.Members[1].(*ast.Membership).Member.(*ast.Definition) + usage := pkg.Members[2].(*ast.Membership).Member.(*ast.PrefixMetadata) + prefix := &ast.PrefixMetadata{Body: usage.Body, HasBody: true, + Type: &ast.QualifiedName{Parts: []ast.NameSegment{{Text: "Local"}}}} + c.Prefixes = []*ast.PrefixMetadata{prefix} + pkg.Members = pkg.Members[:2] + idx := symbols.NewIndexFromDoc(name, root) + r := resolve.New(idx) + r.SetModel(semantics.NewModel(r)) + return idx, root, r + } + idx, root, r := build() + r.ResolveDocument(name, root) + want := ownersOf(idx, idx.DocumentRoot(name)) + pkgScope := idx.DocumentRoot(name).Children()[0] + c := pkgScope.Node().(*ast.Package).Members[1].(*ast.Membership).Member.(*ast.Definition) + body := pkgScope.ChildFor(c.Prefixes[0]) + if body == nil || body.Owner() == nil || idx.GetFQN(body.Owner()) != "P::M" { + t.Fatalf("resolving does not own the prefix body by P::M: %v", body) + } + linkedIdx, _, linker := build() + linker.LinkMetadataBodies(name) + if got := ownersOf(linkedIdx, linkedIdx.DocumentRoot(name)); !reflect.DeepEqual(got, want) { + t.Errorf("linked owners %q, want %q", got, want) + } +} + +// Linking reaches every annotation body of the document: only the one typed by a +// name that does not resolve is left unowned. +func TestLinkMetadataBodiesReachesEveryBody(t *testing.T) { + const name = "linked.sysml" + idx, _, linker := indexedDoc(t, name, linkedMetadataBodies) + linker.LinkMetadataBodies(name) + var bodies, owned int + var visit func(*symbols.Scope) + visit = func(s *symbols.Scope) { + if _, ok := s.Node().(*ast.PrefixMetadata); ok { + bodies++ + if s.Owner() != nil { + owned++ + if fqn := idx.GetFQN(s.Owner()); fqn != "P::M" && fqn != "P::M::a" { + t.Errorf("body owned by %s, want P::M or P::M::a", fqn) + } + } + } + for _, c := range s.Children() { + visit(c) + } + } + visit(idx.DocumentRoot(name)) + if bodies != 5 || owned != 4 { + t.Errorf("%d bodies, %d owned; want 5 and 4", bodies, owned) + } +} diff --git a/internal/core/symbols/builder.go b/internal/core/symbols/builder.go index 25c598ab62..be9ec4eeba 100644 --- a/internal/core/symbols/builder.go +++ b/internal/core/symbols/builder.go @@ -54,7 +54,7 @@ func unwrapMember(m ast.Node) (ast.Node, ast.Visibility) { // wrapper before unwrap. func buildDecl(scope *Scope, decl ast.Node, vis ast.Visibility, trivia []ast.Trivia) { if prefixes := prefixMetadataOf(decl); len(prefixes) > 0 { - buildMetadataBodyScopes(scope, prefixes) + buildMetadataBodyScopes(scope, decl, prefixes) } switch { case buildNamespaceDecl(scope, decl, vis, trivia): @@ -173,7 +173,7 @@ func buildBehaviorDecl(scope *Scope, decl ast.Node, vis ast.Visibility, trivia [ // error nodes have no declaration. Nothing to register here. return true case *ast.PrefixMetadata: - child := buildMetadataBodyScope(scope, d) + child := buildMetadataBodyScope(scope, nil, d) // An identification names the usage as a member of its namespace, exactly // as the `metadata` spelling of the same declaration does. if d.Ident.Name != "" || d.Ident.ShortName != "" { @@ -359,21 +359,25 @@ func prefixMetadataOf(decl ast.Node) []*ast.PrefixMetadata { return prefixes } -func buildMetadataBodyScopes(scope *Scope, prefixes []*ast.PrefixMetadata) { +// buildMetadataBodyScopes builds the body scopes of the annotations written on +// decl, a member of scope. +func buildMetadataBodyScopes(scope *Scope, decl ast.Node, prefixes []*ast.PrefixMetadata) { for _, prefix := range prefixes { if prefix != nil && len(prefix.Body) > 0 { - buildMetadataBodyScope(scope, prefix) + buildMetadataBodyScope(scope, decl, prefix) } } } // buildMetadataBodyScope builds the scope of a metadata usage's body and -// returns it, or nil when the usage has no body. -func buildMetadataBodyScope(parent *Scope, prefix *ast.PrefixMetadata) *Scope { +// returns it, or nil when the usage has no body. annotated is the declaration +// the usage is a prefix of, nil for a usage that is a member of its own. +func buildMetadataBodyScope(parent *Scope, annotated ast.Node, prefix *ast.PrefixMetadata) *Scope { if parent == nil || prefix == nil || len(prefix.Body) == 0 { return nil } child := NewScope(parent, prefix) + child.annotated = annotated child.markBodyLocal() parent.AddChild(child) buildMembers(child, prefix.Body) diff --git a/internal/core/symbols/index.go b/internal/core/symbols/index.go index 65f4b98acd..5b7f222840 100644 --- a/internal/core/symbols/index.go +++ b/internal/core/symbols/index.go @@ -344,19 +344,27 @@ func (idx *Index) mustBeWritable(op string) { // indexed are in: adding a document cannot know whether the target of an import // it states is still to come. func (idx *Index) AddDocument(name string, root *ast.RootNamespace) { - idx.addDocument(name, root, source.KindOf(name), false) + idx.addDocument(name, root, nil, source.KindOf(name), false) +} + +// AddBuiltDocument is AddDocument over a scope tree the caller already built +// from root with Build, so a batch can build its trees off the writer's path. +func (idx *Index) AddBuiltDocument(name string, root *ast.RootNamespace, rs *Scope) { + idx.addDocument(name, root, rs, source.KindOf(name), false) } // AddDocumentWithKind builds the scope tree for root and records its explicit // language, which is needed when the document name does not carry an extension. func (idx *Index) AddDocumentWithKind(name string, root *ast.RootNamespace, kind source.Kind) { - idx.addDocument(name, root, kind, true) + idx.addDocument(name, root, nil, kind, true) } -func (idx *Index) addDocument(name string, root *ast.RootNamespace, kind source.Kind, explicitKind bool) { +func (idx *Index) addDocument(name string, root *ast.RootNamespace, rs *Scope, kind source.Kind, explicitKind bool) { idx.mustBeWritable("AddDocument") idx.RemoveDocument(name) - rs := Build(root) + if rs == nil { + rs = Build(root) + } SetDocName(rs, name) idx.docRoots.set(name, rs) if explicitKind { diff --git a/internal/core/symbols/scope.go b/internal/core/symbols/scope.go index 8487a61dee..2662029fef 100644 --- a/internal/core/symbols/scope.go +++ b/internal/core/symbols/scope.go @@ -22,6 +22,7 @@ type Scope struct { children []*Scope childIndex atomic.Pointer[map[ast.Node]*Scope] // lazily built node -> child scope index for larger scopes bodyLocal bool // declarations live only inside the owning body + annotated ast.Node // for a metadata body, the declaration the annotation is written on docName string // document this scope tree belongs to (stamped by SetDocName) } @@ -68,6 +69,10 @@ func (s *Scope) BodyLocal() bool { return s.bodyLocal } // markBodyLocal records that this scope's names do not escape its body. func (s *Scope) markBodyLocal() { s.bodyLocal = true } +// Annotated returns, for the body scope of a prefix metadata annotation, the +// declaration the annotation is written on; nil for any other scope. +func (s *Scope) Annotated() ast.Node { return s.annotated } + // Children returns the child scopes in definition order. func (s *Scope) Children() []*Scope { return s.children } diff --git a/internal/repl/session.go b/internal/repl/session.go index 6320d3379a..3246ad40ff 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/Open-MBEE/OpenSysML/internal/core/analysis" + "github.com/Open-MBEE/OpenSysML/internal/core/ast" "github.com/Open-MBEE/OpenSysML/internal/core/engines" "github.com/Open-MBEE/OpenSysML/internal/core/lexer" "github.com/Open-MBEE/OpenSysML/internal/core/libs" @@ -372,15 +373,35 @@ func (s *Session) accept(origin, src string) { // A loaded file supersedes only itself and what the prompt said about the same // names, since several files of one model commonly open the same package. func (s *Session) acceptFrom(origin, src string) (declared []string, drops []dropReport) { - p := parser.New(source.New(parseDocName(origin), []byte(src))) - root := p.ParseFile() + return s.acceptParsed(origin, src, preparse(origin, src)) +} + +// parsed is what a submission's text parses to, taken before it is accepted so +// the files of one load can be parsed at once. +type parsed struct { + p *parser.Parser + root *ast.RootNamespace + closes bool +} + +// preparse parses src as the submission from origin, and probes whether it +// closes its own text. +func preparse(origin, src string) parsed { + doc := parseDocName(origin) + p := parser.New(source.New(doc, []byte(src))) + return parsed{p: p, root: p.ParseFile(), closes: closesItsOwnText(doc, src)} +} + +// acceptParsed is acceptFrom over a parse already taken. +func (s *Session) acceptParsed(origin, src string, pre parsed) (declared []string, drops []dropReport) { + p, root := pre.p, pre.root names := declaredNames(root) declared = names text := src // A submission that does not close its own text is masked out of the buffer // rather than left to absorb the submissions after it, and declares nothing: // what the parser recovered from it is not what was meant. - if !closesItsOwnText(parseDocName(origin), src) { + if !pre.closes { key := fileKeyOf(origin) if key != "" { // Re-reading the file supersedes what it declared before, which it no @@ -620,8 +641,9 @@ func (s *Session) transcript() (string, bool) { // openDocuments brings the workspace to the session's documents: the transcript, // and one document per loaded file that parses, gone when its snippet goes. func (s *Session) openDocuments() { + var inputs []model.Input if typed, found := s.transcript(); found { - s.ws.Open(docName, []byte(typed), s.version) + inputs = append(inputs, model.Input{Name: docName, Content: []byte(typed), Version: s.version}) } else { s.ws.Remove(docName) } @@ -632,7 +654,7 @@ func (s *Session) openDocuments() { } live[sn.origin] = true if doc := s.ws.Document(sn.origin); doc == nil || doc.Version != sn.gen { - s.ws.Open(sn.origin, []byte(sn.src), sn.gen) + inputs = append(inputs, model.Input{Name: sn.origin, Content: []byte(sn.src), Version: sn.gen}) } } for _, name := range s.ws.DocumentNames() { @@ -640,6 +662,8 @@ func (s *Session) openDocuments() { s.ws.Remove(name) } } + // One batch: the documents are parsed at once and the imports expanded once. + s.ws.OpenAll(inputs) } // text is the buffer as it was submitted, masking nothing: what %save writes @@ -696,7 +720,16 @@ func (s *Session) maskedSpans() []source.Span { // diagnostics reports the analysis of every session document and the syntax errors // of the masked submissions, each moved to where its text sits in the session buffer. func (s *Session) diagnostics() []passes.Diagnostic { - out := append([]passes.Diagnostic{}, s.ws.Diagnostics(docName)...) + names := []string{docName} + for _, sn := range s.snippets { + if sn.origin != "" && !sn.open { + names = append(names, sn.origin) + } + } + // One batch: the documents not analyzed yet are analyzed at once. + analyzed := s.ws.DiagnosticsAll(names) + out := append([]passes.Diagnostic{}, analyzed[0]...) + next := 1 acc := 0 for _, sn := range s.snippets { var own []passes.Diagnostic @@ -704,7 +737,8 @@ func (s *Session) diagnostics() []passes.Diagnostic { case sn.open: own = sn.diags case sn.origin != "": - own = s.ws.Diagnostics(sn.origin) + own = analyzed[next] + next++ } for _, d := range own { d.Span.Offset += acc @@ -816,8 +850,12 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, seen := map[string]bool{} s.version++ byFile = make([][]string, len(files)) + parses := make([]parsed, len(files)) + model.ParallelFor(s.ws.Workers(), len(files), func(i int) { + parses[i] = preparse(files[i].Name, files[i].Text) + }) for i, f := range files { - names, dropped := s.acceptFrom(f.Name, f.Text) + names, dropped := s.acceptParsed(f.Name, f.Text, parses[i]) for _, name := range names { if !seen[name] { seen[name] = true diff --git a/internal/repl/workers.go b/internal/repl/workers.go new file mode 100644 index 0000000000..26057d3542 --- /dev/null +++ b/internal/repl/workers.go @@ -0,0 +1,15 @@ +package repl + +// Workers returns how many files of one load the session parses and analyzes at once. +func (s *Session) Workers() int { + defer s.reading()() + return s.ws.Workers() +} + +// SetWorkers sets how many files of one load are parsed and analyzed at once from +// here on. What a load reports is the same at any count; a value below one is a +// typed error. +func (s *Session) SetWorkers(n int) error { + defer s.enter()() + return s.ws.SetWorkers(n) +} diff --git a/internal/stressmodel/satnet.go b/internal/stressmodel/satnet.go index dad0df147f..076c6f8b6f 100644 --- a/internal/stressmodel/satnet.go +++ b/internal/stressmodel/satnet.go @@ -84,9 +84,56 @@ func (n SatelliteNetwork) Source() (string, Stats) { return b.String(), stats } +// File is one document of a network split across files. +type File struct { + Name, Source string +} + +// Split generates the network as one document per orbital plane beside the +// library the satellites are built from and the constellation joining the +// planes: the same satellites and links as Generate writes, declared under +// a root package per file so each file is a unit of analysis of its own. +func (n SatelliteNetwork) Split() ([]File, Stats) { + g := &generator{} + var files []File + file := func(name string, write func()) { + var b strings.Builder + g.b = &b + write() + g.stats.Bytes += b.Len() + files = append(files, File{Name: name, Source: b.String()}) + } + file("library.sysml", func() { + g.library() + g.line(0, "}") + }) + for p := 0; p < n.Planes; p++ { + file(fmt.Sprintf("plane%03d.sysml", p), func() { + g.decl(0, "package Plane%d {", p) + g.imports("SatelliteNetwork::") + g.line(0, "") + g.plane(n, p) + g.line(0, "}") + }) + } + file("constellation.sysml", func() { + g.decl(0, "package Constellation {") + g.imports("SatelliteNetwork::") + for p := 0; p < n.Planes; p++ { + g.line(1, "private import Plane%d::*;", p) + } + g.groundSegment(n) + g.line(0, "}") + }) + return files, g.stats +} + type generator struct { b *strings.Builder stats Stats + // indent is the nesting every line is written at, one deeper for a + // constellation nested in the library's package. + indent int } // decl writes one declaration line at the given depth and counts it. @@ -96,10 +143,12 @@ func (g *generator) decl(depth int, format string, args ...any) { } func (g *generator) line(depth int, format string, args ...any) { - for i := 0; i < depth; i++ { - g.b.WriteString(" ") + if format != "" { + for i := 0; i < depth+g.indent; i++ { + g.b.WriteString(" ") + } + fmt.Fprintf(g.b, format, args...) } - fmt.Fprintf(g.b, format, args...) g.b.WriteByte('\n') } @@ -340,31 +389,55 @@ func (g *generator) library() { g.line(0, "") } -// constellation writes every satellite and ground station and the links between them. +// constellation writes every satellite and ground station and the links between +// them as one package nested in the library's. func (g *generator) constellation(n SatelliteNetwork) { - g.decl(1, "package Constellation {") - g.line(2, "private import Interfaces::*;") - g.line(2, "private import Platform::*;") - g.line(2, "private import Requirements::*;") - g.line(2, "private import Behavior::*;") - id := 0 + g.indent = 1 + g.decl(0, "package Constellation {") + g.imports("") for p := 0; p < n.Planes; p++ { - for s := 0; s < n.Satellites; s++ { - g.satellite(id, p, s) - id++ - } + g.plane(n, p) + } + g.groundSegment(n) + g.line(0, "}") + g.indent = 0 + g.line(0, "}") +} + +// imports writes what the satellites and the constellation resolve their names +// through, the library's packages reached from prefix. +func (g *generator) imports(prefix string) { + if prefix != "" { + g.line(1, "private import ScalarValues::*;") + g.line(1, "private import ISQ::*;") + g.line(1, "private import SI::*;") + } + for _, pkg := range []string{"Interfaces", "Platform", "Requirements", "Behavior"} { + g.line(1, "private import %s%s::*;", prefix, pkg) + } +} + +// plane writes the satellites of one orbital plane. +func (g *generator) plane(n SatelliteNetwork, p int) { + for s := 0; s < n.Satellites; s++ { + g.satellite(p*n.Satellites+s, p, s) } +} + +// groundSegment writes the ground stations and the network joining every +// satellite to its neighbours and to a station. +func (g *generator) groundSegment(n SatelliteNetwork) { for k := 0; k < n.GroundStations; k++ { g.groundStation(k) } g.line(0, "") - g.decl(2, "part def Network {") + g.decl(1, "part def Network {") total := n.Planes * n.Satellites for i := 0; i < total; i++ { - g.decl(3, "part sat%d : Sat%d;", i, i) + g.decl(2, "part sat%d : Sat%d;", i, i) } for k := 0; k < n.GroundStations; k++ { - g.decl(3, "part gs%d : Station%d;", k, k) + g.decl(2, "part gs%d : Station%d;", k, k) } for p := 0; p < n.Planes; p++ { for s := 0; s < n.Satellites; s++ { @@ -378,91 +451,89 @@ func (g *generator) constellation(n SatelliteNetwork) { if n.GroundStations > 0 { k := i % n.GroundStations g.stats.Connections++ - g.decl(3, "interface downlink%dTo%d : RFLink connect sat%d.comms.rf to gs%d.uplink {", k, i, i, k) - g.decl(4, "attribute :>> dataRate = %d.0;", 50+i%200) - g.decl(4, "attribute :>> slantRange = %d [km];", 900+i%1500) - g.line(3, "}") + g.decl(2, "interface downlink%dTo%d : RFLink connect sat%d.comms.rf to gs%d.uplink {", k, i, i, k) + g.decl(3, "attribute :>> dataRate = %d.0;", 50+i%200) + g.decl(3, "attribute :>> slantRange = %d [km];", 900+i%1500) + g.line(2, "}") } } } - g.decl(3, "attribute satelliteCount : Integer = %d;", total) - g.line(2, "}") - g.decl(2, "part network : Network;") + g.decl(2, "attribute satelliteCount : Integer = %d;", total) g.line(1, "}") - g.line(0, "}") + g.decl(1, "part network : Network;") } // link writes a crosslink between two satellites' crosslink terminals. func (g *generator) link(kind string, a, b int) { g.stats.Connections++ - g.decl(3, "interface %s%dTo%d : RFLink connect sat%d.comms.crosslinkTx to sat%d.comms.crosslinkRx {", kind, a, b, a, b) - g.decl(4, "attribute :>> dataRate = %d.0;", 100+(a+b)%400) - g.decl(4, "attribute :>> slantRange = %d [km];", 2000+(a*7+b*3)%3000) - g.line(3, "}") + g.decl(2, "interface %s%dTo%d : RFLink connect sat%d.comms.crosslinkTx to sat%d.comms.crosslinkRx {", kind, a, b, a, b) + g.decl(3, "attribute :>> dataRate = %d.0;", 100+(a+b)%400) + g.decl(3, "attribute :>> slantRange = %d [km];", 2000+(a*7+b*3)%3000) + g.line(2, "}") } // satellite writes one fully configured spacecraft definition and its requirements. func (g *generator) satellite(id, plane, slot int) { g.stats.Satellites++ - g.decl(2, "part def Sat%d :> Spacecraft {", id) - g.decl(3, "attribute :>> catalogId = %d;", 40000+id) - g.decl(3, "attribute :>> plane = %d;", plane) - g.decl(3, "attribute :>> slot = %d;", slot) + g.decl(1, "part def Sat%d :> Spacecraft {", id) + g.decl(2, "attribute :>> catalogId = %d;", 40000+id) + g.decl(2, "attribute :>> plane = %d;", plane) + g.decl(2, "attribute :>> slot = %d;", slot) var massTerms, powerTerms []string for _, s := range subsystems { massTerms = append(massTerms, s.name+".mass") powerTerms = append(powerTerms, s.name+".powerDraw") - g.decl(3, "part :>> %s {", s.name) + g.decl(2, "part :>> %s {", s.name) var subMass, subPower []string for j, c := range s.components { g.stats.Components++ subMass = append(subMass, c.name+".mass") subPower = append(subPower, c.name+".powerDraw") - g.decl(4, "part :>> %s {", c.name) - g.decl(5, "attribute :>> mass = %d.%d [kg];", 2+(id+j)%40, (id*3+j)%10) - g.decl(5, "attribute :>> powerDraw = %d.0 [W];", 5+(id*5+j*7)%50) - g.decl(5, "attribute :>> serialNumber = \"%s-%05d-%d\";", strings.ToUpper(c.name), id, j) + g.decl(3, "part :>> %s {", c.name) + g.decl(4, "attribute :>> mass = %d.%d [kg];", 2+(id+j)%40, (id*3+j)%10) + g.decl(4, "attribute :>> powerDraw = %d.0 [W];", 5+(id*5+j*7)%50) + g.decl(4, "attribute :>> serialNumber = \"%s-%05d-%d\";", strings.ToUpper(c.name), id, j) g.componentDetail(c.def, id, j) - g.line(4, "}") + g.line(3, "}") } - g.decl(4, "attribute :>> mass = %s;", strings.Join(subMass, " + ")) - g.decl(4, "attribute :>> powerDraw = %s;", strings.Join(subPower, " + ")) - g.line(3, "}") + g.decl(3, "attribute :>> mass = %s;", strings.Join(subMass, " + ")) + g.decl(3, "attribute :>> powerDraw = %s;", strings.Join(subPower, " + ")) + g.line(2, "}") } - g.decl(3, "attribute :>> dryMass = %s;", strings.Join(massTerms, " + ")) - g.decl(3, "attribute :>> totalPowerDraw = %s;", strings.Join(powerTerms, " + ")) + g.decl(2, "attribute :>> dryMass = %s;", strings.Join(massTerms, " + ")) + g.decl(2, "attribute :>> totalPowerDraw = %s;", strings.Join(powerTerms, " + ")) for _, s := range subsystems { if s.name == "eps" { continue } g.stats.Connections += 2 - g.decl(3, "interface powerTo%s : PowerFeed connect eps.supply to %s.power {", capitalize(s.name), s.name) - g.decl(4, "attribute :>> busVoltage = 28 [V];") - g.line(3, "}") + g.decl(2, "interface powerTo%s : PowerFeed connect eps.supply to %s.power {", capitalize(s.name), s.name) + g.decl(3, "attribute :>> busVoltage = 28 [V];") + g.line(2, "}") } busIndex := 1 for _, s := range subsystems { if s.name == "cdh" { continue } - g.decl(3, "interface busTo%s : DataBusLink connect cdh.obc.bus%d to %s.bus;", capitalize(s.name), busIndex, s.name) + g.decl(2, "interface busTo%s : DataBusLink connect cdh.obc.bus%d to %s.bus;", capitalize(s.name), busIndex, s.name) busIndex++ } g.stats.Connections++ - g.decl(3, "interface payloadToCdh : DataFeed connect payload.dataOut to cdh.dataIn;") - g.decl(3, "constraint massMargin { dryMass <= %d [kg] }", 500+id%100) - g.line(2, "}") - g.decl(2, "part sat%dConfig : Sat%d;", id, id) + g.decl(2, "interface payloadToCdh : DataFeed connect payload.dataOut to cdh.dataIn;") + g.decl(2, "constraint massMargin { dryMass <= %d [kg] }", 500+id%100) + g.line(1, "}") + g.decl(1, "part sat%dConfig : Sat%d;", id, id) g.stats.Requirements += 3 - g.decl(2, "requirement sat%dMass : MassBudget { subject :>> sc = sat%dConfig; attribute :>> limit = %d [kg]; }", id, id, 900+id%100) + g.decl(1, "requirement sat%dMass : MassBudget { subject :>> sc = sat%dConfig; attribute :>> limit = %d [kg]; }", id, id, 900+id%100) g.stats.Elements += 2 - g.decl(2, "satisfy sat%dMass by sat%dConfig;", id, id) - g.decl(2, "requirement sat%dPower : PowerBudget { subject :>> sc = sat%dConfig; }", id, id) + g.decl(1, "satisfy sat%dMass by sat%dConfig;", id, id) + g.decl(1, "requirement sat%dPower : PowerBudget { subject :>> sc = sat%dConfig; }", id, id) g.stats.Elements++ - g.decl(2, "satisfy sat%dPower by sat%dConfig;", id, id) - g.decl(2, "requirement sat%dCrosslink : CrosslinkCapacity { subject :>> sc = sat%dConfig; attribute :>> minimumRate = %d.0; }", id, id, 50+id%50) + g.decl(1, "satisfy sat%dPower by sat%dConfig;", id, id) + g.decl(1, "requirement sat%dCrosslink : CrosslinkCapacity { subject :>> sc = sat%dConfig; attribute :>> minimumRate = %d.0; }", id, id, 50+id%50) g.stats.Elements += 2 - g.decl(2, "satisfy sat%dCrosslink by sat%dConfig;", id, id) + g.decl(1, "satisfy sat%dCrosslink by sat%dConfig;", id, id) g.line(0, "") } @@ -470,71 +541,71 @@ func (g *generator) satellite(id, plane, slot int) { func (g *generator) componentDetail(def string, id, j int) { switch def { case "SolarArray": - g.decl(5, "attribute :>> area = %d.%d ['m²'];", 4+id%6, id%10) - g.decl(5, "attribute :>> generated = %d.0 [W];", 1200+(id*13)%700) + g.decl(4, "attribute :>> area = %d.%d ['m²'];", 4+id%6, id%10) + g.decl(4, "attribute :>> generated = %d.0 [W];", 1200+(id*13)%700) case "Battery": - g.decl(5, "attribute :>> capacity = %d.0 [J];", 3600000+(id*17)%7200000) - g.decl(5, "attribute :>> depthOfDischarge = 0.%d;", 2+id%5) + g.decl(4, "attribute :>> capacity = %d.0 [J];", 3600000+(id*17)%7200000) + g.decl(4, "attribute :>> depthOfDischarge = 0.%d;", 2+id%5) case "PowerConditioner": - g.decl(5, "attribute :>> efficiency = 0.9%d;", id%10) + g.decl(4, "attribute :>> efficiency = 0.9%d;", id%10) case "StarTracker": - g.decl(5, "attribute :>> accuracy = %d.0 [arcsec];", 1+id%5) - g.decl(5, "attribute :>> updateRate = %d.0 [Hz];", 2+id%8) + g.decl(4, "attribute :>> accuracy = %d.0 [arcsec];", 1+id%5) + g.decl(4, "attribute :>> updateRate = %d.0 [Hz];", 2+id%8) case "InertialMeasurementUnit": - g.decl(5, "attribute :>> driftRate = 0.0%d;", 1+id%9) + g.decl(4, "attribute :>> driftRate = 0.0%d;", 1+id%9) case "ReactionWheel": - g.decl(5, "attribute :>> maxTorque = 0.%d ['N⋅m'];", 1+(id+j)%9) - g.decl(5, "attribute :>> momentumCapacity = %d.0;", 10+(id+j)%40) + g.decl(4, "attribute :>> maxTorque = 0.%d ['N⋅m'];", 1+(id+j)%9) + g.decl(4, "attribute :>> momentumCapacity = %d.0;", 10+(id+j)%40) case "OnboardComputer": - g.decl(5, "attribute :>> clockRate = %d.0 [Hz];", 200000000+(id*11)%600000000) - g.decl(5, "attribute :>> memoryBytes = %d;", (1+id%8)*1073741824) + g.decl(4, "attribute :>> clockRate = %d.0 [Hz];", 200000000+(id*11)%600000000) + g.decl(4, "attribute :>> memoryBytes = %d;", (1+id%8)*1073741824) case "MassMemory": - g.decl(5, "attribute :>> capacityBytes = %d;", (16+id%48)*1073741824) + g.decl(4, "attribute :>> capacityBytes = %d;", (16+id%48)*1073741824) case "Transponder": - g.decl(5, "attribute :>> frequency = %d.0 [Hz];", 8000000000+(id*7)%400000000) - g.decl(5, "attribute :>> transmitPower = %d.0 [W];", 10+id%40) + g.decl(4, "attribute :>> frequency = %d.0 [Hz];", 8000000000+(id*7)%400000000) + g.decl(4, "attribute :>> transmitPower = %d.0 [W];", 10+id%40) case "CrosslinkTerminal": - g.decl(5, "attribute :>> wavelength = 1550 [nm];") - g.decl(5, "attribute :>> dataRate = %d.0;", 100+(id*3)%400) + g.decl(4, "attribute :>> wavelength = 1550 [nm];") + g.decl(4, "attribute :>> dataRate = %d.0;", 100+(id*3)%400) case "Antenna": - g.decl(5, "attribute :>> gain = %d.%d;", 20+id%20, id%10) - g.decl(5, "attribute :>> diameter = 0.%d [m];", 3+id%6) + g.decl(4, "attribute :>> gain = %d.%d;", 20+id%20, id%10) + g.decl(4, "attribute :>> diameter = 0.%d [m];", 3+id%6) case "PropellantTank": - g.decl(5, "attribute :>> propellantMass = %d.0 [kg];", 30+(id*5)%100) - g.decl(5, "attribute :>> volume = 0.%d ['m³'];", 1+id%5) + g.decl(4, "attribute :>> propellantMass = %d.0 [kg];", 30+(id*5)%100) + g.decl(4, "attribute :>> volume = 0.%d ['m³'];", 1+id%5) case "Thruster": - g.decl(5, "attribute :>> thrust = %d.0 [mN];", 20+id%200) - g.decl(5, "attribute :>> specificImpulse = %d.0 [s];", 1200+(id*19)%800) + g.decl(4, "attribute :>> thrust = %d.0 [mN];", 20+id%200) + g.decl(4, "attribute :>> specificImpulse = %d.0 [s];", 1200+(id*19)%800) case "Radiator": - g.decl(5, "attribute :>> area = 1.%d ['m²'];", id%10) - g.decl(5, "attribute :>> emissivity = 0.8%d;", id%10) + g.decl(4, "attribute :>> area = 1.%d ['m²'];", id%10) + g.decl(4, "attribute :>> emissivity = 0.8%d;", id%10) case "Heater": - g.decl(5, "attribute :>> setpoint = %d.0 [K];", 283+id%20) + g.decl(4, "attribute :>> setpoint = %d.0 [K];", 283+id%20) case "ImagingSensor": - g.decl(5, "attribute :>> groundSampleDistance = 0.%d [m];", 3+id%7) - g.decl(5, "attribute :>> swath = %d.0 [km];", 10+id%30) + g.decl(4, "attribute :>> groundSampleDistance = 0.%d [m];", 3+id%7) + g.decl(4, "attribute :>> swath = %d.0 [km];", 10+id%30) case "PayloadProcessor": - g.decl(5, "attribute :>> throughput = %d.0;", 100+(id*23)%900) + g.decl(4, "attribute :>> throughput = %d.0;", 100+(id*23)%900) } } // groundStation writes one ground station definition with its as-built values. func (g *generator) groundStation(k int) { g.stats.GroundStations++ - g.decl(2, "part def Station%d :> GroundStation {", k) - g.decl(3, "attribute :>> stationId = %d;", k) - g.decl(3, "attribute :>> latitude = %d.%d;", -60+(k*37)%120, k%10) - g.decl(3, "attribute :>> longitude = %d.%d;", -180+(k*53)%360, k%10) + g.decl(1, "part def Station%d :> GroundStation {", k) + g.decl(2, "attribute :>> stationId = %d;", k) + g.decl(2, "attribute :>> latitude = %d.%d;", -60+(k*37)%120, k%10) + g.decl(2, "attribute :>> longitude = %d.%d;", -180+(k*53)%360, k%10) for j, c := range stationComponents { g.stats.Components++ - g.decl(3, "part :>> %s {", c.name) - g.decl(4, "attribute :>> mass = %d.0 [kg];", 50+(k*7+j*11)%900) - g.decl(4, "attribute :>> powerDraw = %d.0 [W];", 100+(k*13+j*17)%2000) - g.decl(4, "attribute :>> serialNumber = \"GS-%s-%03d\";", strings.ToUpper(c.name), k) + g.decl(2, "part :>> %s {", c.name) + g.decl(3, "attribute :>> mass = %d.0 [kg];", 50+(k*7+j*11)%900) + g.decl(3, "attribute :>> powerDraw = %d.0 [W];", 100+(k*13+j*17)%2000) + g.decl(3, "attribute :>> serialNumber = \"GS-%s-%03d\";", strings.ToUpper(c.name), k) g.componentDetail(c.def, k, j) - g.line(3, "}") + g.line(2, "}") } - g.line(2, "}") + g.line(1, "}") g.line(0, "") } diff --git a/internal/usage/environment.go b/internal/usage/environment.go index c6d95d7163..5773a43758 100644 --- a/internal/usage/environment.go +++ b/internal/usage/environment.go @@ -23,6 +23,14 @@ func JobsEnvironment() []Item { } } +// WorkersEnvironment describes the setting the sysml command reads for how many +// files of one load are parsed and analyzed at once. +func WorkersEnvironment() []Item { + return []Item{ + {"OPENSYSML_WORKERS", "Files of one load that are parsed and analyzed at once, each on a worker of its own over the shared index; -workers overrides it. The diagnostics are the same at any count. Default the number of CPUs."}, + } +} + // LegacyPrefixNote states how the superseded variable names are still read, and // belongs with any list of them. const LegacyPrefixNote = "Each variable above also answers to its legacy " + diff --git a/man/man1/sysml.1 b/man/man1/sysml.1 index 6c08533384..52adaf5574 100644 --- a/man/man1/sysml.1 +++ b/man/man1/sysml.1 @@ -360,6 +360,11 @@ path such as car.engine (repeatable). The default is []. .TP .B \-version Show version information +.TP +.BI \-workers " value" +Files of one load that are parsed and analyzed at once, each on a worker of +its own over the shared index; the diagnostics are the same at any count. +Default OPENSYSML_WORKERS, else the number of CPUs .SH EXAMPLES .RS 2 .nf @@ -748,6 +753,11 @@ exploration, the engines \-engine all consults \(em each on a worker of its own over the shared model; \-jobs and %jobs override it. Default the number of CPUs. .TP +.B OPENSYSML_WORKERS +Files of one load that are parsed and analyzed at once, each on a worker of +its own over the shared index; \-workers overrides it. The diagnostics are the +same at any count. Default the number of CPUs. +.TP .B OPENSYSML_TOOLS Directory of the tool manifest: one JSON file per external tool (toolName, version, executable, variables), each registered as the engine tool: From 6b436cc8780007eb708f97df1b0d1b7fb8a33e35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:31:10 +0000 Subject: [PATCH 05/53] test(stressmodel): validate the split network at one worker and several, and benchmark it -workers and OPENSYSML_WORKERS are checked from the command line over a model of several files, and BenchmarkValidateSplit loads the network split by plane on one worker and on one per CPU. Co-Authored-By: jason.han --- cmd/sysml/load_test.go | 7 +++ cmd/sysml/workers_test.go | 70 +++++++++++++++++++++++++++++ internal/stressmodel/bench_test.go | 29 ++++++++++++ internal/stressmodel/satnet_test.go | 48 ++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 cmd/sysml/workers_test.go diff --git a/cmd/sysml/load_test.go b/cmd/sysml/load_test.go index edd3d2f497..6858666c31 100644 --- a/cmd/sysml/load_test.go +++ b/cmd/sysml/load_test.go @@ -116,8 +116,15 @@ func TestCheckGlobExitStatus(t *testing.T) { // checkPaths runs the binary on paths the caller names, rather than on a model // written to a file for it as check does. func checkPaths(t *testing.T, binary string, args ...string) runOutcome { + t.Helper() + return checkPathsEnv(t, binary, nil, args...) +} + +// checkPathsEnv is checkPaths with variables added to the binary's environment. +func checkPathsEnv(t *testing.T, binary string, env []string, args ...string) runOutcome { t.Helper() cmd := exec.Command(binary, args...) + cmd.Env = append(os.Environ(), env...) var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() diff --git a/cmd/sysml/workers_test.go b/cmd/sysml/workers_test.go new file mode 100644 index 0000000000..82c1430f58 --- /dev/null +++ b/cmd/sysml/workers_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A model of several files whose validation crosses them: a metadata body to +// link, a reference into another file, and an error to report in a third. +var workersModel = map[string]string{ + "a.sysml": "package A {\n\tmetadata def M { attribute n; }\n\tpart def X { @M { n = 1; } }\n}\n", + "b.sysml": "package B { part x : A::X; part y : Missing; }\n", + "c.sysml": "package C { part z : A::X { @A::M { n = 2; } } }\n", +} + +func writeWorkersModel(t *testing.T) []string { + t.Helper() + dir := t.TempDir() + paths := make([]string, 0, len(workersModel)) + for _, name := range []string{"a.sysml", "b.sysml", "c.sysml"} { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(workersModel[name]), 0o644); err != nil { + t.Fatal(err) + } + paths = append(paths, path) + } + return paths +} + +// TestWorkersFlagAndEnvironment checks that -workers and OPENSYSML_WORKERS take a +// positive integer, that the flag wins over the variable, that either rejected +// at startup loads nothing, and that the count does not change what -validate +// reports over a model of several files. +func TestWorkersFlagAndEnvironment(t *testing.T) { + binary := buildCLI(t) + paths := writeWorkersModel(t) + validate := func(env []string, args ...string) runOutcome { + return checkPathsEnv(t, binary, env, append(append([]string{"-validate"}, args...), paths...)...) + } + want := validate(nil) + if want.status != 2 || !strings.Contains(want.output(), "unresolved reference: Missing") { + t.Fatalf("the default run should report b.sysml's unresolved name and exit 2, got %d:\n%s", want.status, want.output()) + } + + for _, workers := range []string{"1", "2", "8"} { + if got := validate(nil, "-workers", workers); got.status != want.status || got.output() != want.output() { + t.Errorf("-workers %s reported %d\n%s\nwant the default's %d\n%s", workers, got.status, got.output(), want.status, want.output()) + } + if got := validate([]string{"OPENSYSML_WORKERS=" + workers}); got.status != want.status || got.output() != want.output() { + t.Errorf("OPENSYSML_WORKERS=%s reported %d\n%s\nwant the default's %d\n%s", workers, got.status, got.output(), want.status, want.output()) + } + } + + for _, bad := range []string{"0", "-3", "two", "1.5"} { + got := validate(nil, "-workers", bad) + if got.status != 2 || !strings.Contains(got.output(), `-workers="`+bad+`" is not a positive integer`) || strings.Contains(got.output(), "Missing") { + t.Errorf("-workers %s: status %d\n%s", bad, got.status, got.output()) + } + got = validate([]string{"OPENSYSML_WORKERS=" + bad}) + if got.status != 2 || !strings.Contains(got.output(), `OPENSYSML_WORKERS="`+bad+`" is not a positive integer`) || strings.Contains(got.output(), "Missing") { + t.Errorf("OPENSYSML_WORKERS=%s: status %d\n%s", bad, got.status, got.output()) + } + } + + if got := validate([]string{"OPENSYSML_WORKERS=nope"}, "-workers", "2"); got.status != want.status || got.output() != want.output() { + t.Errorf("-workers 2 under OPENSYSML_WORKERS=nope reported %d\n%s\nwant the default's\n%s", got.status, got.output(), want.output()) + } +} diff --git a/internal/stressmodel/bench_test.go b/internal/stressmodel/bench_test.go index 44dacd05e5..26bc9207f4 100644 --- a/internal/stressmodel/bench_test.go +++ b/internal/stressmodel/bench_test.go @@ -114,3 +114,32 @@ func BenchmarkEditBeside(b *testing.B) { }) } } + +// BenchmarkValidateSplit measures loading a network split one file per plane, as +// the command line does: the files parsed and analyzed on one worker and on one +// per CPU, over one shared index. +func BenchmarkValidateSplit(b *testing.B) { + for _, n := range networkSizes { + files, stats := splitFiles(network(n)) + for _, workers := range []int{1, runtime.GOMAXPROCS(0)} { + b.Run(fmt.Sprintf("satellites=%d/files=%d/workers=%d", stats.Satellites, len(files), workers), func(b *testing.B) { + load := func() { + sess := repl.NewSession() + if err := sess.SetWorkers(workers); err != nil { + b.Fatal(err) + } + sess.SubmitFiles(files) + if sess.HasErrors() { + b.Fatalf("the split network did not analyse cleanly:\n%s", strings.Join(sess.DiagnosticLines(), "\n")) + } + } + load() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + load() + } + }) + } + } +} diff --git a/internal/stressmodel/satnet_test.go b/internal/stressmodel/satnet_test.go index c6e39bb86f..7bdf9e44f4 100644 --- a/internal/stressmodel/satnet_test.go +++ b/internal/stressmodel/satnet_test.go @@ -59,3 +59,51 @@ func TestSatelliteNetworkScales(t *testing.T) { t.Errorf("first satellite definition missing from:\n%s", one) } } + +// splitFiles is a network split by plane as the CLI loads it, one source per file. +func splitFiles(n SatelliteNetwork) ([]repl.SourceFile, Stats) { + files, stats := n.Split() + srcs := make([]repl.SourceFile, len(files)) + for i, f := range files { + srcs[i] = repl.SourceFile{Name: f.Name, Text: f.Source} + } + return srcs, stats +} + +// TestSatelliteNetworkSplitValidates keeps the split in step with the single +// file: it declares the same network, loads clean under strict conformance at +// one worker and at several, and every satisfy assertion holds across files. +func TestSatelliteNetworkSplitValidates(t *testing.T) { + n := SatelliteNetwork{Planes: 2, Satellites: 2, GroundStations: 1} + _, whole := n.Source() + files, stats := splitFiles(n) + if len(files) != n.Planes+2 { + t.Fatalf("got %d files, want one per plane beside the library and the constellation", len(files)) + } + // The split declares one package per plane over the single file's elements. + whole.Bytes, stats.Bytes = 0, 0 + whole.Elements += n.Planes + if stats != whole { + t.Errorf("split declares %+v, the single file %+v", stats, whole) + } + + for _, workers := range []int{1, 4} { + s := repl.NewSession() + s.SetConformanceMode(conformance.ModeOf(true)) + if err := s.SetWorkers(workers); err != nil { + t.Fatal(err) + } + for _, d := range s.SubmitFiles(files).Diagnostics { + t.Errorf("workers=%d: diagnostic: %s", workers, d.Message) + } + verdicts := s.CheckSatisfy("") + if len(verdicts) != stats.Requirements { + t.Fatalf("workers=%d: got %d satisfy verdicts, want %d", workers, len(verdicts), stats.Requirements) + } + for _, v := range verdicts { + if !v.Holds() { + t.Errorf("workers=%d: %s: %v", workers, v.Subject, v.Lines) + } + } + } +} From 06ac0153f6b499f22f2d67129f608afe1dc7e21a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:35:49 +0000 Subject: [PATCH 06/53] refactor(passes): link a batch's metadata bodies with a resolver alone Co-Authored-By: jason.han --- internal/core/passes/analyze.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index 270e9b37fe..9aa0a5d716 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -4,6 +4,7 @@ import ( "sort" "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/core/resolve" "github.com/Open-MBEE/OpenSysML/internal/core/source" "github.com/Open-MBEE/OpenSysML/internal/core/symbols" ) @@ -123,10 +124,9 @@ func PrepareBatch(idx *symbols.Index, batch *Batch) { if idx == nil || batch == nil { return } - linker := NewContext("", idx, nil) - _ = linker.Model() + linker := resolve.New(idx) for _, name := range batch.Documents { - linker.Resolver().LinkMetadataBodies(name) + linker.LinkMetadataBodies(name) } } From 038bc1abec647aab3b57a54312a3dffcdf897595 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:02:42 +0000 Subject: [PATCH 07/53] docs(performance): measure the split constellation on the worker pool, and the gather it parallelizes Records the 200- and 1 600-satellite splits at one, two, four and eight workers beside the single file, the CPU and heap profiles that put the split's cost in the three workspace-wide audits, the allocation sites the pool does not help, and a benchmark of the per-document analysis alone. Co-Authored-By: jason.han --- README.md | 2 +- .../parallel-batch-validation.performance.md | 1 + docs/internals/performance.md | 83 ++++++++++++++++ docs/project/satellite-network-stress-test.md | 96 ++++++++++++++++++- docs/project/spec-compliance.md | 2 +- internal/stressmodel/bench_test.go | 65 ++++++++++++- 6 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 changes/unreleased/parallel-batch-validation.performance.md diff --git a/README.md b/README.md index 3f91e4245a..b72e2aece6 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,373 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 917 conformance cases, 235 golden traces, 459 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,382 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 917 conformance cases, 235 golden traces, 459 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (917/917 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/changes/unreleased/parallel-batch-validation.performance.md b/changes/unreleased/parallel-batch-validation.performance.md new file mode 100644 index 0000000000..14e65ba1d1 --- /dev/null +++ b/changes/unreleased/parallel-batch-validation.performance.md @@ -0,0 +1 @@ +- **A batch of files is parsed and analysed on a pool of workers.** `sysml -validate`, `-satisfy` and `%load` open the files they are given as one batch: the files are parsed on one worker per CPU, indexed once, wildcard imports are expanded once for the batch instead of once per file, and the documents are analysed in parallel, each with a resolver and semantic model of its own over an index nothing writes meanwhile. The diagnostics are the same at any worker count, in command-line order; `-workers N` or `OPENSYSML_WORKERS` set the pool. On an eight-CPU machine the 1 600-satellite stress constellation split over 34 files validates in 30.9 s against 129 s on one worker, and a 200-satellite split in 1.52 s against 5.35 s; a single file is unaffected. `cmd/stress-model -split-planes ` writes the constellation one file per orbital plane, and `docs/project/satellite-network-stress-test.md` records where the split model's remaining time goes: three passes that gather the whole workspace once per document analysed. diff --git a/docs/internals/performance.md b/docs/internals/performance.md index c863234e2b..eb745556d1 100644 --- a/docs/internals/performance.md +++ b/docs/internals/performance.md @@ -311,6 +311,89 @@ so the win is collector pressure rather than bytes: Diagnostics and exit status were verified byte-identical against the previous binary over the same models. +### What a batch of files costs + +`sysml -validate a.sysml b.sysml …`, `-satisfy` and `%load` open the files as +one batch of workspace documents (`model.(*Workspace).OpenAll`): the files are +parsed and their scope trees built on a pool of workers, installed in the +shared index one after another, wildcard imports are expanded once for the +batch, and the documents are analyzed on the pool +(`model.(*Workspace).DiagnosticsAll`), each in a `passes.Context` of its own +with a private resolver and semantic model, over an index nothing writes while +the pool runs. What resolving a document would otherwise link into the scope +tree on first use — the owner of a metadata body — is linked for every document +of the batch before the pool starts (`passes.PrepareBatch`), so the workers +only read it. Diagnostics come back in the order the files were given and are +the same at any worker count; `-workers` and `OPENSYSML_WORKERS` set the pool, +default one worker per CPU. The earlier cost of indexing files one at a time — +re-expanding wildcard imports over every document loaded so far, quadratic in +the file count — is gone with it: the 34-file constellation below parses and +indexes in 1.4 s. + +Measured on the satellite constellation split one file per orbital plane +(`cmd/stress-model -split-planes`; `Intel Xeon Platinum 8559C`, 8 CPUs, 31 GiB, +Go 1.25.0, one run each, `/usr/bin/time -v`): + +| model | files | workers | wall | CPU | peak RSS | +| ----- | ----- | ------- | ---- | --- | -------- | +| 1 600 satellites, one file | 1 | — | 18.5 s | 136% | 2.46 GB | +| 1 600 satellites, split | 34 | 1 | 129 s | 135% | 1.98 GB | +| | | 2 | 66.1 s | 269% | 2.60 GB | +| | | 4 | 38.6 s | 481% | 3.94 GB | +| | | 8 | 30.9 s | 658% | 7.24 GB | +| 200 satellites, split | 10 | 1 | 5.35 s | 132% | 354 MB | +| | | 8 | 1.52 s | 529% | 840 MB | + +The single file is unchanged (18.5 s here against 18.7 s for the previous +binary on the same run, 2.46 GB against 2.54 GB). The pool gives 4.2× on eight +workers; what it is parallelizing is mostly a cost the split introduced. Three +passes judge a document against the whole workspace — `OOSEMMethodPass`, +`IdentityMetadataPass` and `MOSAPass` gather every document's roots, resolving +`semantics.(*Model).FeatureTypeSet` for every symbol they meet — and each of the +34 analyses gathers afresh in its own model, so the gather is done 34 times +over 34 documents. In the eight-worker CPU profile the three are 75% of 201 s +of samples (112 s, 25 s and 15 s); on one worker they are 118 s of a 128 s +analysis; the documents' own resolution, type checking and remaining passes are +about 10 s in either. The per-worker tables that gather builds are also why +peak RSS grows with the workers — eight workers hold eight workspace-wide +memoizations, live, so `GOMEMLIMIT` cannot reclaim them (`GOMEMLIMIT=2500MiB` +still peaks at 3.51 GB and takes 66 s). Gathering once per batch and handing +the result to every context is the fix; it belongs with the per-document +gather cache of the persistent-workspace design +([scaling to very large models](../project/large-model-scaling-design.md)), +and `passes.Context.Batch` is where a worker receives it. Until then, the +pool's own speedup is measured by `BenchmarkAnalyzeSplitPerDocument` in +`internal/stressmodel`, which analyzes the split's files over one index with +the three passes left out: 3.64 s → 1.04 s at 512 satellites over six files, +one worker against eight, the largest file bounding it. The whole load of the +same split, audits included, is `BenchmarkValidateSplit`: 10.3 s → 2.77 s. + +Parallelism does not reduce what a load allocates — the 34-file run allocates +39.1 GiB and 422 million objects at any worker count — and the collector +marking eight workers' garbage at once is where the pool loses its remaining +efficiency (`runtime.gcBgMarkWorker` 13% and `runtime.scanobject` 18% of the +eight-worker samples; user time 172 s → 197 s). The allocation sites the +pool does not help, from the heap profile of the single-file 1 600-satellite +run (62 million sampled objects and 4.3 GiB, of a run that counts 85.5 million +allocations and 5.4 GiB), by objects allocated: + +| share of objects | site | what allocates | +| ---------------- | ---- | -------------- | +| 12.8% | `passes.(*w9cConflictChecker).specializes` | a slice per conformance question of the inherited-name conflict pass | +| 12.6% | `passes.contributionsOf` | the per-base member list the same pass compares | +| 9.8% | `symbols.FQNOf` (via `strings.Builder`) | a fully-qualified name built as a string, 78% of it from `symbols.(*Index).GetFQN`, 18% from the conflict pass | +| 7.3% | `resolve.(*Resolver).specializationChain` | a slice per walk of a type's generalizations | +| 3.5% | `semantics.(*Model).AllSupertypes` | a slice per supertype closure | +| 2.6% | `parser.(*Parser).parseQualifiedNameRelaxed` | a qualified-name node per reference | +| 1.9% | `parser.(*Parser).parseBase` | a node per specialization clause | + +By bytes the parser leads — `parseUsage` and what it calls are 25% of the 5.4 +GiB, `parseQualifiedNameRelaxed` alone 5% — with `contributionsOf` (6.6%), +`specializes` (4.9%) and `FQNOf` (4.4%) behind it. Each of these is one +allocation per token, per name or per lookup where one per file, or none, would +serve — the snapshot decoder's node table, allocated as one block, is the +model — and each is to be measured on its own before it is changed. + ## What a process pays before the model Every `sysml`, `sysml-lsp` and `sysml-grpc` start, and every test that builds a diff --git a/docs/project/satellite-network-stress-test.md b/docs/project/satellite-network-stress-test.md index 66ef84232f..9a54aa8b7a 100644 --- a/docs/project/satellite-network-stress-test.md +++ b/docs/project/satellite-network-stress-test.md @@ -127,6 +127,90 @@ report for the synthetic model there, because most of their elements are attribute redefinitions with a literal value rather than definitions with bodies of their own. +### Split by plane, parallel + +A project of this size is not one file. `cmd/stress-model -split-planes ` +writes the same constellation as one `.sysml` per orbital plane plus +`library.sysml` (the definitions every plane shares) and `constellation.sysml` +(the ground segment and the cross-plane network); the split declares the same +network and analyzes to the same diagnostics as the single file +(`TestSatelliteNetworkSplitValidates`). `sysml -validate` over the files parses +them on a pool of workers, indexes them once, expands wildcard imports once +and analyzes them on the pool, each document with a resolver and semantic +model of its own; `-workers N` (or `OPENSYSML_WORKERS`) sets the pool, default +one worker per CPU. The diagnostics are the same at any worker count, in +command-line order. + +```bash +go run ./cmd/stress-model -planes 32 -satellites 50 -ground-stations 160 -split-planes constellation/ +/usr/bin/time -v sysml -validate -memstats -workers 8 constellation/*.sysml +``` + +Same machine as above (`Intel Xeon Platinum 8559C`, 8 CPUs, 31 GiB, Go +1.25.0, Linux); one run per row; *CPU* is `(user + system) / wall`. + +| model | files | workers | wall | user | CPU | allocated | peak RSS | +| ----- | ----- | ------- | ---- | ---- | --- | --------- | -------- | +| 200 satellites, one file | 1 | — | 1.95 s | 2.4 s | 130% | 721 MiB | 393 MB | +| 200 satellites, split | 10 | 1 | 5.35 s | 6.9 s | 132% | 1.9 GiB | 354 MB | +| | | 2 | 2.96 s | 7.3 s | 252% | 1.9 GiB | 419 MB | +| | | 4 | 1.88 s | 7.4 s | 405% | 1.9 GiB | 566 MB | +| | | 8 | 1.52 s | 7.6 s | 529% | 1.9 GiB | 840 MB | +| 1 600 satellites, one file | 1 | — | 18.5 s | 24.0 s | 136% | 5.4 GiB | 2.46 GB | +| 1 600 satellites, split | 34 | 1 | 129 s | 172 s | 135% | 39.1 GiB | 1.98 GB | +| | | 2 | 66.1 s | 175 s | 269% | 39.1 GiB | 2.60 GB | +| | | 4 | 38.6 s | 181 s | 481% | 39.1 GiB | 3.94 GB | +| | | 8 | 30.9 s | 197 s | 658% | 39.1 GiB | 7.24 GB | + +Three things the table says: + +- **The pool works as a pool.** Eight workers take the 1 600-satellite split + from 129 s to 30.9 s (4.2×) at 658% CPU, and the 200-satellite split from + 5.35 s to 1.52 s (3.5×). `BenchmarkAnalyzeSplitPerDocument` in + `internal/stressmodel`, which analyzes the split's six files over one index + *without* the three workspace-wide audits below, runs 3.64 s → 1.04 s at 512 + satellites on one worker versus eight — the largest file is about a quarter + of the work, so six files cannot use eight workers better than that. +- **Splitting the file made the serial validation seven times slower, and the + pool does not recover it.** One file validates in 18.5 s; the same model in + 34 files takes 129 s on one worker and 30.9 s on eight. The reason is a + gather that is quadratic in the file count, measured below; it is what the + pool spends most of its time parallelizing, and what a **per-document + gather cache** (the persistent-workspace design in + [scaling to very large models](large-model-scaling-design.md), §3) would + remove. Until it lands, the ~5 s target that design sets for this run is + out of reach: with the gather removed, the split's per-document analysis + is about 10 s of work, and the pool's 4–5× on this machine puts it at + 2–3 s plus a 1.4 s parse and index. +- **Peak RSS grows with the workers, for the same reason.** Each worker's + private semantic model memoizes the kind of every symbol in the workspace + while its gather runs, so eight workers hold eight copies of a + workspace-wide table: 1.98 GB at one worker, 7.24 GB at eight, against + 2.46 GB for the single file. The growth is live memory, not collector + laziness — under `GOMEMLIMIT=2500MiB` the eight-worker run still peaks at + 3.51 GB, runs 104 collections instead of 27 and takes 66 s. A machine + short of memory should set `-workers` down; two workers hold the run at + 2.60 GB, the single file's footprint, for half the serial time. + +**What the gather costs.** A CPU profile of the 34-file run on eight workers +(29 s wall, 201 s of samples) spends 75% of them in three passes that walk +every workspace document to judge the one they analyze — the OOSEM method +audit `OOSEMMethodPass` (112 s, all of it `oosemAudit.gather` computing +`semantics.(*Model).FeatureTypeSet` for every symbol of every root), +`IdentityMetadataPass` (25 s) and the MOSA audit `MOSAPass` (15 s). The passes +are correct to look at the whole workspace; the cost is that each of the 34 +analyses does it afresh in a model of its own, so the gather is done 34 +times over 34 documents. The per-document work is small beside it: name +resolution of the document itself is 7 s of the 201, the inherited-name +conflict pass 5 s, type checking 1.4 s, and parsing all 34 files 1.3 s. On +one worker the same profile shape reads 118 s of gather in 128 s of analysis. +Serial per-document analysis times over the 34 files are even — 3.5 s to +5.5 s each, `constellation.sysml` the largest at 5.2 s — so the pool's +shortfall from 8× (4.2× measured) is not a straggler; it is the collector +marking eight workers' tables at once (`runtime.gcBgMarkWorker` is 13% of +the eight-worker samples, `runtime.scanobject` 18%) and the user time it +adds (172 s → 197 s). + ## Running: instantiation, state machines and satisfaction `sysml -satisfy -memstats` loads and validates the model, then for every @@ -277,10 +361,10 @@ the interactive band at every operation measured. usage cost more per element, long documentation comments cost less — but the shape of the curve (linear load, memory-bound batch, workspace-bound editing) does not depend on the regularity. -- The whole constellation is one file. Splitting it over files changes two - things: the CLI submits files one at a time and reindexes after each, which - is quadratic in the file count (`docs/internals/performance.md`, notes for - further work), and an editor pays the per-file analysis once per open file. +- Most figures are for the whole constellation as one file. The split by + plane is measured above at two sizes only, and what it shows is that the + three workspace-wide audits, not the split itself, set its cost; an editor + also pays the per-file analysis once per open file. - `-satisfy` instantiates each satellite's tree on its own; it does not instantiate the whole `Network` as one object with 12 800 satellites and their links, and no figure here says what that would cost. @@ -303,7 +387,9 @@ items below are the ones the profiles point at directly. whole-workspace walk. The audits need the whole workspace only when some document declares an artefact of the method's kinds; whether one does is computable once per reindex and cached, and a workspace that declares none - would then pay nothing. That alone removes a quarter of the per-edit cost. + would then pay nothing. That alone removes a quarter of the per-edit cost, + and — gathered once per batch rather than once per document — most of the + split constellation's 129 s. - **Keep the semantic model across edits.** Every diagnostics request after an edit starts from cold memoization. Invalidating what a change can reach — the documents that import the changed one, transitively — rather than diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index deec450f59..92d0486c98 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 235 golden execution traces under the default schedule (state×86, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 48 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,373 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,382 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/internal/stressmodel/bench_test.go b/internal/stressmodel/bench_test.go index 26bc9207f4..d0386eded6 100644 --- a/internal/stressmodel/bench_test.go +++ b/internal/stressmodel/bench_test.go @@ -6,7 +6,11 @@ import ( "strings" "testing" + "github.com/Open-MBEE/OpenSysML/internal/core/ast" "github.com/Open-MBEE/OpenSysML/internal/core/model" + "github.com/Open-MBEE/OpenSysML/internal/core/parser" + "github.com/Open-MBEE/OpenSysML/internal/core/passes" + "github.com/Open-MBEE/OpenSysML/internal/core/source" "github.com/Open-MBEE/OpenSysML/internal/repl" ) @@ -115,9 +119,8 @@ func BenchmarkEditBeside(b *testing.B) { } } -// BenchmarkValidateSplit measures loading a network split one file per plane, as -// the command line does: the files parsed and analyzed on one worker and on one -// per CPU, over one shared index. +// BenchmarkValidateSplit loads a network split one file per plane as the command +// line does, on one worker and on one per CPU. func BenchmarkValidateSplit(b *testing.B) { for _, n := range networkSizes { files, stats := splitFiles(network(n)) @@ -143,3 +146,59 @@ func BenchmarkValidateSplit(b *testing.B) { } } } + +// perDocumentRegistry is the default registry without the workspace-wide audits. +func perDocumentRegistry() *passes.Registry { + reg := passes.NewRegistry() + for _, p := range passes.DefaultRegistry().Passes() { + switch p.(type) { + case passes.OOSEMMethodPass, passes.IdentityMetadataPass, passes.MOSAPass: + continue + } + reg.Register(p) + } + return reg +} + +// BenchmarkAnalyzeSplitPerDocument analyzes the split network's files over one +// index without the workspace-wide audits: the pool's own speedup. +func BenchmarkAnalyzeSplitPerDocument(b *testing.B) { + for _, n := range networkSizes { + files, stats := splitFiles(network(n)) + idx, _ := model.NewIndexWithStdlib() + roots := make([]*ast.RootNamespace, len(files)) + names := make([]string, len(files)) + for i, f := range files { + p := parser.New(source.New(f.Name, []byte(f.Text))) + roots[i] = p.ParseFile() + if len(p.Diagnostics) > 0 { + b.Fatalf("%s: %s", f.Name, p.Diagnostics[0].Message) + } + names[i] = f.Name + idx.AddDocument(f.Name, roots[i]) + } + idx.ExpandWildcardImports() + batch := &passes.Batch{Documents: names} + passes.PrepareBatch(idx, batch) + reg := perDocumentRegistry() + for _, workers := range []int{1, runtime.GOMAXPROCS(0)} { + b.Run(fmt.Sprintf("satellites=%d/files=%d/workers=%d", stats.Satellites, len(files), workers), func(b *testing.B) { + analyze := func() { + model.ParallelFor(workers, len(files), func(i int) { + ctx := passes.NewContext(names[i], idx, nil) + ctx.Batch = batch + if diags := reg.Run(ctx, names[i], roots[i]); len(diags) > 0 { + b.Errorf("%s: %s", names[i], diags[0].Message) + } + }) + } + analyze() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + analyze() + } + }) + } + } +} From 46d25054bda69c1970278d95d9bd2473f1d464ba Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:02:51 +0000 Subject: [PATCH 08/53] refactor: shorten the comments on the batch pipeline to a line or two Co-Authored-By: jason.han --- cmd/stress-model/main.go | 7 ++----- cmd/sysml/workers_test.go | 6 ++---- internal/core/model/batch.go | 14 ++++---------- internal/core/model/batch_test.go | 6 ++---- internal/core/passes/analyze.go | 12 ++++-------- internal/core/passes/pass.go | 5 ++--- internal/core/resolve/document.go | 10 ++++------ internal/core/resolve/link_metadata_test.go | 15 ++++++--------- internal/repl/workers.go | 5 ++--- internal/stressmodel/satnet.go | 6 ++---- internal/stressmodel/satnet_test.go | 5 ++--- 11 files changed, 32 insertions(+), 59 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 2245cff289..4fe8ec7fc0 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -1,8 +1,5 @@ -// Command stress-model writes a large generated SysML v2 model of a stated shape -// and size to stdout, or one file per orbital plane to a directory, for measuring -// how the toolchain scales. The one shape today is a satellite network — a -// constellation of fully modeled spacecraft and the ground stations they downlink -// to. See docs/project/satellite-network-stress-test.md. +// Command stress-model writes a large generated satellite-network model to stdout, +// or one file per orbital plane; see docs/project/satellite-network-stress-test.md. package main import ( diff --git a/cmd/sysml/workers_test.go b/cmd/sysml/workers_test.go index 82c1430f58..02e6ef48cf 100644 --- a/cmd/sysml/workers_test.go +++ b/cmd/sysml/workers_test.go @@ -29,10 +29,8 @@ func writeWorkersModel(t *testing.T) []string { return paths } -// TestWorkersFlagAndEnvironment checks that -workers and OPENSYSML_WORKERS take a -// positive integer, that the flag wins over the variable, that either rejected -// at startup loads nothing, and that the count does not change what -validate -// reports over a model of several files. +// TestWorkersFlagAndEnvironment: -workers and OPENSYSML_WORKERS take a positive +// integer, the flag wins, a bad value loads nothing, and -validate reports the same. func TestWorkersFlagAndEnvironment(t *testing.T) { binary := buildCLI(t) paths := writeWorkersModel(t) diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go index b5ca0541dd..4b236f123a 100644 --- a/internal/core/model/batch.go +++ b/internal/core/model/batch.go @@ -86,11 +86,8 @@ func (w *Workspace) SetWorkers(n int) error { return nil } -// OpenAll opens every input as an authoritative buffer in one batch: the -// documents are parsed and their scope trees built on the workers, then added -// to the index in the order given, and the wildcard imports of the whole batch -// are expanded once. It leaves the workspace as opening the inputs one by one -// would, at a cost that does not grow with the number already open. +// OpenAll opens the inputs as one batch: parsed on the workers, added to the index +// in order, wildcard imports expanded once. Same result as opening them one by one. func (w *Workspace) OpenAll(inputs []Input) { docs := make([]*Document, len(inputs)) ParallelFor(w.Workers(), len(inputs), func(i int) { @@ -108,11 +105,8 @@ func (w *Workspace) OpenAll(inputs []Input) { w.invalidateLocked() } -// DiagnosticsAll returns the diagnostics of the named documents, in the order -// named, with nil for a name the workspace does not hold. The documents not yet -// analyzed since the last change are analyzed on the workers, each with a -// context of its own over the index, which nothing writes meanwhile; the -// results are cached as Diagnostics caches them. +// DiagnosticsAll returns the named documents' diagnostics in the order named (nil +// for an unknown name), analyzing the uncached ones on the workers, then caching. func (w *Workspace) DiagnosticsAll(names []string) [][]passes.Diagnostic { w.mu.Lock() defer w.mu.Unlock() diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index 586fc559c4..95ffeef87a 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -28,10 +28,8 @@ var batchRoots = []struct { {dir: pilotCorporaGate.roots[2].dir, require: pilotCorporaGate.requireEnv, fetch: pilotCorporaGate.fetch}, } -// Every directory of models, opened as one batch, reports the same diagnostics -// in the same order on one worker as on many, and the same as opening its files -// one by one does: the parallel pipeline changes when the work is done, not what -// it finds. +// Every directory of models, opened as one batch, reports the same diagnostics in +// the same order on one worker as on many, and as opening its files one by one. func TestParallelBatchValidationMatchesSerial(t *testing.T) { seen := map[string]bool{} for _, root := range batchRoots { diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index 9aa0a5d716..8d08489f63 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -116,10 +116,8 @@ func AnalyzeWithOptions(name string, kind source.Kind, root *ast.RootNamespace, return AnalyzeInBatch(name, kind, root, parseDiags, idx, opts, nil) } -// PrepareBatch readies the index for the documents of batch to be analyzed at -// once: what resolving each would otherwise link into its scope tree on first use -// is linked now, so the contexts of the batch only read it. Call it before the -// first AnalyzeInBatch of the batch, with nothing else using the index. +// PrepareBatch links what resolving each document of batch would write into its +// scope tree, so AnalyzeInBatch contexts only read the index. Call it alone, first. func PrepareBatch(idx *symbols.Index, batch *Batch) { if idx == nil || batch == nil { return @@ -130,10 +128,8 @@ func PrepareBatch(idx *symbols.Index, batch *Batch) { } } -// AnalyzeInBatch validates one document of a batch, with a context of its own -// over an index nothing writes while the batch runs (see PrepareBatch); the -// result does not depend on which documents share the batch or on how many are -// analyzed at once. +// AnalyzeInBatch validates one document of a prepared batch in a context of its +// own; the result does not depend on which documents share the batch. func AnalyzeInBatch(name string, kind source.Kind, root *ast.RootNamespace, parseDiags []Diagnostic, idx *symbols.Index, opts Options, batch *Batch) []Diagnostic { ctx := NewContextWithOptions(name, kind, idx, parseDiags, opts) diff --git a/internal/core/passes/pass.go b/internal/core/passes/pass.go index 3c8dca610c..e7b64ded52 100644 --- a/internal/core/passes/pass.go +++ b/internal/core/passes/pass.go @@ -68,9 +68,8 @@ type Context struct { failures []source.Span } -// Batch is what a batch of analyses computes once, before its documents are -// analyzed at the same time, for the passes that judge a document against the -// whole workspace; anything a pass would gather over every document belongs here. +// Batch is what a batch of analyses computes once before its documents are +// analyzed together; anything a pass would gather over every document belongs here. type Batch struct { // Documents names the documents the batch analyzes, in the order asked for. Documents []string diff --git a/internal/core/resolve/document.go b/internal/core/resolve/document.go index 41ab000e9b..772271afc6 100644 --- a/internal/core/resolve/document.go +++ b/internal/core/resolve/document.go @@ -561,9 +561,8 @@ func (r *Resolver) resolveMetadataPrefix(names, parent *symbols.Scope, prefix *a r.resolveMetadataBody(body, prefix.Body) } -// metadataBodyOwner is the metadata definition the body of prefix resolves -// against, its type read in names; nil when the type does not resolve or the -// annotation has no body. +// metadataBodyOwner is the metadata definition the body of prefix resolves against, +// its type read in names; nil when it does not resolve or there is no body. func (r *Resolver) metadataBodyOwner(names *symbols.Scope, prefix *ast.PrefixMetadata) *symbols.Symbol { owner, ok := r.ResolveQualified(names, prefix.Type) if !ok || owner == nil || len(prefix.Body) == 0 { @@ -575,9 +574,8 @@ func (r *Resolver) metadataBodyOwner(names *symbols.Scope, prefix *ast.PrefixMet return owner } -// LinkMetadataBodies gives every annotation body scope of the document the -// owner resolving the document would; afterwards resolving it writes nothing -// to the scope tree, so documents of one index can be resolved concurrently. +// LinkMetadataBodies sets every annotation body scope's owner as resolving the +// document would, so resolving it afterwards writes nothing to the scope tree. func (r *Resolver) LinkMetadataBodies(name string) { rootScope := r.idx.DocumentRoot(name) if rootScope == nil { diff --git a/internal/core/resolve/link_metadata_test.go b/internal/core/resolve/link_metadata_test.go index 2d9311e2fa..b198418365 100644 --- a/internal/core/resolve/link_metadata_test.go +++ b/internal/core/resolve/link_metadata_test.go @@ -12,9 +12,8 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/core/symbols" ) -// linkedMetadataBodies covers the ways an annotation body finds its owner: -// inside a definition, at the root, about an element, through an alias, nested -// in another body, and a type that does not resolve, whose body stays unowned. +// linkedMetadataBodies covers how an annotation body finds its owner: in a +// definition, at the root, about an element, via an alias, nested, unresolved. const linkedMetadataBodies = `package P { attribute def T { attribute b; } metadata def M { attribute a : T; attribute n; } @@ -60,9 +59,8 @@ func indexedDoc(t *testing.T, name, src string) (*symbols.Index, *ast.RootNamesp return idx, root, r } -// Linking a document's metadata bodies sets exactly the owners resolving it -// sets, so resolving a linked document changes nothing in its scope tree and -// reports what it would have. +// Linking a document's metadata bodies sets exactly the owners resolving it sets, +// so resolving a linked document changes nothing and reports what it would have. func TestLinkMetadataBodiesSetsWhatResolvingWould(t *testing.T) { const name = "linked.sysml" for _, src := range []string{linkedMetadataBodies, nestedMetadataBodies["nested.sysml"], nestedMetadataBodies["nested.kerml"]} { @@ -88,9 +86,8 @@ func TestLinkMetadataBodiesSetsWhatResolvingWould(t *testing.T) { } } -// A prefix carrying a body, which the AST allows though the parser writes -// bodies only on `@` usages, resolves its type from the annotated declaration's -// own scope; the linker does the same, so a type visible only there is found. +// A prefix carrying a body resolves its type from the annotated declaration's own +// scope; the linker does the same, so a type visible only there is found. func TestLinkMetadataBodiesResolvesAPrefixBodyFromTheAnnotatedScope(t *testing.T) { const name = "prefixed.sysml" const src = `package P { diff --git a/internal/repl/workers.go b/internal/repl/workers.go index 26057d3542..0350472ed7 100644 --- a/internal/repl/workers.go +++ b/internal/repl/workers.go @@ -6,9 +6,8 @@ func (s *Session) Workers() int { return s.ws.Workers() } -// SetWorkers sets how many files of one load are parsed and analyzed at once from -// here on. What a load reports is the same at any count; a value below one is a -// typed error. +// SetWorkers sets how many files of one load are parsed and analyzed at once; +// a value below one is a typed error. What a load reports is the same at any count. func (s *Session) SetWorkers(n int) error { defer s.enter()() return s.ws.SetWorkers(n) diff --git a/internal/stressmodel/satnet.go b/internal/stressmodel/satnet.go index 076c6f8b6f..e9ffb3bd81 100644 --- a/internal/stressmodel/satnet.go +++ b/internal/stressmodel/satnet.go @@ -89,10 +89,8 @@ type File struct { Name, Source string } -// Split generates the network as one document per orbital plane beside the -// library the satellites are built from and the constellation joining the -// planes: the same satellites and links as Generate writes, declared under -// a root package per file so each file is a unit of analysis of its own. +// Split generates the network Generate writes as one document per orbital plane +// beside the shared library and the constellation joining the planes. func (n SatelliteNetwork) Split() ([]File, Stats) { g := &generator{} var files []File diff --git a/internal/stressmodel/satnet_test.go b/internal/stressmodel/satnet_test.go index 7bdf9e44f4..24daf217f5 100644 --- a/internal/stressmodel/satnet_test.go +++ b/internal/stressmodel/satnet_test.go @@ -70,9 +70,8 @@ func splitFiles(n SatelliteNetwork) ([]repl.SourceFile, Stats) { return srcs, stats } -// TestSatelliteNetworkSplitValidates keeps the split in step with the single -// file: it declares the same network, loads clean under strict conformance at -// one worker and at several, and every satisfy assertion holds across files. +// TestSatelliteNetworkSplitValidates: the split declares the single file's +// network, loads clean at one worker and at several, and satisfies across files. func TestSatelliteNetworkSplitValidates(t *testing.T) { n := SatelliteNetwork{Planes: 2, Satellites: 2, GroundStations: 1} _, whole := n.Source() From c48c767151dbaeb56260a09af2ee4c9fdfd7f19b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:31:03 +0000 Subject: [PATCH 09/53] fix(stress-model): write the split model with the permissions the other tools use Co-Authored-By: jason.han --- cmd/stress-model/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 4fe8ec7fc0..bccfcc0f54 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -49,11 +49,11 @@ func main() { // writeSplit writes the network one file per plane into dir, creating it. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := os.MkdirAll(dir, 0o750); err != nil { return stats, err } for _, f := range files { - if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o600); err != nil { return stats, err } } From 773ae98559383f519372fb38b14696a90c34c53c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:04:31 +0000 Subject: [PATCH 10/53] fix(sysml): read the workers setting through envvar, and before every load OPENSYSML_WORKERS now answers to its legacy SYSML_ name like the other variables, and -query, -render, -render-all and -compile resolve the run bounds before loading, as the other loading modes do. Co-Authored-By: jason.han --- cmd/sysml/main.go | 12 ++++++++++++ cmd/sysml/workers_test.go | 21 +++++++++++++++++++++ internal/core/model/batch.go | 8 ++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/cmd/sysml/main.go b/cmd/sysml/main.go index 263f368568..af1c75ada2 100644 --- a/cmd/sysml/main.go +++ b/cmd/sysml/main.go @@ -484,6 +484,9 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -compile needs -o to name the executable (or the source file, with -source)") return 2 } + if status := resolveRunBounds(); status != 0 { + return status + } if err := runCompile(args); err != nil { return fail(err) } @@ -568,6 +571,9 @@ func runCLI() int { return refuse(modelChecks, "-render-all writes views out and decides nothing about the model; check it in its own run") } + if status := resolveRunBounds(); status != 0 { + return status + } if err := runRenderAll(args); err != nil { return fail(err) } @@ -605,6 +611,9 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -query cannot be combined with checks, -eval, -render, -render-document, -output or -from") return 2 } + if status := resolveRunBounds(); status != 0 { + return status + } return runQuery(args, queryText) } @@ -617,6 +626,9 @@ func runCLI() int { fmt.Fprintln(os.Stderr, "sysml: -render and -render-document each write a document out; ask for one per run") return 2 } + if status := resolveRunBounds(); status != 0 { + return status + } if err := runRender(args); err != nil { return fail(err) } diff --git a/cmd/sysml/workers_test.go b/cmd/sysml/workers_test.go index 02e6ef48cf..1c133c77d0 100644 --- a/cmd/sysml/workers_test.go +++ b/cmd/sysml/workers_test.go @@ -65,4 +65,25 @@ func TestWorkersFlagAndEnvironment(t *testing.T) { if got := validate([]string{"OPENSYSML_WORKERS=nope"}, "-workers", "2"); got.status != want.status || got.output() != want.output() { t.Errorf("-workers 2 under OPENSYSML_WORKERS=nope reported %d\n%s\nwant the default's\n%s", got.status, got.output(), want.output()) } + + legacy := validate([]string{"SYSML_WORKERS=1"}) + if legacy.status != want.status || legacy.stdout != want.stdout || !strings.Contains(legacy.stderr, "SYSML_WORKERS is deprecated; set OPENSYSML_WORKERS instead") { + t.Errorf("SYSML_WORKERS=1 reported %d\n%s\nwant the default's output and a deprecation warning", legacy.status, legacy.output()) + } + if got := validate([]string{"SYSML_WORKERS=0"}); got.status != 2 || !strings.Contains(got.output(), `OPENSYSML_WORKERS="0" is not a positive integer`) || strings.Contains(got.output(), "Missing") { + t.Errorf("SYSML_WORKERS=0: status %d\n%s", got.status, got.output()) + } + + // Every mode that loads a model reads the setting before loading. + for _, mode := range [][]string{ + {"-query", `sysml:name="X"`}, + {"-render", "A::X"}, + {"-render-all", t.TempDir()}, + {"-compile", "A::X", "-o", filepath.Join(t.TempDir(), "x")}, + } { + got := checkPathsEnv(t, binary, []string{"OPENSYSML_WORKERS=0"}, append(mode, paths...)...) + if got.status != 2 || !strings.Contains(got.output(), `OPENSYSML_WORKERS="0" is not a positive integer`) || strings.Contains(got.output(), "Missing") { + t.Errorf("%s under OPENSYSML_WORKERS=0: status %d\n%s", mode[0], got.status, got.output()) + } + } } diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go index 4b236f123a..828c2aa5a6 100644 --- a/internal/core/model/batch.go +++ b/internal/core/model/batch.go @@ -3,13 +3,13 @@ package model import ( "bytes" "fmt" - "os" "runtime" "strconv" "strings" "sync" "sync/atomic" + "github.com/Open-MBEE/OpenSysML/internal/core/envvar" "github.com/Open-MBEE/OpenSysML/internal/core/passes" ) @@ -51,10 +51,10 @@ func ParseWorkers(source, text string) (int, error) { return n, nil } -// WorkersFromEnv is the worker count OPENSYSML_WORKERS asks for, DefaultWorkers -// when it is unset or empty; a value that is not a positive integer is an error. +// WorkersFromEnv is the worker count OPENSYSML_WORKERS (or its legacy SYSML_ name) +// asks for, DefaultWorkers when unset or empty; a value that is not a positive integer is an error. func WorkersFromEnv() (int, error) { - return workersFromLookup(os.Getenv) + return workersFromLookup(envvar.Lookup) } // workersFromLookup is WorkersFromEnv over an explicit lookup, so the parsing is From 72868cda2ecd6e3132824db46be1f5cd07a39761 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:03:13 +0000 Subject: [PATCH 11/53] fix(repl): name a blocking error only from the submission's own document A loaded file is analyzed as a document of its own, so an error in it gates that file's deeper checks only. The blocker note on a clean prompt submission now skips diagnostics from loaded files, and a load's from the transcript. Co-Authored-By: jason.han --- internal/repl/filedocs_test.go | 25 +++++++++++++++++++++++++ internal/repl/render.go | 14 +++++++++----- internal/repl/session.go | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/internal/repl/filedocs_test.go b/internal/repl/filedocs_test.go index 7e91bb756d..b254cb52f6 100644 --- a/internal/repl/filedocs_test.go +++ b/internal/repl/filedocs_test.go @@ -88,6 +88,31 @@ func TestPromptDoesNotSeeALoadedFilesRootImports(t *testing.T) { } } +// An error in a loaded file gates the deeper checks of that file only: a clean +// prompt submission is fully analyzed in its own document, so no blocker is named +// for it, and a later loaded file is not blocked by what the prompt holds either. +func TestLoadedFileErrorsDoNotBlockOtherDocuments(t *testing.T) { + s := NewSession() + bad := tempFile(t, "bad.sysml", "package Bad { part a : Missing; }\n") + if _, _, err := s.runMeta("%load " + bad); err != nil { + t.Fatal(err) + } + res := s.Submit("package Clean { part def A; }") + if note := res.Blocked.note(); note != "" { + t.Errorf("a loaded file's error should not block the prompt's document: %s", note) + } + + s.Submit("package Typed { part b : Absent; }") + good := tempFile(t, "good.sysml", "package Good { part def B; }\n") + if note := s.submit(good, "package Good { part def B; }\n").Blocked.note(); note != "" { + t.Errorf("the prompt's error should not block a loaded file's document: %s", note) + } + // Within the transcript, an earlier typed error still gates the deeper checks. + if s.Submit("package Also { part def C; }").Blocked.note() == "" { + t.Error("the typed unresolved reference should still be named as blocking the prompt") + } +} + // Every multi-file directory of the fixtures and of the OMG corpora reports the // same diagnostics loaded from the command line as opened in a workspace. func TestCommandLineLoadMatchesWorkspace(t *testing.T) { diff --git a/internal/repl/render.go b/internal/repl/render.go index c561c2fe7d..6c09d43236 100644 --- a/internal/repl/render.go +++ b/internal/repl/render.go @@ -40,6 +40,10 @@ type Result struct { // masked locates the submissions kept out of the analyzed buffer, whose // findings gated no validation tier. masked []source.Span + + // foreign locates the snippets analyzed in another document than this + // submission's, whose findings gated none of its validation tiers. + foreign []source.Span } // Member is one top-level member of a session document; Offset is where the @@ -478,7 +482,7 @@ func (n *blockerNote) record(key string) { func (r Result) analysisBlocked() *blocker { var first *blocker for _, d := range r.Diagnostics { - if !d.Blocking() || r.mine(d.Span) || r.isMasked(d.Span) { + if !d.Blocking() || r.mine(d.Span) || covers(r.masked, d.Span) || covers(r.foreign, d.Span) { continue } if first != nil { @@ -490,10 +494,10 @@ func (r Result) analysisBlocked() *blocker { return first } -// isMasked reports whether a span falls in a submission that was kept out of -// the analyzed buffer, so its errors blocked nothing. -func (r Result) isMasked(span source.Span) bool { - for _, m := range r.masked { +// covers reports whether a span starts in one of the snippets located, whose +// errors blocked nothing of the submission's. +func covers(snippets []source.Span, span source.Span) bool { + for _, m := range snippets { // End() included: a submission that does not close its own text is // reported at its end as often as inside it. if span.Offset >= m.Offset && span.Offset <= m.End() { diff --git a/internal/repl/session.go b/internal/repl/session.go index 6320d3379a..85e8299616 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -693,6 +693,21 @@ func (s *Session) maskedSpans() []source.Span { return out } +// foreignSpans locates the snippets analyzed in another document than the +// submission's: a loaded file is a document of its own, so a load shares one +// with nothing else, and the transcript only with the submissions typed at the prompt. +func (s *Session) foreignSpans(load bool) []source.Span { + var out []source.Span + acc := 0 + for _, sn := range s.snippets { + if load || sn.origin != "" { + out = append(out, source.Span{Offset: acc, Len: len(sn.src)}) + } + acc += len(sn.src) + 1 + } + return out +} + // diagnostics reports the analysis of every session document and the syntax errors // of the masked submissions, each moved to where its text sits in the session buffer. func (s *Session) diagnostics() []passes.Diagnostic { @@ -866,6 +881,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, Origins: s.origins(), own: own, masked: s.maskedSpans(), + foreign: s.foreignSpans(len(files) > 0 && files[0].Name != ""), Notices: notices, } res.Blocked = s.blockedBy(res) From 7b9b73d9bb8593d28bbf900255067580a514d507 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:10:02 +0000 Subject: [PATCH 12/53] fix(repl): keep the transcript's blocker note through a file load A load shares no document with the rest of the buffer, so nothing blocks it and it neither names nor forgets the error the transcript has already been told of. Co-Authored-By: jason.han --- internal/repl/filedocs_test.go | 7 ++++++- internal/repl/session.go | 18 +++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/repl/filedocs_test.go b/internal/repl/filedocs_test.go index b254cb52f6..e5e8a2074e 100644 --- a/internal/repl/filedocs_test.go +++ b/internal/repl/filedocs_test.go @@ -107,10 +107,15 @@ func TestLoadedFileErrorsDoNotBlockOtherDocuments(t *testing.T) { if note := s.submit(good, "package Good { part def B; }\n").Blocked.note(); note != "" { t.Errorf("the prompt's error should not block a loaded file's document: %s", note) } - // Within the transcript, an earlier typed error still gates the deeper checks. + // Within the transcript, an earlier typed error still gates the deeper checks, + // and is named once: a load in between does not make it worth saying again. if s.Submit("package Also { part def C; }").Blocked.note() == "" { t.Error("the typed unresolved reference should still be named as blocking the prompt") } + s.submit(good, "package Good { part def B; }\n") + if note := s.Submit("package More { part def D; }").Blocked.note(); note != "" { + t.Errorf("the standing error was named already; a load does not renew it: %s", note) + } } // Every multi-file directory of the fixtures and of the OMG corpora reports the diff --git a/internal/repl/session.go b/internal/repl/session.go index 85e8299616..678a4614d2 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -693,14 +693,13 @@ func (s *Session) maskedSpans() []source.Span { return out } -// foreignSpans locates the snippets analyzed in another document than the -// submission's: a loaded file is a document of its own, so a load shares one -// with nothing else, and the transcript only with the submissions typed at the prompt. -func (s *Session) foreignSpans(load bool) []source.Span { +// foreignSpans locates the loaded files in the buffer, each a document of its +// own whose findings gated nothing of the transcript's. +func (s *Session) foreignSpans() []source.Span { var out []source.Span acc := 0 for _, sn := range s.snippets { - if load || sn.origin != "" { + if sn.origin != "" { out = append(out, source.Span{Offset: acc, Len: len(sn.src)}) } acc += len(sn.src) + 1 @@ -830,6 +829,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, ) seen := map[string]bool{} s.version++ + load := len(files) > 0 && files[0].Name != "" byFile = make([][]string, len(files)) for i, f := range files { names, dropped := s.acceptFrom(f.Name, f.Text) @@ -881,10 +881,14 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, Origins: s.origins(), own: own, masked: s.maskedSpans(), - foreign: s.foreignSpans(len(files) > 0 && files[0].Name != ""), + foreign: s.foreignSpans(), Notices: notices, } - res.Blocked = s.blockedBy(res) + // Nothing outside a load shares its documents, so nothing blocks it, and the + // note the transcript has had stays the transcript's. + if !load { + res.Blocked = s.blockedBy(res) + } return res, byFile, whole } From 33d05dbb9b159f0b6231c27000d12cd87ee74d06 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:18:12 +0000 Subject: [PATCH 13/53] fix(repl): let a load that resolves the standing error end its note's interval A load still names no blocker, but when it leaves the transcript unblocked the recorded note is cleared, so the error is named again should a reload bring it back. Co-Authored-By: jason.han --- internal/repl/filedocs_test.go | 9 ++++++++- internal/repl/render.go | 9 +++++++-- internal/repl/session.go | 6 +----- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/repl/filedocs_test.go b/internal/repl/filedocs_test.go index e5e8a2074e..067c838846 100644 --- a/internal/repl/filedocs_test.go +++ b/internal/repl/filedocs_test.go @@ -102,7 +102,7 @@ func TestLoadedFileErrorsDoNotBlockOtherDocuments(t *testing.T) { t.Errorf("a loaded file's error should not block the prompt's document: %s", note) } - s.Submit("package Typed { part b : Absent; }") + s.Submit("package Typed { part b : Absent::B; }") good := tempFile(t, "good.sysml", "package Good { part def B; }\n") if note := s.submit(good, "package Good { part def B; }\n").Blocked.note(); note != "" { t.Errorf("the prompt's error should not block a loaded file's document: %s", note) @@ -116,6 +116,13 @@ func TestLoadedFileErrorsDoNotBlockOtherDocuments(t *testing.T) { if note := s.Submit("package More { part def D; }").Blocked.note(); note != "" { t.Errorf("the standing error was named already; a load does not renew it: %s", note) } + // A load that resolves the standing error ends its interval: should a reload + // bring the error back, the next prompt is told again. + s.submit(good, "package Absent { part def B; }\n") + s.submit(good, "package Good { part def B; }\n") + if s.Submit("package Yet { part def E; }").Blocked.note() == "" { + t.Error("an error resolved by a load and brought back by a reload should be named again") + } } // Every multi-file directory of the fixtures and of the OMG corpora reports the diff --git a/internal/repl/render.go b/internal/repl/render.go index 6c09d43236..1ae895a276 100644 --- a/internal/repl/render.go +++ b/internal/repl/render.go @@ -442,13 +442,18 @@ func (b *blocker) note() string { // blockedBy reports the unresolved error that stopped the deeper checks from // running over this submission: a standing error is named on the first -// submission whose report says so, not on every one after it. -func (s *Session) blockedBy(r Result) *blocker { +// submission whose report says so, not on every one after it. A load shares no +// document with the transcript, so it names nothing; one that resolves the +// standing error lets it be named again should it return. +func (s *Session) blockedBy(r Result, load bool) *blocker { b := r.analysisBlocked() if b == nil { s.notedBlocker.record("") return nil } + if load { + return nil + } key := b.key() if key == s.notedBlocker.reportedKey() { return nil diff --git a/internal/repl/session.go b/internal/repl/session.go index 678a4614d2..60244633c7 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -884,11 +884,7 @@ func (s *Session) submitEach(files []SourceFile) (res Result, byFile [][]string, foreign: s.foreignSpans(), Notices: notices, } - // Nothing outside a load shares its documents, so nothing blocks it, and the - // note the transcript has had stays the transcript's. - if !load { - res.Blocked = s.blockedBy(res) - } + res.Blocked = s.blockedBy(res, load) return res, byFile, whole } From ba825c6f2d667fb15cecdedb764786e064d8539a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:27:53 +0000 Subject: [PATCH 14/53] fix(model): commit a batch only over the documents it reserved OpenAll parses outside the lock, so a document another caller opened, edited, closed or removed meanwhile was overwritten at commit. The batch now records what each name held as it started and installs only where that still stands. Co-Authored-By: jason.han --- internal/core/model/batch.go | 34 +++++++++++++++++++++-- internal/core/model/batch_test.go | 46 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go index 828c2aa5a6..3861b326d5 100644 --- a/internal/core/model/batch.go +++ b/internal/core/model/batch.go @@ -87,22 +87,50 @@ func (w *Workspace) SetWorkers(n int) error { } // OpenAll opens the inputs as one batch: parsed on the workers, added to the index -// in order, wildcard imports expanded once. Same result as opening them one by one. +// in order, wildcard imports expanded once. Same result as opening them one by one +// as the batch starts: a document changed by another caller meanwhile keeps that change. func (w *Workspace) OpenAll(inputs []Input) { + was := w.reserveBatch(inputs) docs := make([]*Document, len(inputs)) ParallelFor(w.Workers(), len(inputs), func(i int) { in := inputs[i] docs[i] = newDocument(in.Name, bytes.Clone(in.Content), in.Version) }) + w.commitBatch(was, docs) +} + +// reserveBatch is the document each input's name holds as the batch starts, +// which is what commitBatch installs over. +func (w *Workspace) reserveBatch(inputs []Input) map[string]*Document { + w.mu.RLock() + defer w.mu.RUnlock() + was := make(map[string]*Document, len(inputs)) + for _, in := range inputs { + was[in.Name] = w.docs[in.Name] + } + return was +} + +// commitBatch installs the parsed documents whose name still holds what the +// batch reserved; a name changed since keeps its newer document. +func (w *Workspace) commitBatch(was map[string]*Document, docs []*Document) { w.mu.Lock() defer w.mu.Unlock() + installed := false for _, doc := range docs { + if w.docs[doc.Name] != was[doc.Name] { + continue + } w.open[doc.Name] = true w.docs[doc.Name] = doc w.index.AddBuiltDocument(doc.Name, doc.AST, doc.Scope) + was[doc.Name] = doc + installed = true + } + if installed { + w.index.ExpandWildcardImports() + w.invalidateLocked() } - w.index.ExpandWildcardImports() - w.invalidateLocked() } // DiagnosticsAll returns the named documents' diagnostics in the order named (nil diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index 95ffeef87a..2dc49ba01f 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -99,6 +99,52 @@ func TestOpenAllReplacesEarlierDocuments(t *testing.T) { } } +// A document another caller changes while a batch parses keeps that change: the +// batch installs only over what it reserved, so an edit, a buffer opened and a +// removal made meanwhile all stand, and only the untouched name is opened. +func TestOpenAllKeepsAChangeMadeWhileItParsed(t *testing.T) { + ws := NewWorkspace() + ws.Open("a.sysml", []byte("package A { part def Old; }"), 1) + ws.Open("d.sysml", []byte("package D { part def Old; }"), 1) + inputs := []Input{ + {Name: "a.sysml", Content: []byte("package A { part def Batch; }"), Version: 2}, + {Name: "b.sysml", Content: []byte("package B { part def Batch; }"), Version: 1}, + {Name: "c.sysml", Content: []byte("package C { part def Batch; }"), Version: 1}, + {Name: "d.sysml", Content: []byte("package D { part def Batch; }"), Version: 2}, + } + was := ws.reserveBatch(inputs) + docs := make([]*Document, len(inputs)) + for i, in := range inputs { + docs[i] = newDocument(in.Name, in.Content, in.Version) + } + ws.Update("a.sysml", []byte("package A { part def Edited; }"), 3) + ws.Open("b.sysml", []byte("package B { part def Opened; }"), 1) + ws.Remove("d.sysml") + ws.commitBatch(was, docs) + + if doc := ws.Document("a.sysml"); doc == nil || doc.Version != 3 { + t.Errorf("a.sysml should keep the edit made while the batch parsed, got %+v", doc) + } + if syms := ws.LookupQualified("A::Edited"); len(syms) != 1 { + t.Errorf("A::Edited should be indexed once, found %d", len(syms)) + } + if syms := ws.LookupQualified("A::Batch"); len(syms) != 0 { + t.Errorf("the batch's stale a.sysml should not be indexed, found A::Batch %d times", len(syms)) + } + if syms := ws.LookupQualified("B::Opened"); len(syms) != 1 || len(ws.LookupQualified("B::Batch")) != 0 { + t.Error("b.sysml was opened while the batch parsed and should keep that buffer") + } + if ws.Document("d.sysml") != nil || len(ws.LookupQualified("D::Batch")) != 0 { + t.Error("d.sysml was removed while the batch parsed and should stay removed") + } + if doc := ws.Document("c.sysml"); doc == nil || !ws.IsOpen("c.sysml") { + t.Errorf("c.sysml, untouched meanwhile, should be opened by the batch, got %+v", doc) + } + if syms := ws.LookupQualified("C::Batch"); len(syms) != 1 { + t.Errorf("C::Batch should be indexed once, found %d", len(syms)) + } +} + func TestWorkersSetting(t *testing.T) { ws := NewWorkspace() if ws.Workers() != DefaultWorkers() || DefaultWorkers() < 1 { From 284a74f3e765644286f24e75c24fca1452883ef1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:57:03 +0000 Subject: [PATCH 15/53] fix(repl): take a masked file's declarations out of the index when no document is left A file reloaded with its enclosure left open is masked and its workspace document removed; with no scoped document left, symbolIndex returned before taking the file's previous declarations back out of the session index, so a qualified lookup kept answering with what the session no longer held. The empty-document path now drops every indexed document, as a reset does, and keeps the standard library. Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- internal/repl/openinput_test.go | 29 +++++++++++++++++++++++++++++ internal/repl/session.go | 23 +++++++++++++++-------- 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 8b0bd71a5b..82da081e8c 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,388 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 938 conformance cases, 255 golden traces, 464 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,389 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 938 conformance cases, 255 golden traces, 464 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (938/938 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 40afa1bb73..cb6cffe953 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 255 golden execution traces under the default schedule (state×106, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 48 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,388 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,389 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/internal/repl/openinput_test.go b/internal/repl/openinput_test.go index 56d3936aba..9628331a5a 100644 --- a/internal/repl/openinput_test.go +++ b/internal/repl/openinput_test.go @@ -249,6 +249,35 @@ func TestReloadingAFixedFileClearsItsSyntaxError(t *testing.T) { } } +// The reverse: a file reloaded with its enclosure left open is masked, and takes +// its declarations out of the index with it, so a qualified lookup no longer +// finds what the session no longer holds; the library stays reachable. +func TestReloadingAFileLeftOpenDropsItsSymbols(t *testing.T) { + s := NewSession() + path := filepath.Join(t.TempDir(), "model.sysml") + if err := os.WriteFile(path, []byte("package P { attribute x = 1; }\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.LoadFile(path); err != nil { + t.Fatal(err) + } + if _, _, err := s.lookupSymbol("P::x"); err != nil { + t.Fatalf("P::x did not resolve after the load: %v", err) + } + if err := os.WriteFile(path, []byte("package P {\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := s.LoadFile(path); err != nil { + t.Fatal(err) + } + if sym, _, err := s.lookupSymbol("P::x"); err == nil { + t.Errorf("P::x should be gone with the file that declared it, found %v", sym) + } + if _, _, err := s.lookupSymbol("ScalarValues::Real"); err != nil { + t.Errorf("the library should still answer a qualified lookup: %v", err) + } +} + // Typed input that leaves an enclosure open is masked the same way, and the // declarations already in the buffer are untouched. func TestOpenTypedSubmissionKeepsTheBuffer(t *testing.T) { diff --git a/internal/repl/session.go b/internal/repl/session.go index 60244633c7..2d0de25f49 100644 --- a/internal/repl/session.go +++ b/internal/repl/session.go @@ -1128,14 +1128,7 @@ func (s *Session) clear() []string { s.snippets = nil s.version = 0 s.rtCtx, s.replaced = nil, nil - if s.idx != nil { - // Drop the documents, keep the library the index was built with. - for _, name := range s.idxDocs { - s.idx.RemoveDocument(name) - } - s.idxDocs = nil - s.idxVersion = 0 - } + s.dropIndexedDocs() s.instances = make(map[string]*runtime.Instance) s.unnamed = nil s.lost = lost @@ -1236,6 +1229,7 @@ func (s *Session) newRuntimeOver(model *runtime.Model) (*runtime.Context, error) func (s *Session) symbolIndex() *symbols.Index { docs := s.sessionDocs() if !hasScope(docs) { + s.dropIndexedDocs() return nil } if s.idx == nil { @@ -1262,6 +1256,19 @@ func (s *Session) symbolIndex() *symbols.Index { return s.idx } +// dropIndexedDocs takes the session's documents back out of the index, keeping +// the library it was built with. +func (s *Session) dropIndexedDocs() { + if s.idx == nil { + return + } + for _, name := range s.idxDocs { + s.idx.RemoveDocument(name) + } + s.idxDocs = nil + s.idxVersion = 0 +} + // hasScope reports whether any of the documents built a scope tree. func hasScope(docs []*model.Document) bool { for _, doc := range docs { From cb738c79cdc9d493d0914a5583a5d3aee9ac9a20 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:16:12 +0000 Subject: [PATCH 16/53] perf(symbols): replace an indexed document without re-expanding imports Adding a document the index already holds removed the old one through RemoveDocument, which expands wildcard imports before the replacement is in. A batch reloading N files expanded N times before its own expansion; the removal now defers to the caller's expansion as AddDocument already does. The edit reindexer, which analyzes right after adding, expands explicitly. Co-Authored-By: jason.han --- internal/core/edit/edit.go | 1 + internal/core/symbols/index.go | 13 +++++++-- internal/core/symbols/index_removal_test.go | 32 +++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/core/edit/edit.go b/internal/core/edit/edit.go index b917a0e6ae..40ea9832a3 100644 --- a/internal/core/edit/edit.go +++ b/internal/core/edit/edit.go @@ -220,6 +220,7 @@ func (r *reindexer) analyzedIn(name string, root *ast.RootNamespace, kind source } } r.idx.AddDocumentWithKind(name, root, kind) + r.idx.ExpandWildcardImports() return r.idx } diff --git a/internal/core/symbols/index.go b/internal/core/symbols/index.go index 5b7f222840..9bef60a571 100644 --- a/internal/core/symbols/index.go +++ b/internal/core/symbols/index.go @@ -361,7 +361,8 @@ func (idx *Index) AddDocumentWithKind(name string, root *ast.RootNamespace, kind func (idx *Index) addDocument(name string, root *ast.RootNamespace, rs *Scope, kind source.Kind, explicitKind bool) { idx.mustBeWritable("AddDocument") - idx.RemoveDocument(name) + // The caller expands once the documents are in; nothing is read in between. + idx.removeDocument(name, false) if rs == nil { rs = Build(root) } @@ -801,6 +802,12 @@ func (idx *Index) hasFQN(fqn string, sym *Symbol) bool { // the removal is recorded in the overlay, which stops answering for what the // document contributed while the base keeps it for every other index over it. func (idx *Index) RemoveDocument(name string) { + idx.removeDocument(name, true) +} + +// removeDocument is RemoveDocument, re-expanding only when asked: a replacement +// takes the old document out and expands once the new one is in. +func (idx *Index) removeDocument(name string, expand bool) { idx.mustBeWritable("RemoveDocument") if !idx.knows(name) { return @@ -842,7 +849,9 @@ func (idx *Index) RemoveDocument(name string) { idx.docReexports.del(name) idx.dropNamespaceFilters(name) - idx.ExpandWildcardImports() + if expand { + idx.ExpandWildcardImports() + } } // MarkLibrary records that the named document holds bundled library content, diff --git a/internal/core/symbols/index_removal_test.go b/internal/core/symbols/index_removal_test.go index 0b0b8200ff..ddd29ecb2d 100644 --- a/internal/core/symbols/index_removal_test.go +++ b/internal/core/symbols/index_removal_test.go @@ -281,6 +281,38 @@ func TestEditingTheTargetOfAFileLevelImportDropsItsReexport(t *testing.T) { } } +// Replacing documents that are already indexed expands nothing until the caller +// asks, and that one expansion leaves what a fresh build over the new set gives: +// a reload pays for one expansion, as a first load does. +func TestReplacingDocumentsExpandsOnceEqualToFreshBuild(t *testing.T) { + before := map[string]string{ + "a.sysml": "package Mid { public import Lib::*; part def OldOnly; }", + "b.sysml": "package Top { public import Mid::*; } package Far { public import Top::*; }", + "c.sysml": "package Src { part def Exported; } package User { public import Src::*; }", + } + after := map[string]string{ + "a.sysml": "package Mid { public import Lib::*; part def NewOnly; }", + "b.sysml": "package Top { public import Mid::*; }", + "c.sysml": "package Src { part def Renamed; } package User { public import Src::*; }", + } + reused := buildIndex(t, before) + addDoc(t, reused, "a.sysml", after["a.sysml"]) + // Top's re-export of OldOnly goes at the expansion, not at the replacement. + if len(reused.LookupQualified("Top::OldOnly")) == 0 { + t.Fatal("replacing a.sysml expanded on its own") + } + addDoc(t, reused, "b.sysml", after["b.sysml"]) + addDoc(t, reused, "c.sysml", after["c.sysml"]) + reused.ExpandWildcardImports() + if got := len(reused.LookupQualified("Top::OldOnly")); got != 0 { + t.Errorf("Top::OldOnly = %d symbols after its declaration was replaced, want 0", got) + } + if got, want := indexState(reused), indexState(buildIndex(t, after)); got != want { + t.Errorf("replacing every document left an index a fresh build would not produce:\n%s", + diffLines(want, got)) + } +} + // Deriving every importer from an empty re-export state — what expansion falls // back to when its incremental rounds do not settle — has to rebuild exactly what // was there, including a target that only resolves once another importer has been From 36b58e2c1559d428bde392258d35dc3940036e2e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:16:12 +0000 Subject: [PATCH 17/53] fix(stress-model): drop the plane files of a larger earlier -split-planes run Regenerating into a directory that held more planes left the surplus planeNNN.sysml files behind, so a validate over the directory took in planes the constellation no longer declares. Only files of the generator's own plane name shape are removed; anything else in the directory is kept. Co-Authored-By: jason.han --- README.md | 2 +- cmd/stress-model/main.go | 25 ++++++++++++++-- cmd/stress-model/main_test.go | 52 +++++++++++++++++++++++++++++++++ docs/project/spec-compliance.md | 2 +- internal/stressmodel/satnet.go | 20 ++++++++++++- 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 cmd/stress-model/main_test.go diff --git a/README.md b/README.md index f0d59d9ef2..d094247464 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,399 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 938 conformance cases, 255 golden traces, 464 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,402 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 938 conformance cases, 255 golden traces, 464 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (938/938 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index bccfcc0f54..4ab69ea530 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -46,16 +46,37 @@ func main() { } } -// writeSplit writes the network one file per plane into dir, creating it. +// writeSplit writes the network one file per plane into dir, creating it, and +// removes the plane files an earlier, larger generation left there. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { return stats, err } + written := make(map[string]bool, len(files)) for _, f := range files { if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o600); err != nil { return stats, err } + written[f.Name] = true } - return stats, nil + return stats, removeStalePlanes(dir, written) +} + +// removeStalePlanes deletes the plane files in dir the generator did not just +// write; only names of the generator's own shape are touched. +func removeStalePlanes(dir string, written map[string]bool) error { + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() || written[e.Name()] || !stressmodel.IsPlaneFile(e.Name()) { + continue + } + if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { + return err + } + } + return nil } diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go new file mode 100644 index 0000000000..2f367b2014 --- /dev/null +++ b/cmd/stress-model/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/stressmodel" +) + +func TestWriteSplitDropsThePlanesOfALargerGeneration(t *testing.T) { + dir := t.TempDir() + keep := filepath.Join(dir, "notes.sysml") + if err := os.WriteFile(keep, []byte("package Notes;\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 8, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var got []string + for _, e := range entries { + got = append(got, e.Name()) + } + want := []string{ + "constellation.sysml", "library.sysml", "notes.sysml", + "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml", + } + if !slices.Equal(got, want) { + t.Errorf("after regenerating with four planes the directory holds %v, want %v", got, want) + } +} + +func TestIsPlaneFileMatchesOnlyTheGeneratorsNames(t *testing.T) { + for _, name := range []string{"plane000.sysml", "plane031.sysml", "plane1000.sysml"} { + if !stressmodel.IsPlaneFile(name) { + t.Errorf("IsPlaneFile(%q) = false, want true", name) + } + } + for _, name := range []string{"plane.sysml", "plane01.sysml", "plane-01.sysml", "plane001.kerml", "planet001.sysml", "myplane001.sysml", "library.sysml"} { + if stressmodel.IsPlaneFile(name) { + t.Errorf("IsPlaneFile(%q) = true, want false", name) + } + } +} diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 291b715b61..c8676c84aa 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 255 golden execution traces under the default schedule (state×106, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 48 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,399 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,402 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/internal/stressmodel/satnet.go b/internal/stressmodel/satnet.go index e9ffb3bd81..1ebf080a51 100644 --- a/internal/stressmodel/satnet.go +++ b/internal/stressmodel/satnet.go @@ -13,6 +13,7 @@ package stressmodel import ( "fmt" "io" + "strconv" "strings" ) @@ -89,6 +90,23 @@ type File struct { Name, Source string } +// planeFile names the document of plane p. +func planeFile(p int) string { return fmt.Sprintf("plane%03d.sysml", p) } + +// IsPlaneFile reports whether name is one Split gives a plane's document, so a +// writer can tell the planes of an earlier generation from anything else. +func IsPlaneFile(name string) bool { + digits, ok := strings.CutPrefix(name, "plane") + if !ok { + return false + } + if digits, ok = strings.CutSuffix(digits, ".sysml"); !ok { + return false + } + p, err := strconv.Atoi(digits) + return err == nil && p >= 0 && name == planeFile(p) +} + // Split generates the network Generate writes as one document per orbital plane // beside the shared library and the constellation joining the planes. func (n SatelliteNetwork) Split() ([]File, Stats) { @@ -106,7 +124,7 @@ func (n SatelliteNetwork) Split() ([]File, Stats) { g.line(0, "}") }) for p := 0; p < n.Planes; p++ { - file(fmt.Sprintf("plane%03d.sysml", p), func() { + file(planeFile(p), func() { g.decl(0, "package Plane%d {", p) g.imports("SatelliteNetwork::") g.line(0, "") From 5ad66c8d046474352e65a2907b18c420a99a15e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:22:28 +0000 Subject: [PATCH 18/53] fix(stress-model): remove only the files the last -split-planes run recorded A plane file's name does not say who wrote it, so a user's own planeNNN.sysml in the output directory was removed as a leftover. The generator now lists what it wrote in .stress-model-files and a later run removes only those of that list it did not write again; a directory without the list loses nothing. Co-Authored-By: jason.han --- cmd/stress-model/main.go | 49 ++++++++++----- cmd/stress-model/main_test.go | 62 ++++++++++++------- docs/project/satellite-network-stress-test.md | 6 +- internal/stressmodel/satnet.go | 20 +----- 4 files changed, 80 insertions(+), 57 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 4ab69ea530..88c60ffda1 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -3,10 +3,12 @@ package main import ( + "errors" "flag" "fmt" "os" "path/filepath" + "strings" "github.com/Open-MBEE/OpenSysML/internal/stressmodel" ) @@ -46,37 +48,56 @@ func main() { } } +// manifestName is the file in a -split-planes directory listing what the last +// generation wrote there, so the next one removes only its own files. +const manifestName = ".stress-model-files" + // writeSplit writes the network one file per plane into dir, creating it, and -// removes the plane files an earlier, larger generation left there. +// removes what an earlier generation wrote there that this one did not. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { return stats, err } + previous, err := readManifest(dir) + if err != nil { + return stats, err + } written := make(map[string]bool, len(files)) + var manifest strings.Builder for _, f := range files { if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o600); err != nil { return stats, err } written[f.Name] = true + manifest.WriteString(f.Name + "\n") + } + for _, name := range previous { + if written[name] { + continue + } + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return stats, err + } } - return stats, removeStalePlanes(dir, written) + return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(manifest.String()), 0o600) } -// removeStalePlanes deletes the plane files in dir the generator did not just -// write; only names of the generator's own shape are touched. -func removeStalePlanes(dir string, written map[string]bool) error { - entries, err := os.ReadDir(dir) +// readManifest returns the file names the last generation into dir recorded; +// none when there was no generation. Only plain names in dir are honored. +func readManifest(dir string) ([]string, error) { + data, err := os.ReadFile(filepath.Join(dir, manifestName)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } if err != nil { - return err + return nil, err } - for _, e := range entries { - if e.IsDir() || written[e.Name()] || !stressmodel.IsPlaneFile(e.Name()) { - continue - } - if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { - return err + var names []string + for _, name := range strings.Split(string(data), "\n") { + if name != "" && name != manifestName && filepath.Base(name) == name { + names = append(names, name) } } - return nil + return names, nil } diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 2f367b2014..97d51d9a1b 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -9,11 +9,13 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/stressmodel" ) -func TestWriteSplitDropsThePlanesOfALargerGeneration(t *testing.T) { +func TestWriteSplitDropsOnlyWhatALargerGenerationWrote(t *testing.T) { dir := t.TempDir() - keep := filepath.Join(dir, "notes.sysml") - if err := os.WriteFile(keep, []byte("package Notes;\n"), 0o600); err != nil { - t.Fatal(err) + // A model of the user's own, one of them under a name a plane could take. + for _, name := range []string{"notes.sysml", "plane009.sysml"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("package Notes;\n"), 0o600); err != nil { + t.Fatal(err) + } } if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 8, Satellites: 1}, dir); err != nil { t.Fatal(err) @@ -21,32 +23,46 @@ func TestWriteSplitDropsThePlanesOfALargerGeneration(t *testing.T) { if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err != nil { t.Fatal(err) } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - var got []string - for _, e := range entries { - got = append(got, e.Name()) - } want := []string{ - "constellation.sysml", "library.sysml", "notes.sysml", - "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml", + manifestName, "constellation.sysml", "library.sysml", "notes.sysml", + "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml", "plane009.sysml", } - if !slices.Equal(got, want) { + if got := listing(t, dir); !slices.Equal(got, want) { t.Errorf("after regenerating with four planes the directory holds %v, want %v", got, want) } + if got, err := os.ReadFile(filepath.Join(dir, "plane009.sysml")); err != nil || string(got) != "package Notes;\n" { + t.Errorf("the user's plane009.sysml reads %q, %v; want it untouched", got, err) + } } -func TestIsPlaneFileMatchesOnlyTheGeneratorsNames(t *testing.T) { - for _, name := range []string{"plane000.sysml", "plane031.sysml", "plane1000.sysml"} { - if !stressmodel.IsPlaneFile(name) { - t.Errorf("IsPlaneFile(%q) = false, want true", name) +func TestWriteSplitIntoAnUnknownDirectoryRemovesNothing(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"plane000.sysml", "plane005.sysml", "library.sysml"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("package Mine;\n"), 0o600); err != nil { + t.Fatal(err) } } - for _, name := range []string{"plane.sysml", "plane01.sysml", "plane-01.sysml", "plane001.kerml", "planet001.sysml", "myplane001.sysml", "library.sysml"} { - if stressmodel.IsPlaneFile(name) { - t.Errorf("IsPlaneFile(%q) = true, want false", name) - } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + want := []string{manifestName, "constellation.sysml", "library.sysml", "plane000.sysml", "plane001.sysml", "plane005.sysml"} + if got := listing(t, dir); !slices.Equal(got, want) { + t.Errorf("a first generation into a directory left %v, want %v", got, want) + } + if got, err := os.ReadFile(filepath.Join(dir, "plane005.sysml")); err != nil || string(got) != "package Mine;\n" { + t.Errorf("plane005.sysml, which no generation wrote, reads %q, %v; want it untouched", got, err) + } +} + +func listing(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) } + return names } diff --git a/docs/project/satellite-network-stress-test.md b/docs/project/satellite-network-stress-test.md index 9a54aa8b7a..672a74d703 100644 --- a/docs/project/satellite-network-stress-test.md +++ b/docs/project/satellite-network-stress-test.md @@ -134,7 +134,11 @@ writes the same constellation as one `.sysml` per orbital plane plus `library.sysml` (the definitions every plane shares) and `constellation.sysml` (the ground segment and the cross-plane network); the split declares the same network and analyzes to the same diagnostics as the single file -(`TestSatelliteNetworkSplitValidates`). `sysml -validate` over the files parses +(`TestSatelliteNetworkSplitValidates`). The generator lists what it wrote in +`.stress-model-files` beside the model, and a later generation into the same +directory removes only the files on that list it did not write again, so a +smaller constellation leaves no plane of a larger one behind and nothing else +in the directory is touched. `sysml -validate` over the files parses them on a pool of workers, indexes them once, expands wildcard imports once and analyzes them on the pool, each document with a resolver and semantic model of its own; `-workers N` (or `OPENSYSML_WORKERS`) sets the pool, default diff --git a/internal/stressmodel/satnet.go b/internal/stressmodel/satnet.go index 1ebf080a51..e9ffb3bd81 100644 --- a/internal/stressmodel/satnet.go +++ b/internal/stressmodel/satnet.go @@ -13,7 +13,6 @@ package stressmodel import ( "fmt" "io" - "strconv" "strings" ) @@ -90,23 +89,6 @@ type File struct { Name, Source string } -// planeFile names the document of plane p. -func planeFile(p int) string { return fmt.Sprintf("plane%03d.sysml", p) } - -// IsPlaneFile reports whether name is one Split gives a plane's document, so a -// writer can tell the planes of an earlier generation from anything else. -func IsPlaneFile(name string) bool { - digits, ok := strings.CutPrefix(name, "plane") - if !ok { - return false - } - if digits, ok = strings.CutSuffix(digits, ".sysml"); !ok { - return false - } - p, err := strconv.Atoi(digits) - return err == nil && p >= 0 && name == planeFile(p) -} - // Split generates the network Generate writes as one document per orbital plane // beside the shared library and the constellation joining the planes. func (n SatelliteNetwork) Split() ([]File, Stats) { @@ -124,7 +106,7 @@ func (n SatelliteNetwork) Split() ([]File, Stats) { g.line(0, "}") }) for p := 0; p < n.Planes; p++ { - file(planeFile(p), func() { + file(fmt.Sprintf("plane%03d.sysml", p), func() { g.decl(0, "package Plane%d {", p) g.imports("SatelliteNetwork::") g.line(0, "") From a7b7acfad2593597d7bef532f72c17a179cdab02 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:27:27 +0000 Subject: [PATCH 19/53] fix(stress-model): leave what is not a regular file at a recorded name alone A directory or link standing where the last run put a plane was not written by the generator; the cleanup now removes only regular files. Co-Authored-By: jason.han --- cmd/stress-model/main.go | 10 +++++++++- cmd/stress-model/main_test.go | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 88c60ffda1..e52a5fa416 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -76,7 +76,15 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, if written[name] { continue } - if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + path := filepath.Join(dir, name) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) || (err == nil && !info.Mode().IsRegular()) { + continue + } + if err != nil { + return stats, err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return stats, err } } diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 97d51d9a1b..8e7bec38d2 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -20,12 +20,19 @@ func TestWriteSplitDropsOnlyWhatALargerGenerationWrote(t *testing.T) { if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 8, Satellites: 1}, dir); err != nil { t.Fatal(err) } + // A directory now standing where the first generation put a plane. + if err := os.Remove(filepath.Join(dir, "plane007.sysml")); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "plane007.sysml"), 0o750); err != nil { + t.Fatal(err) + } if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err != nil { t.Fatal(err) } want := []string{ manifestName, "constellation.sysml", "library.sysml", "notes.sysml", - "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml", "plane009.sysml", + "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml", "plane007.sysml", "plane009.sysml", } if got := listing(t, dir); !slices.Equal(got, want) { t.Errorf("after regenerating with four planes the directory holds %v, want %v", got, want) From 8652630a97c11913fa45898a2186eed9b56244b9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:49:38 +0000 Subject: [PATCH 20/53] chore(stress-model): annotate the manifest read for gosec Co-Authored-By: jason.han --- cmd/stress-model/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index e52a5fa416..e0b590261d 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -94,7 +94,7 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, // readManifest returns the file names the last generation into dir recorded; // none when there was no generation. Only plain names in dir are honored. func readManifest(dir string) ([]string, error) { - data, err := os.ReadFile(filepath.Join(dir, manifestName)) + data, err := os.ReadFile(filepath.Join(dir, manifestName)) // #nosec G304 -- the output directory is named on the command line. if errors.Is(err, os.ErrNotExist) { return nil, nil } From 2a51debbbcb69d7c0216c7a551e4c4ac5fa1ea2f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:32:27 +0000 Subject: [PATCH 21/53] fix(passes): prepare a batch with the model-backed resolver analysis uses PrepareBatch linked metadata bodies through a bare resolver, so an annotation whose type is reached through an inherited or filtered import, a typed usage or another document was left for the workers to link, writing into shared scopes concurrently. The batch is now prepared with the resolver a context analyzes with, semantic model attached. Co-Authored-By: jason.han --- internal/core/model/batch_test.go | 38 +++++++++++++ internal/core/passes/analyze.go | 2 + internal/core/passes/batch_test.go | 91 ++++++++++++++++++++++++++++++ internal/core/passes/pass.go | 14 +++-- 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 internal/core/passes/batch_test.go diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index 2dc49ba01f..5a218a5af9 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -82,6 +82,44 @@ func TestDiagnosticsAllAnswersInTheOrderAsked(t *testing.T) { } } +// A batch whose annotation types are reached through inherited and filtered +// imports, typed usages and another document — the lookups only a model-backed +// resolver answers — reports on many workers what one reports; under the race +// detector this also shows the workers writing nothing into the shared scopes. +func TestParallelBatchLinksAnnotationsFoundThroughTheModel(t *testing.T) { + inputs := []Input{ + {Name: "meta.sysml", Version: 1, Content: []byte(`package Meta { + metadata def Tag { attribute n; } + metadata def Other { attribute m; } +}`)}, + {Name: "model.sysml", Version: 1, Content: []byte(`package P { + part def Base { public import Meta::*; } + part def Sub :> Base; + part def Filtered { public import Meta::*[@Meta::Tag]; } + part b : Base; + part def Owned { metadata def Own { attribute n; } } + part def Derived :> Owned; + part def C { + @Meta::Tag { n = 1; } + @Sub::Tag { n = 2; } + @Filtered::Tag { n = 3; } + @b::Tag { n = 4; } + @Derived::Own { n = 5; } + } +}`)}, + {Name: "audit.sysml", Version: 1, Content: []byte(`package Audit { + part def Ground { part c : P::C; } + part sat : Ground; +}`)}, + } + serial := serialDiagnostics(inputs) + for range 8 { + if got := batchDiagnostics(t, inputs, 8); got != serial { + t.Fatalf("a batch on 8 workers reported:\n%s\nwant, as opening the files one by one does:\n%s", got, serial) + } + } +} + // A batch opened over documents already there replaces them as Open does, so // the index holds each name once. func TestOpenAllReplacesEarlierDocuments(t *testing.T) { diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index 8d08489f63..19551004ec 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -118,11 +118,13 @@ func AnalyzeWithOptions(name string, kind source.Kind, root *ast.RootNamespace, // PrepareBatch links what resolving each document of batch would write into its // scope tree, so AnalyzeInBatch contexts only read the index. Call it alone, first. +// The linker resolves as a context does, model attached, to link the same owners. func PrepareBatch(idx *symbols.Index, batch *Batch) { if idx == nil || batch == nil { return } linker := resolve.New(idx) + attachModel(linker) for _, name := range batch.Documents { linker.LinkMetadataBodies(name) } diff --git a/internal/core/passes/batch_test.go b/internal/core/passes/batch_test.go new file mode 100644 index 0000000000..34d46902a1 --- /dev/null +++ b/internal/core/passes/batch_test.go @@ -0,0 +1,91 @@ +package passes + +import ( + "reflect" + "slices" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/core/ast" + "github.com/Open-MBEE/OpenSysML/internal/core/parser" + "github.com/Open-MBEE/OpenSysML/internal/core/source" + "github.com/Open-MBEE/OpenSysML/internal/core/symbols" +) + +// preparedBatch is a batch whose annotation types are reached every way member +// lookup can go: declared, inherited, through an inherited import, a filtered +// import, a typed usage, and across documents. +var preparedBatch = map[string]string{ + "meta.sysml": `package Meta { + metadata def Tag { attribute n; } + metadata def Other { attribute m; } +}`, + "model.sysml": `package P { + part def Base { public import Meta::*; } + part def Sub :> Base; + part def Filtered { public import Meta::*[@Meta::Tag]; } + part b : Base; + part def Owned { metadata def Own { attribute n; } } + part def Derived :> Owned; + part def C { + @Meta::Tag { n = 1; } + @Sub::Tag { n = 2; } + @Filtered::Tag { n = 3; } + @b::Tag { n = 4; } + @Derived::Own { n = 5; } + @Missing { n = 6; } + } +}`, +} + +func indexedBatch(t *testing.T, docs map[string]string) (*symbols.Index, map[string]*ast.RootNamespace) { + t.Helper() + idx := symbols.NewIndex() + roots := make(map[string]*ast.RootNamespace, len(docs)) + for name, src := range docs { + p := parser.New(source.New(name, []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("%s: parse diagnostics %v", name, p.Diagnostics) + } + idx.AddDocument(name, root) + roots[name] = root + } + idx.ExpandWildcardImports() + return idx, roots +} + +// ownersOf renders the owner of every scope of the tree in tree order, "" for none. +func ownersOf(idx *symbols.Index, root *symbols.Scope) []string { + var out []string + var visit func(*symbols.Scope) + visit = func(s *symbols.Scope) { + owner := "" + if s.Owner() != nil { + owner = idx.GetFQN(s.Owner()) + } + out = append(out, owner) + for _, c := range s.Children() { + visit(c) + } + } + visit(root) + return out +} + +// Preparing a batch links exactly the annotation bodies analyzing its documents +// links, wherever the annotation type is found, so analysis writes nothing. +func TestPrepareBatchLinksWhatAnalysisLinks(t *testing.T) { + const name = "model.sysml" + analyzed, roots := indexedBatch(t, preparedBatch) + AnalyzeWithOptions(name, source.KindSysML, roots[name], nil, analyzed, Options{}) + want := ownersOf(analyzed, analyzed.DocumentRoot(name)) + if !slices.ContainsFunc(want, func(o string) bool { return o != "" }) { + t.Fatal("analysis linked no annotation body, so the comparison proves nothing") + } + + prepared, _ := indexedBatch(t, preparedBatch) + PrepareBatch(prepared, &Batch{Documents: []string{"meta.sysml", name}}) + if got := ownersOf(prepared, prepared.DocumentRoot(name)); !reflect.DeepEqual(got, want) { + t.Errorf("preparing links owners\n%q\nwant those analysis links\n%q", got, want) + } +} diff --git a/internal/core/passes/pass.go b/internal/core/passes/pass.go index e7b64ded52..9495e6ee00 100644 --- a/internal/core/passes/pass.go +++ b/internal/core/passes/pass.go @@ -139,10 +139,16 @@ func (c *Context) Resolver() *resolve.Resolver { // the shared resolver so constraint passes reuse one memoized instance. func (c *Context) Model() *semantics.Model { if c.model == nil { - c.model = semantics.NewModel(c.Resolver()) - // Attach model to resolver for inheritance-aware member resolution - c.Resolver().SetModel(c.model) - c.model.SetArgumentTyper(NewArgumentTyper(c.Resolver(), c.model)) + c.model = attachModel(c.Resolver()) } return c.model } + +// attachModel gives r the semantic model every context resolves with, so member +// lookup through r sees inherited members and typed arguments as the passes do. +func attachModel(r *resolve.Resolver) *semantics.Model { + m := semantics.NewModel(r) + r.SetModel(m) + m.SetArgumentTyper(NewArgumentTyper(r, m)) + return m +} From dbbfb4d9e7d96aec920399eaee2e62759dd393d8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:32:27 +0000 Subject: [PATCH 22/53] fix(stress-model): record every split file before it lands A generation that failed part way left files the manifest did not list, so a later run could not clean up after it. The files are now staged and each is recorded in the manifest before it is moved into place; the manifest is rewritten to the generation's own files only once all landed. Co-Authored-By: jason.han --- README.md | 2 +- cmd/stress-model/main.go | 34 +++++++++++++++++---- cmd/stress-model/main_test.go | 52 +++++++++++++++++++++++++++++++++ docs/project/spec-compliance.md | 2 +- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 570c604c95..435781c81a 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,412 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,415 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (940/940 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index e0b590261d..de2ae27f49 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -53,7 +53,9 @@ func main() { const manifestName = ".stress-model-files" // writeSplit writes the network one file per plane into dir, creating it, and -// removes what an earlier generation wrote there that this one did not. +// removes what an earlier generation wrote there that this one did not. The +// files are staged beside their places and each is recorded in the manifest +// before it is moved in, so a generation that fails leaves nothing unrecorded. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { @@ -63,14 +65,34 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, if err != nil { return stats, err } - written := make(map[string]bool, len(files)) - var manifest strings.Builder + staging, err := os.MkdirTemp(dir, ".stress-model-*") + if err != nil { + return stats, err + } + defer os.RemoveAll(staging) for _, f := range files { - if err := os.WriteFile(filepath.Join(dir, f.Name), []byte(f.Source), 0o600); err != nil { + if err := os.WriteFile(filepath.Join(staging, f.Name), []byte(f.Source), 0o600); err != nil { return stats, err } + } + manifest, err := os.OpenFile(filepath.Join(dir, manifestName), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) // #nosec G304 -- the output directory is named on the command line. + if err != nil { + return stats, err + } + written := make(map[string]bool, len(files)) + var current strings.Builder + for _, f := range files { + current.WriteString(f.Name + "\n") + if _, err := manifest.WriteString(f.Name + "\n"); err != nil { + return stats, errors.Join(err, manifest.Close()) + } + if err := os.Rename(filepath.Join(staging, f.Name), filepath.Join(dir, f.Name)); err != nil { + return stats, errors.Join(err, manifest.Close()) + } written[f.Name] = true - manifest.WriteString(f.Name + "\n") + } + if err := manifest.Close(); err != nil { + return stats, err } for _, name := range previous { if written[name] { @@ -88,7 +110,7 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, err } } - return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(manifest.String()), 0o600) + return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(current.String()), 0o600) } // readManifest returns the file names the last generation into dir recorded; diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 8e7bec38d2..39e4e373ac 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -61,6 +61,58 @@ func TestWriteSplitIntoAnUnknownDirectoryRemovesNothing(t *testing.T) { } } +// A generation that fails part way records every file it did move in, so the +// next one still cleans up after it, and leaves no staging behind. +func TestWriteSplitThatFailsRecordsWhatItWrote(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "notes.sysml"), []byte("package Notes;\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + // A directory standing where the third plane goes fails its move, after the + // library and two planes — one the earlier generation never had — moved in. + blocker := filepath.Join(dir, "plane002.sysml") + if err := os.MkdirAll(filepath.Join(blocker, "inner"), 0o750); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err == nil { + t.Fatal("moving a plane onto a directory should fail the generation") + } + want := []string{manifestName, "constellation.sysml", "library.sysml", "notes.sysml", "plane000.sysml", "plane001.sysml", "plane002.sysml"} + if got := listing(t, dir); !slices.Equal(got, want) { + t.Errorf("the failed generation left %v, want %v: nothing staged, nothing unrecorded", got, want) + } + recorded, err := readManifest(dir) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"constellation.sysml", "library.sysml", "plane000.sysml", "plane001.sysml"} { + if !slices.Contains(recorded, name) { + t.Errorf("the manifest %v should still record %s", recorded, name) + } + } + if slices.Contains(recorded, "notes.sysml") { + t.Errorf("the manifest %v claims the user's notes.sysml", recorded) + } + + // With the directory gone, a smaller generation owns everything it finds. + if err := os.RemoveAll(blocker); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + want = []string{manifestName, "constellation.sysml", "library.sysml", "notes.sysml", "plane000.sysml"} + if got := listing(t, dir); !slices.Equal(got, want) { + t.Errorf("regenerating with one plane left %v, want %v", got, want) + } + if recorded, err := readManifest(dir); err != nil || !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { + t.Errorf("the manifest reads %v, %v; want only the last generation's files", recorded, err) + } +} + func listing(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 37e996d118..6a09c03a5c 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 257 golden execution traces under the default schedule (state×108, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 54 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,412 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,415 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From a20678631f017f18157c15d2a03d26547ce58b00 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:41:48 +0000 Subject: [PATCH 23/53] test(model): pin that asking for part of a batch reads the rest untouched Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- internal/core/model/batch_test.go | 47 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 435781c81a..3079281860 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,415 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,416 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (940/940 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 6a09c03a5c..220cd49a59 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 257 golden execution traces under the default schedule (state×108, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 54 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,415 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,416 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index 5a218a5af9..2f01c6db74 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -120,6 +120,53 @@ func TestParallelBatchLinksAnnotationsFoundThroughTheModel(t *testing.T) { } } +// Asking for some of a batch's documents leaves the others' scopes as they are: +// the workspace-wide passes read them, so the answer is what asking one by one gives. +func TestDiagnosticsAllOverSomeDocumentsMatchesAskingOneByOne(t *testing.T) { + inputs := []Input{ + {Name: "meta.sysml", Version: 1, Content: []byte(`package Meta { + metadata def Tag { attribute n; } + part def Base { public import Meta::*; } + part def Sub :> Base; + part def C { + @Meta::Tag { n = 1; } + @Sub::Tag { n = 2; } + } + part def D; + metadata Tag about D { n = 3; } +}`)}, + {Name: "a.sysml", Version: 1, Content: []byte(`package A { + part def Ground { part c : Meta::C; @Meta::Tag { n = 4; } } +}`)}, + {Name: "b.sysml", Version: 1, Content: []byte(`package B { + part def Station { part c : Meta::C; @Meta::Tag { n = 5; } } +}`)}, + } + asked := []string{"a.sysml", "b.sysml"} + serial := NewWorkspace() + for _, in := range inputs { + serial.Open(in.Name, in.Content, in.Version) + } + var want strings.Builder + for _, name := range asked { + renderDiagnostics(&want, name, serial.Diagnostics(name)) + } + for range 8 { + ws := NewWorkspace() + if err := ws.SetWorkers(8); err != nil { + t.Fatal(err) + } + ws.OpenAll(inputs) + var got strings.Builder + for i, diags := range ws.DiagnosticsAll(asked) { + renderDiagnostics(&got, asked[i], diags) + } + if got.String() != want.String() { + t.Fatalf("asking for two of three documents on 8 workers reported:\n%s\nwant, as asking one by one does:\n%s", got.String(), want.String()) + } + } +} + // A batch opened over documents already there replaces them as Open does, so // the index holds each name once. func TestOpenAllReplacesEarlierDocuments(t *testing.T) { From 8f442e8a4c9ada3f96c0bd826e661769cb0edfe5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:44:46 +0000 Subject: [PATCH 24/53] docs(perf): record the split constellation with one gather per batch The 34-file 1 600-satellite split validates in 7.33 s on eight workers and 19.5 s on one, at the single file's peak RSS, now that the batch shares one gather of the workspace-wide audits; the profile puts the serial gather (3.6 s) and the index install (1.1 s) where the remaining time goes. Co-Authored-By: jason.han --- .../parallel-batch-validation.performance.md | 2 +- docs/internals/performance.md | 110 ++++++------ docs/project/satellite-network-stress-test.md | 159 ++++++------------ 3 files changed, 109 insertions(+), 162 deletions(-) diff --git a/changes/unreleased/parallel-batch-validation.performance.md b/changes/unreleased/parallel-batch-validation.performance.md index 14e65ba1d1..6c803b8808 100644 --- a/changes/unreleased/parallel-batch-validation.performance.md +++ b/changes/unreleased/parallel-batch-validation.performance.md @@ -1 +1 @@ -- **A batch of files is parsed and analysed on a pool of workers.** `sysml -validate`, `-satisfy` and `%load` open the files they are given as one batch: the files are parsed on one worker per CPU, indexed once, wildcard imports are expanded once for the batch instead of once per file, and the documents are analysed in parallel, each with a resolver and semantic model of its own over an index nothing writes meanwhile. The diagnostics are the same at any worker count, in command-line order; `-workers N` or `OPENSYSML_WORKERS` set the pool. On an eight-CPU machine the 1 600-satellite stress constellation split over 34 files validates in 30.9 s against 129 s on one worker, and a 200-satellite split in 1.52 s against 5.35 s; a single file is unaffected. `cmd/stress-model -split-planes ` writes the constellation one file per orbital plane, and `docs/project/satellite-network-stress-test.md` records where the split model's remaining time goes: three passes that gather the whole workspace once per document analysed. +- **A batch of files is parsed and analysed on a pool of workers.** `sysml -validate`, `-satisfy` and `%load` open the files they are given as one batch: the files are parsed on one worker per CPU, indexed once, wildcard imports are expanded once for the batch instead of once per file, and the documents are analysed in parallel, each with a resolver and semantic model of its own over an index nothing writes meanwhile; the facts the workspace-wide audits (OOSEM, MOSA, identity metadata) need of every document are gathered once for the batch and shared by the workers. The diagnostics are the same at any worker count, in command-line order; `-workers N` or `OPENSYSML_WORKERS` set the pool. On an eight-CPU machine the 1 600-satellite stress constellation split over 34 files validates in 7.3 s against 19.5 s on one worker and 18.5 s as a single file, at the single file's peak memory; a 200-satellite split in 0.89 s against 2.30 s. `cmd/stress-model -split-planes ` writes the constellation one file per orbital plane, and `docs/project/satellite-network-stress-test.md` records where the split model's remaining time goes. diff --git a/docs/internals/performance.md b/docs/internals/performance.md index b089912348..6ac37bd8cd 100644 --- a/docs/internals/performance.md +++ b/docs/internals/performance.md @@ -323,12 +323,19 @@ with a private resolver and semantic model, over an index nothing writes while the pool runs. What resolving a document would otherwise link into the scope tree on first use — the owner of a metadata body — is linked for every document of the batch before the pool starts (`passes.PrepareBatch`), so the workers -only read it. Diagnostics come back in the order the files were given and are -the same at any worker count; `-workers` and `OPENSYSML_WORKERS` set the pool, -default one worker per CPU. The earlier cost of indexing files one at a time — -re-expanding wildcard imports over every document loaded so far, quadratic in -the file count — is gone with it: the 34-file constellation below parses and -indexes in 1.4 s. +only read it. The batch carries one `passes.Gathers` (`passes.Batch.Gathers`): +the first context that runs a workspace-wide audit gathers every document's +facts into it, under its lock, and every context reads the same union +afterwards, so the audits gather each document once per batch rather than once +per analysis. The gathers are the batch's, not the workspace's persistent +ones: a private resolver records no dependencies, so facts it gathered could +not be invalidated when what they read changes, and the diagnostics a batch +computes are cached the same way — dropped on any change to the workspace +(`Workspace.batched`) rather than per dependency. Diagnostics come back in the +order the files were given and are the same at any worker count; `-workers` +and `OPENSYSML_WORKERS` set the pool, default one worker per CPU. The earlier +cost of indexing files one at a time — re-expanding wildcard imports over every +document loaded so far, quadratic in the file count — is gone with it. Measured on the satellite constellation split one file per orbital plane (`cmd/stress-model -split-planes`; `Intel Xeon Platinum 8559C`, 8 CPUs, 31 GiB, @@ -336,43 +343,42 @@ Go 1.25.0, one run each, `/usr/bin/time -v`): | model | files | workers | wall | CPU | peak RSS | | ----- | ----- | ------- | ---- | --- | -------- | -| 1 600 satellites, one file | 1 | — | 18.5 s | 136% | 2.46 GB | -| 1 600 satellites, split | 34 | 1 | 129 s | 135% | 1.98 GB | -| | | 2 | 66.1 s | 269% | 2.60 GB | -| | | 4 | 38.6 s | 481% | 3.94 GB | -| | | 8 | 30.9 s | 658% | 7.24 GB | -| 200 satellites, split | 10 | 1 | 5.35 s | 132% | 354 MB | -| | | 8 | 1.52 s | 529% | 840 MB | - -The single file is unchanged (18.5 s here against 18.7 s for the previous -binary on the same run, 2.46 GB against 2.54 GB). The pool gives 4.2× on eight -workers; what it is parallelizing is mostly a cost the split introduced. Three -passes judge a document against the whole workspace — `OOSEMMethodPass`, -`IdentityMetadataPass` and `MOSAPass` gather every document's roots, resolving -`semantics.(*Model).FeatureTypeSet` for every symbol they meet — and each of the -34 analyses gathers afresh in its own model, so the gather is done 34 times -over 34 documents. In the eight-worker CPU profile the three are 75% of 201 s -of samples (112 s, 25 s and 15 s); on one worker they are 118 s of a 128 s -analysis; the documents' own resolution, type checking and remaining passes are -about 10 s in either. The per-worker tables that gather builds are also why -peak RSS grows with the workers — eight workers hold eight workspace-wide -memoizations, live, so `GOMEMLIMIT` cannot reclaim them (`GOMEMLIMIT=2500MiB` -still peaks at 3.51 GB and takes 66 s). Gathering once per batch and handing -the result to every context is the fix; it belongs with the per-document -gather cache of the persistent-workspace design -([scaling to very large models](../project/large-model-scaling-design.md)), -and `passes.Context.Batch` is where a worker receives it. Until then, the -pool's own speedup is measured by `BenchmarkAnalyzeSplitPerDocument` in -`internal/stressmodel`, which analyzes the split's files over one index with -the three passes left out: 3.64 s → 1.04 s at 512 satellites over six files, -one worker against eight, the largest file bounding it. The whole load of the -same split, audits included, is `BenchmarkValidateSplit`: 10.3 s → 2.77 s. +| 1 600 satellites, one file | 1 | — | 18.5 s | 134% | 2.47 GB | +| 1 600 satellites, split | 34 | 1 | 19.5 s | 130% | 2.12 GB | +| | | 2 | 12.3 s | 221% | 2.17 GB | +| | | 4 | 8.86 s | 311% | 2.55 GB | +| | | 8 | 7.33 s | 394% | 2.69 GB | +| 200 satellites, split | 10 | 1 | 2.30 s | 126% | 346 MB | +| | | 8 | 0.89 s | 368% | 489 MB | + +The single file is unchanged at 18.5 s. The split costs the single file's time on one worker and +2.5× less on eight, at the single file's peak RSS. What bounds the pool is +the gather: in the eight-worker CPU profile (7.45 s wall, 28.6 s of samples) +the three audits' gather is 3.6 s — `passes.(*Gathers).oosemOf` 2.7 s, +`identitiesOf` 0.55 s, `mosaOf` 0.41 s — run by one context over all 34 +documents while the other workers wait at the lock, and installing the scope +trees and expanding wildcard imports before the pool (`commitBatch`, 1.1 s) is +serial too; the rest — name resolution 6.8 s, the inherited-name conflict pass +3.3 s, type checking 1.2 s, the collector 5.0 s — is spread over the workers. +Gathering on the pool as well, each worker gathering its own document's facts +into the union before analysis starts, is the step left to the ~5 s the +scaling design sets for this run +([scaling to very large models](../project/large-model-scaling-design.md)). +Before the audits gathered once per batch, each of the 34 analyses gathered +all 34 documents afresh in its own model: 129 s on one worker, 30.9 s on eight +at 7.24 GB peak RSS, 118 s of a 128 s serial analysis in the three passes. +`BenchmarkAnalyzeSplitPerDocument` in `internal/stressmodel` measures the +pool's own speedup with the three audits left out, over the split's six files +at 512 satellites: 3.65 s → 0.94 s, one worker against eight, the largest file +bounding it. The whole load of the same split, audits included, is +`BenchmarkValidateSplit`: 5.77 s → 1.67 s (10.3 s → 2.77 s before the batch gather). Parallelism does not reduce what a load allocates — the 34-file run allocates -39.1 GiB and 422 million objects at any worker count — and the collector -marking eight workers' garbage at once is where the pool loses its remaining -efficiency (`runtime.gcBgMarkWorker` 13% and `runtime.scanobject` 18% of the -eight-worker samples; user time 172 s → 197 s). The allocation sites the +6.8 GiB and 90 million objects at any worker count, against the single file's +5.4 GiB and 86 million — and the collector marking eight workers' garbage at +once is where the pool loses efficiency beyond the gather +(`runtime.gcBgMarkWorker` 17.5% and `runtime.scanobject` 17.9% of the +eight-worker samples; user time 24.7 s → 28.0 s). The allocation sites the pool does not help, from the heap profile of the single-file 1 600-satellite run (62 million sampled objects and 4.3 GiB, of a run that counts 85.5 million allocations and 5.4 GiB), by objects allocated: @@ -539,12 +545,11 @@ constellation, one file, three runs each: At 1 600 satellites: 17.7 s and 5.5 GiB allocated became 20.5 s and 5.8 GiB. The cost falls on whatever analyzes through the workspace's own context: the -LSP server, a REPL session, and `sysml -validate`, which loads through a REPL -session. A batch that analyzes each document in a private `passes.Context` — -its own resolver and model over the read-only index, as a pool of workers -must — has no frames to record into and pays none of it; the workspace's -gathered facts are what such a batch should hand its workers, so that they do -not gather per worker what the workspace gathered once. +LSP server, and a REPL session's typed submissions. `sysml -validate` and +`%load` analyze each file in a private `passes.Context` — its own resolver and +model over the read-only index, as a pool of workers must — with the audits' +facts gathered once for the batch ("What a batch of files costs"), and pay +none of it. ## Notes for further work @@ -553,17 +558,6 @@ not gather per worker what the workspace gathered once. immutable once its index is built, so whether it declares any `about` usages — and which — is computable once at library-index build time; a session would then walk only workspace documents. -- Validating many files in one `sysml -validate` invocation is quadratic in - the file count: the CLI submits files one at a time and every submission - reindexes the workspace, re-running wildcard-import expansion over every - document loaded so far. The 100-file OMG training corpus costs 6.7 s as one - batch where its two halves cost 0.6 s and 3.4 s separately, and a CPU - profile of the batch spends 52% under `model.(*Workspace).setOpenBuffer` → - `reindexLocked` with `symbols.(*Index).ExpandWildcardImports` the largest - component. Submitting a batch as one indexing unit, or expanding wildcard - imports incrementally for documents a new submission cannot affect, would - make a batch cost what its parts cost. - - Runs over a large model spend their time in collection, not in the executor (above). Reducing what a load leaves behind is the lever, since the live model is what each cycle scans. diff --git a/docs/project/satellite-network-stress-test.md b/docs/project/satellite-network-stress-test.md index ddd783b412..044c68ee04 100644 --- a/docs/project/satellite-network-stress-test.md +++ b/docs/project/satellite-network-stress-test.md @@ -148,7 +148,9 @@ smaller constellation leaves no plane of a larger one behind and nothing else in the directory is touched. `sysml -validate` over the files parses them on a pool of workers, indexes them once, expands wildcard imports once and analyzes them on the pool, each document with a resolver and semantic -model of its own; `-workers N` (or `OPENSYSML_WORKERS`) sets the pool, default +model of its own; the facts the workspace-wide audits (OOSEM, MOSA, identity +metadata) need of every document are gathered once for the batch and read by +every worker. `-workers N` (or `OPENSYSML_WORKERS`) sets the pool, default one worker per CPU. The diagnostics are the same at any worker count, in command-line order. @@ -162,111 +164,62 @@ Same machine as above (`Intel Xeon Platinum 8559C`, 8 CPUs, 31 GiB, Go | model | files | workers | wall | user | CPU | allocated | peak RSS | | ----- | ----- | ------- | ---- | ---- | --- | --------- | -------- | -| 200 satellites, one file | 1 | — | 1.95 s | 2.4 s | 130% | 721 MiB | 393 MB | -| 200 satellites, split | 10 | 1 | 5.35 s | 6.9 s | 132% | 1.9 GiB | 354 MB | -| | | 2 | 2.96 s | 7.3 s | 252% | 1.9 GiB | 419 MB | -| | | 4 | 1.88 s | 7.4 s | 405% | 1.9 GiB | 566 MB | -| | | 8 | 1.52 s | 7.6 s | 529% | 1.9 GiB | 840 MB | -| 1 600 satellites, one file | 1 | — | 18.5 s | 24.0 s | 136% | 5.4 GiB | 2.46 GB | -| 1 600 satellites, split | 34 | 1 | 129 s | 172 s | 135% | 39.1 GiB | 1.98 GB | -| | | 2 | 66.1 s | 175 s | 269% | 39.1 GiB | 2.60 GB | -| | | 4 | 38.6 s | 181 s | 481% | 39.1 GiB | 3.94 GB | -| | | 8 | 30.9 s | 197 s | 658% | 39.1 GiB | 7.24 GB | +| 200 satellites, one file | 1 | — | 1.95 s | 2.4 s | 127% | 722 MiB | 383 MB | +| 200 satellites, split | 10 | 1 | 2.30 s | 2.8 s | 126% | 861 MiB | 346 MB | +| | | 2 | 1.51 s | 3.0 s | 210% | 873 MiB | 377 MB | +| | | 4 | 1.02 s | 2.9 s | 300% | 875 MiB | 398 MB | +| | | 8 | 0.89 s | 3.2 s | 368% | 877 MiB | 489 MB | +| 1 600 satellites, one file | 1 | — | 18.5 s | 23.9 s | 134% | 5.4 GiB | 2.47 GB | +| 1 600 satellites, split | 34 | 1 | 19.5 s | 24.7 s | 130% | 6.7 GiB | 2.12 GB | +| | | 2 | 12.3 s | 26.4 s | 221% | 6.8 GiB | 2.17 GB | +| | | 4 | 8.86 s | 26.7 s | 311% | 6.8 GiB | 2.55 GB | +| | | 8 | 7.33 s | 28.0 s | 394% | 6.8 GiB | 2.69 GB | Three things the table says: -- **The pool works as a pool.** Eight workers take the 1 600-satellite split - from 129 s to 30.9 s (4.2×) at 658% CPU, and the 200-satellite split from - 5.35 s to 1.52 s (3.5×). `BenchmarkAnalyzeSplitPerDocument` in - `internal/stressmodel`, which analyzes the split's six files over one index - *without* the three workspace-wide audits below, runs 3.64 s → 1.04 s at 512 - satellites on one worker versus eight — the largest file is about a quarter - of the work, so six files cannot use eight workers better than that. -- **Splitting the file made the serial validation seven times slower, and the - pool does not recover it.** One file validates in 18.5 s; the same model in - 34 files takes 129 s on one worker and 30.9 s on eight. The reason is a - gather that is quadratic in the file count, measured below; it is what the - pool spends most of its time parallelizing, and what a **per-document - gather cache** (the persistent-workspace design in - [scaling to very large models](large-model-scaling-design.md), §3) would - remove. Until it lands, the ~5 s target that design sets for this run is - out of reach: with the gather removed, the split's per-document analysis - is about 10 s of work, and the pool's 4–5× on this machine puts it at - 2–3 s plus a 1.4 s parse and index. -- **Peak RSS grows with the workers, for the same reason.** Each worker's - private semantic model memoizes the kind of every symbol in the workspace - while its gather runs, so eight workers hold eight copies of a - workspace-wide table: 1.98 GB at one worker, 7.24 GB at eight, against - 2.46 GB for the single file. The growth is live memory, not collector - laziness — under `GOMEMLIMIT=2500MiB` the eight-worker run still peaks at - 3.51 GB, runs 104 collections instead of 27 and takes 66 s. A machine - short of memory should set `-workers` down; two workers hold the run at - 2.60 GB, the single file's footprint, for half the serial time. - -**What the gather costs.** A CPU profile of the 34-file run on eight workers -(29 s wall, 201 s of samples) spends 75% of them in three passes that walk -every workspace document to judge the one they analyze — the OOSEM method -audit `OOSEMMethodPass` (112 s, all of it `oosemAudit.gather` computing -`semantics.(*Model).FeatureTypeSet` for every symbol of every root), -`IdentityMetadataPass` (25 s) and the MOSA audit `MOSAPass` (15 s). The passes -are correct to look at the whole workspace; the cost is that each of the 34 -analyses does it afresh in a model of its own, so the gather is done 34 -times over 34 documents. The per-document work is small beside it: name -resolution of the document itself is 7 s of the 201, the inherited-name -conflict pass 5 s, type checking 1.4 s, and parsing all 34 files 1.3 s. On -one worker the same profile shape reads 118 s of gather in 128 s of analysis. -Serial per-document analysis times over the 34 files are even — 3.5 s to -5.5 s each, `constellation.sysml` the largest at 5.2 s — so the pool's -shortfall from 8× (4.2× measured) is not a straggler; it is the collector -marking eight workers' tables at once (`runtime.gcBgMarkWorker` is 13% of -the eight-worker samples, `runtime.scanobject` 18%) and the user time it -adds (172 s → 197 s). - -## Running: instantiation, state machines and satisfaction - -`sysml -satisfy -memstats` loads and validates the model, then for every -`satisfy` assertion instantiates its subject — a satellite's configured usage, -with its subsystem and component tree — starts the mode machine the spacecraft -exhibits, evaluates the summed mass and power over the instance and checks the -requirement's constraint against it. Three assertions per satellite; every one -holds. - -| satellites | assertions | wall | of which load | allocated | peak RSS | -| ---------- | ---------- | ---- | ------------- | --------- | -------- | -| 2 | 6 | 0.10–0.15 s | 0.06 s | 65 MiB | 90 MB | -| 10 | 30 | 0.23–0.25 s | 0.13 s | 122 MiB | 118 MB | -| 50 | 150 | 0.90–0.98 s | 0.49 s | 402 MiB | 210 MB | -| 100 | 300 | 1.86–1.95 s | 0.95 s | 762 MiB | 310 MB | -| 200 | 600 | 3.9–4.1 s | 2.0 s | 1.5 GiB | 530 MB | -| 400 | 1 200 | 8.3 s | 4.5 s | 2.9 GiB | 975 MB | -| 800 | 2 400 | 17.5–17.9 s | 8.7 s | 6.0 GiB | 1.83 GB | -| 1 600 | 4 800 | 38.1 s | 19.0 s | 12.8 GiB | 3.8 GB | -| 3 200 | 9 600 | 83 s | 42 s | 28.9 GiB | 7.6 GB | - -Checking the whole constellation costs **about 2.0× a validation** of the -same model at every size, and the extra is linear: about 3.2 ms, 1.2 MiB -allocated and 0.45 MB of peak RSS per assertion — that is, per instantiation -of a satellite with its twenty components and a running state machine. A CPU -profile at 400 satellites puts the run's own share (30% of samples, the rest -being the load) almost entirely in `runtime.(*Context).Instantiate`: -materializing the parts that run behaviors (`materializeBehavingParts`, -`runsBehaviors`) and shaping the features of each type (`FeaturesOf`, -`semantics.(*Model).ShapeFeatures`). Evaluating the budgets is a small part. - -Re-checking a loaded constellation is much cheaper than the first check, -because the runtime's per-type memoization — feature shapes, which types run -behaviors, the verification cases under each scope — is then warm. -`BenchmarkSatisfy` measures the warm re-check of every assertion: - -| satellites | assertions | warm re-check | allocated | -| ---------- | ---------- | ------------- | --------- | -| 32 | 96 | 3.5 ms | 1.9 MiB | -| 128 | 384 | 20 ms | 11.2 MiB | -| 512 | 1 536 | 144 ms | 104 MiB | - -That is under 0.1 ms per assertion warm, against 3.2 ms cold: the first check -pays for building the runtime's view of every type, and a session that keeps -the model loaded — the REPL, the gRPC service — amortizes it. +- **Splitting the file costs little, and the pool pays it back.** One file + validates in 18.5 s; the same model in 34 files takes 19.5 s on one worker + — the split adds the per-plane packages, their imports and the gather of + 34 documents instead of one — and 7.33 s on eight, 2.5× the single file's + speed. The 200-satellite split goes from 2.30 s to 0.89 s. Peak RSS stays + at the single file's: 2.69 GB at eight workers against 2.47 GB, since the + workers share one gather and hold only their own document's memoization. +- **The gather is what bounds the pool.** Eight workers reach 394% CPU, not + 700%. A CPU profile of the eight-worker run (7.45 s wall, 28.6 s of + samples) puts 3.6 s in the three audits' gather — `Gathers.oosemOf` 2.7 s, + `identitiesOf` 0.55 s, `mosaOf` 0.41 s — which the first context to ask + runs over all 34 documents while the other workers wait for it; before + the pool, installing the 34 scope trees in the index and expanding + wildcard imports (`commitBatch`, 1.1 s) is serial too. Nearly 5 s of the + 7.33 s is therefore on one thread. Gathering the + documents on the pool as well — each worker gathering its own document's + facts into the union, in a context of its own, before analysis starts — + would take most of the 3.6 s off the critical path and is the remaining + step to the ~5 s the [scaling design](large-model-scaling-design.md) sets + for this run. +- **The analysis itself parallelizes.** Outside the gather the profile is + the per-document work: name resolution 6.8 s of the 28.6 s, the + inherited-name conflict pass 3.3 s, type checking 1.2 s, the collector + marking eight workers' allocations at once 5.0 s. On one worker the 34 + analyses are 18.2 s of samples, 3.6 s of them the gather, so a document + averages 0.43 s: no one file is a straggler that would bound the pool the + way the gather does. `BenchmarkAnalyzeSplitPerDocument` in + `internal/stressmodel` analyzes the split's six files over one index with + the three audits left out, the pool's own speedup: 3.65 s → 0.94 s at 512 + satellites on one worker versus eight, the largest file about a quarter of + the work. `BenchmarkValidateSplit`, the whole load audits included, runs + 5.77 s → 1.67 s (10.3 s → 2.77 s before the batch gather). + +**What the gather cost before.** The three audits used to gather every +workspace document once per document analyzed, each analysis in a model of +its own, so the gather was done 34 times over 34 documents: the 34-file split +took 129 s on one worker and 30.9 s on eight (658% CPU, 39.1 GiB allocated, +7.24 GB peak RSS for eight workspace-wide memoizations held at once), against +18.5 s for the single file. The profile of that run spent 75% of its samples +in `OOSEMMethodPass`, `IdentityMetadataPass` and `MOSAPass`; the per-document +work was about 10 s of 128 s on one worker. The per-document gather cache +(`passes.Gathers`) removed the quadratic term for the editor path, and +handing one such gather to a batch's workers removed it for the command line. ## Editing: what an editor pays per keystroke From ecef65efe6fd860354beaf17837402a630de06d6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:05:04 +0000 Subject: [PATCH 25/53] fix(stress-model): remove a recorded plane file only while it reads as written The manifest records each file's content digest beside its name. A record whose move never happened, or a plane the user has since edited, names a file that no longer reads as recorded, and a later generation leaves it. Co-Authored-By: jason.han --- cmd/stress-model/main.go | 87 ++++++++++++++----- cmd/stress-model/main_test.go | 68 +++++++++++++-- docs/project/satellite-network-stress-test.md | 9 +- 3 files changed, 131 insertions(+), 33 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index de2ae27f49..5ff9927079 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -3,6 +3,8 @@ package main import ( + "crypto/sha256" + "encoding/hex" "errors" "flag" "fmt" @@ -49,13 +51,21 @@ func main() { } // manifestName is the file in a -split-planes directory listing what the last -// generation wrote there, so the next one removes only its own files. +// generation wrote there, each file with the digest of its content, so the +// next one removes only its own files and only while they read as written. const manifestName = ".stress-model-files" +// digest is the manifest's identity of a file's content. +func digest(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + // writeSplit writes the network one file per plane into dir, creating it, and // removes what an earlier generation wrote there that this one did not. The // files are staged beside their places and each is recorded in the manifest -// before it is moved in, so a generation that fails leaves nothing unrecorded. +// before it is moved in, so a generation that fails leaves nothing unrecorded; +// a record whose move never happened names a file that does not read as recorded. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { @@ -82,8 +92,9 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, written := make(map[string]bool, len(files)) var current strings.Builder for _, f := range files { - current.WriteString(f.Name + "\n") - if _, err := manifest.WriteString(f.Name + "\n"); err != nil { + line := digest([]byte(f.Source)) + " " + f.Name + "\n" + current.WriteString(line) + if _, err := manifest.WriteString(line); err != nil { return stats, errors.Join(err, manifest.Close()) } if err := os.Rename(filepath.Join(staging, f.Name), filepath.Join(dir, f.Name)); err != nil { @@ -94,28 +105,56 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, if err := manifest.Close(); err != nil { return stats, err } - for _, name := range previous { - if written[name] { - continue - } - path := filepath.Join(dir, name) - info, err := os.Lstat(path) - if errors.Is(err, os.ErrNotExist) || (err == nil && !info.Mode().IsRegular()) { + for _, rec := range previous { + if written[rec.Name] { continue } - if err != nil { - return stats, err - } - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + if err := removeIfRecorded(filepath.Join(dir, rec.Name), rec.Digest); err != nil { return stats, err } } return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(current.String()), 0o600) } -// readManifest returns the file names the last generation into dir recorded; -// none when there was no generation. Only plain names in dir are honored. -func readManifest(dir string) ([]string, error) { +// removeIfRecorded removes the regular file at path when its content still +// has the recorded digest; anything else standing there is not the generator's. +func removeIfRecorded(path, recorded string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + content, err := os.ReadFile(path) // #nosec G304 -- path is a recorded name under the output directory. + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if digest(content) != recorded { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +// record is one manifest line: a file the last generation wrote, and the +// digest of what it wrote there. +type record struct { + Name string + Digest string +} + +// readManifest returns what the last generation into dir recorded; nothing +// when there was no generation. Only plain names in dir with a digest are honored. +func readManifest(dir string) ([]record, error) { data, err := os.ReadFile(filepath.Join(dir, manifestName)) // #nosec G304 -- the output directory is named on the command line. if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -123,11 +162,13 @@ func readManifest(dir string) ([]string, error) { if err != nil { return nil, err } - var names []string - for _, name := range strings.Split(string(data), "\n") { - if name != "" && name != manifestName && filepath.Base(name) == name { - names = append(names, name) + var records []record + for _, line := range strings.Split(string(data), "\n") { + sum, name, ok := strings.Cut(line, " ") + if !ok || sum == "" || name == "" || name == manifestName || filepath.Base(name) != name { + continue } + records = append(records, record{Name: name, Digest: sum}) } - return names, nil + return records, nil } diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 39e4e373ac..ce33bf6f9b 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -84,10 +84,7 @@ func TestWriteSplitThatFailsRecordsWhatItWrote(t *testing.T) { if got := listing(t, dir); !slices.Equal(got, want) { t.Errorf("the failed generation left %v, want %v: nothing staged, nothing unrecorded", got, want) } - recorded, err := readManifest(dir) - if err != nil { - t.Fatal(err) - } + recorded := recordedNames(t, dir) for _, name := range []string{"constellation.sysml", "library.sysml", "plane000.sysml", "plane001.sysml"} { if !slices.Contains(recorded, name) { t.Errorf("the manifest %v should still record %s", recorded, name) @@ -108,11 +105,70 @@ func TestWriteSplitThatFailsRecordsWhatItWrote(t *testing.T) { if got := listing(t, dir); !slices.Equal(got, want) { t.Errorf("regenerating with one plane left %v, want %v", got, want) } - if recorded, err := readManifest(dir); err != nil || !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { - t.Errorf("the manifest reads %v, %v; want only the last generation's files", recorded, err) + if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { + t.Errorf("the manifest reads %v; want only the last generation's files", recorded) + } +} + +// A generation interrupted between recording a file and moving it in leaves +// the manifest naming whatever stood at that name; a later generation must +// not take that for its own. Neither may it take a recorded file the user +// has since edited. +func TestWriteSplitRemovesOnlyFilesThatReadAsRecorded(t *testing.T) { + dir := t.TempDir() + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 3, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + // The user's plane007.sysml, recorded by an interrupted larger generation + // that never moved its own plane007 over it. + if err := os.WriteFile(filepath.Join(dir, "plane007.sysml"), []byte("package Mine;\n"), 0o600); err != nil { + t.Fatal(err) + } + interrupted := digest([]byte("package Plane7;\n")) + " plane007.sysml\n" + manifest, err := os.OpenFile(filepath.Join(dir, manifestName), os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := manifest.WriteString(interrupted); err != nil { + t.Fatal(err) + } + if err := manifest.Close(); err != nil { + t.Fatal(err) + } + // The user's edit of a plane the generator did write. + if err := os.WriteFile(filepath.Join(dir, "plane002.sysml"), []byte("package Edited;\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + want := []string{manifestName, "constellation.sysml", "library.sysml", "plane000.sysml", "plane002.sysml", "plane007.sysml"} + if got := listing(t, dir); !slices.Equal(got, want) { + t.Errorf("regenerating with one plane left %v, want %v: plane001 removed, the user's files kept", got, want) + } + for name, content := range map[string]string{"plane002.sysml": "package Edited;\n", "plane007.sysml": "package Mine;\n"} { + if got, err := os.ReadFile(filepath.Join(dir, name)); err != nil || string(got) != content { + t.Errorf("%s reads %q, %v; want it untouched", name, got, err) + } + } + if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { + t.Errorf("the manifest reads %v; want only the last generation's files", recorded) } } +func recordedNames(t *testing.T, dir string) []string { + t.Helper() + records, err := readManifest(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, r := range records { + names = append(names, r.Name) + } + return names +} + func listing(t *testing.T, dir string) []string { t.Helper() entries, err := os.ReadDir(dir) diff --git a/docs/project/satellite-network-stress-test.md b/docs/project/satellite-network-stress-test.md index 044c68ee04..1fb57dd6c1 100644 --- a/docs/project/satellite-network-stress-test.md +++ b/docs/project/satellite-network-stress-test.md @@ -142,10 +142,11 @@ writes the same constellation as one `.sysml` per orbital plane plus (the ground segment and the cross-plane network); the split declares the same network and analyzes to the same diagnostics as the single file (`TestSatelliteNetworkSplitValidates`). The generator lists what it wrote in -`.stress-model-files` beside the model, and a later generation into the same -directory removes only the files on that list it did not write again, so a -smaller constellation leaves no plane of a larger one behind and nothing else -in the directory is touched. `sysml -validate` over the files parses +`.stress-model-files` beside the model, each with a digest of its content, and +a later generation into the same directory removes only the files on that list +it did not write again and that still read as written, so a smaller +constellation leaves no plane of a larger one behind and nothing else in the +directory — a file of the user's, or an edited plane — is touched. `sysml -validate` over the files parses them on a pool of workers, indexes them once, expands wildcard imports once and analyzes them on the pool, each document with a resolver and semantic model of its own; the facts the workspace-wide audits (OOSEM, MOSA, identity From fa7a0d04e1ddcf3e90594124019258266d67af07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:09:47 +0000 Subject: [PATCH 26/53] docs(repl): a repeated root name resolves by document name order, not load order Two files declaring the same root package are ordered as the workspace orders documents, by name, so which declaration a reference reaches does not depend on the order the files were given in. A test pins both orders. Co-Authored-By: jason.han --- .../unreleased/per-file-documents.changed.md | 2 +- docs/guide/04-repl.md | 3 +- docs/reference/cli.md | 5 ++-- internal/repl/filedocs_test.go | 28 +++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/changes/unreleased/per-file-documents.changed.md b/changes/unreleased/per-file-documents.changed.md index e2861aa57e..bd0eca889a 100644 --- a/changes/unreleased/per-file-documents.changed.md +++ b/changes/unreleased/per-file-documents.changed.md @@ -1 +1 @@ -- **Every file the command line or `%load` reads is a document of its own.** `sysml -validate`, `-satisfy`, `-e` and the REPL's `%load` used to join the files they were given into one buffer with the typed transcript, so a model split over files was analysed as if it were one file; each file is now a workspace document, indexed with the others and analysed on its own, exactly as the editor and the OMG corpus gates analyse it. Two things a reader will observe: a root-level import in one file (`private import ScalarValues::*;`) no longer serves the other files on the command line or the prompt after `%load` — a KerML root import surfaces its names in its own document's root namespace only, as `docs/project/spec-compliance.md` records — and two files that both declare `package A` are no longer reported as `Duplicate of other owned member name`: they are two root namespaces of one name, and a reference to `A` resolves to the first declaration, as the pilot implementation resolves it. Root packages stay reachable from every file and from the prompt through the global namespace. A differential test runs every multi-file directory of the fixtures and of the four OMG corpora through the command line and through a workspace and asserts the same diagnostics. +- **Every file the command line or `%load` reads is a document of its own.** `sysml -validate`, `-satisfy`, `-e` and the REPL's `%load` used to join the files they were given into one buffer with the typed transcript, so a model split over files was analysed as if it were one file; each file is now a workspace document, indexed with the others and analysed on its own, exactly as the editor and the OMG corpus gates analyse it. Two things a reader will observe: a root-level import in one file (`private import ScalarValues::*;`) no longer serves the other files on the command line or the prompt after `%load` — a KerML root import surfaces its names in its own document's root namespace only, as `docs/project/spec-compliance.md` records — and two files that both declare `package A` are no longer reported as `Duplicate of other owned member name`: they are two root namespaces of one name, and a reference to `A` resolves to the declaration in the file whose name sorts first (the order the editor gives documents, whatever order the files were given in), as the pilot implementation resolves a repeated root name to the first. Root packages stay reachable from every file and from the prompt through the global namespace. A differential test runs every multi-file directory of the fixtures and of the four OMG corpora through the command line and through a workspace and asserts the same diagnostics. diff --git a/docs/guide/04-repl.md b/docs/guide/04-repl.md index 22c609434c..2fa04774be 100644 --- a/docs/guide/04-repl.md +++ b/docs/guide/04-repl.md @@ -148,7 +148,8 @@ note: P is opened by more than one loaded file; each opening stays a declaration Each file keeps its own identity, which is what lets you reload one of them and replace only its own contribution. If the two openings were merged into a single namespace, an edit to one file could silently delete the other file's members. A qualified reference to `P` resolves to -the first declaration loaded, so `P::A` resolves while `P::B` does not; an unqualified reference +the declaration in the file whose name sorts first — the order the editor gives documents, +not the order the files were loaded in — so `P::A` resolves while `P::B` does not; an unqualified reference from one opening to the other does not resolve either. Entering a package at the prompt is unaffected: it still merges into the package already in the session. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0036384930..2c56ade770 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -97,8 +97,9 @@ package another declares resolves. Two consequences follow: at the top of `types.sysml` does not make `Real` resolvable in `instances.sysml`; each file imports what it uses. - Two files that both declare `package A` are two root packages of that name, not a duplicate. - A reference to `A` resolves to the first declaration on the command line, so `A::x` resolves - where `x` is a member of that first declaration. + A reference to `A` resolves to the declaration in the file whose name sorts first (the + order the editor and the workspace give documents, whatever order the files were given + in), so `A::x` resolves where `x` is a member of that declaration. ## Real-World Examples diff --git a/internal/repl/filedocs_test.go b/internal/repl/filedocs_test.go index 067c838846..4749f20761 100644 --- a/internal/repl/filedocs_test.go +++ b/internal/repl/filedocs_test.go @@ -67,6 +67,34 @@ func TestLoadedFilesDeclaringOneRootPackageAreNotDuplicates(t *testing.T) { } } +// Which declaration of a repeated root name a reference reaches follows the +// documents' name order, as the workspace orders them, not the command line. +func TestRepeatedRootPackageResolvesByDocumentNameNotLoadOrder(t *testing.T) { + dir := t.TempDir() + first := writeFile(t, filepath.Join(dir, "first.sysml"), "package A { part def X; }\n") + second := writeFile(t, filepath.Join(dir, "second.sysml"), "package A { part def Y; }\n") + useX := writeFile(t, filepath.Join(dir, "use-x.sysml"), "package C { part x : A::X; }\n") + useY := writeFile(t, filepath.Join(dir, "use-y.sysml"), "package D { part y : A::Y; }\n") + + for _, paths := range [][]string{{first, second, useX, useY}, {second, first, useY, useX}} { + got := cliDiagnostics(t, paths) + if len(got) != 1 || !strings.Contains(got[0], "use-y.sysml") || !strings.Contains(got[0], "A::Y") { + t.Errorf("loading %v reported:\n%s\nwant only A::Y unresolved: first.sysml's A sorts first", basenames(paths), strings.Join(got, "\n")) + } + if want := workspaceDiagnostics(t, paths); strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("the CLI reported:\n%s\nwant, as the workspace does:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + } +} + +func basenames(paths []string) []string { + out := make([]string, len(paths)) + for i, p := range paths { + out[i] = filepath.Base(p) + } + return out +} + // The prompt's transcript is a document of its own too: a root-level import in // a loaded file does not serve what is typed after %load, though the file's // root packages are reachable through the global namespace as before. From 20f511927d8527a642cccb1625a1924d5ee46e3f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:17:33 +0000 Subject: [PATCH 27/53] fix(model): OpenAll keeps a removal of a document opened while it parsed A batch reserved each name by the document it held, so a name absent at the start, opened and removed by another caller meanwhile, read as untouched at commit and the batch installed its stale document over the removal. Each name now carries a change count, moved by every install and removal; the batch reserves and installs over the count, so any change stands. Co-Authored-By: jason.han --- internal/core/model/batch.go | 22 ++++++++++++---------- internal/core/model/batch_test.go | 11 +++++++++-- internal/core/model/workspace.go | 16 +++++++++++----- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go index e51c07a03d..e7765c6318 100644 --- a/internal/core/model/batch.go +++ b/internal/core/model/batch.go @@ -99,32 +99,34 @@ func (w *Workspace) OpenAll(inputs []Input) { w.commitBatch(was, docs) } -// reserveBatch is the document each input's name holds as the batch starts, -// which is what commitBatch installs over. -func (w *Workspace) reserveBatch(inputs []Input) map[string]*Document { +// reserveBatch is each input's name's change count as the batch starts, which is +// what commitBatch installs over: a name opened and removed meanwhile is absent +// again, but its count has moved. +func (w *Workspace) reserveBatch(inputs []Input) map[string]uint64 { w.mu.RLock() defer w.mu.RUnlock() - was := make(map[string]*Document, len(inputs)) + was := make(map[string]uint64, len(inputs)) for _, in := range inputs { - was[in.Name] = w.docs[in.Name] + was[in.Name] = w.changes[in.Name] } return was } -// commitBatch installs the parsed documents whose name still holds what the -// batch reserved; a name changed since keeps its newer document. -func (w *Workspace) commitBatch(was map[string]*Document, docs []*Document) { +// commitBatch installs the parsed documents whose name is as the batch reserved +// it; a name changed since keeps its newer state. +func (w *Workspace) commitBatch(was map[string]uint64, docs []*Document) { w.mu.Lock() defer w.mu.Unlock() var installed []string for _, doc := range docs { - if w.docs[doc.Name] != was[doc.Name] { + if w.changes[doc.Name] != was[doc.Name] { continue } w.open[doc.Name] = true w.docs[doc.Name] = doc + w.changes[doc.Name]++ + was[doc.Name] = w.changes[doc.Name] w.index.AddBuiltDocument(doc.Name, doc.AST, doc.Scope) - was[doc.Name] = doc installed = append(installed, doc.Name) } if len(installed) > 0 { diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index 2f01c6db74..aa18546b33 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -185,8 +185,9 @@ func TestOpenAllReplacesEarlierDocuments(t *testing.T) { } // A document another caller changes while a batch parses keeps that change: the -// batch installs only over what it reserved, so an edit, a buffer opened and a -// removal made meanwhile all stand, and only the untouched name is opened. +// batch installs only over what it reserved, so an edit, a buffer opened, a +// removal and an open-then-remove made meanwhile all stand, and only the +// untouched name is opened. func TestOpenAllKeepsAChangeMadeWhileItParsed(t *testing.T) { ws := NewWorkspace() ws.Open("a.sysml", []byte("package A { part def Old; }"), 1) @@ -196,6 +197,7 @@ func TestOpenAllKeepsAChangeMadeWhileItParsed(t *testing.T) { {Name: "b.sysml", Content: []byte("package B { part def Batch; }"), Version: 1}, {Name: "c.sysml", Content: []byte("package C { part def Batch; }"), Version: 1}, {Name: "d.sysml", Content: []byte("package D { part def Batch; }"), Version: 2}, + {Name: "e.sysml", Content: []byte("package E { part def Batch; }"), Version: 1}, } was := ws.reserveBatch(inputs) docs := make([]*Document, len(inputs)) @@ -205,6 +207,8 @@ func TestOpenAllKeepsAChangeMadeWhileItParsed(t *testing.T) { ws.Update("a.sysml", []byte("package A { part def Edited; }"), 3) ws.Open("b.sysml", []byte("package B { part def Opened; }"), 1) ws.Remove("d.sysml") + ws.Open("e.sysml", []byte("package E { part def Opened; }"), 1) + ws.Remove("e.sysml") ws.commitBatch(was, docs) if doc := ws.Document("a.sysml"); doc == nil || doc.Version != 3 { @@ -222,6 +226,9 @@ func TestOpenAllKeepsAChangeMadeWhileItParsed(t *testing.T) { if ws.Document("d.sysml") != nil || len(ws.LookupQualified("D::Batch")) != 0 { t.Error("d.sysml was removed while the batch parsed and should stay removed") } + if ws.Document("e.sysml") != nil || len(ws.LookupQualified("E::Batch")) != 0 { + t.Error("e.sysml was opened and removed while the batch parsed and should stay removed") + } if doc := ws.Document("c.sysml"); doc == nil || !ws.IsOpen("c.sysml") { t.Errorf("c.sysml, untouched meanwhile, should be opened by the batch, got %+v", doc) } diff --git a/internal/core/model/workspace.go b/internal/core/model/workspace.go index 0ce8ccec99..4bf86418fb 100644 --- a/internal/core/model/workspace.go +++ b/internal/core/model/workspace.go @@ -20,11 +20,14 @@ import ( // document set plus the global symbol index. Mutations are serialized under a // write lock; reads take a read lock. type Workspace struct { - mu sync.RWMutex - docs map[string]*Document - onDisk map[string][]byte // last-known on-disk bytes, used when a doc is not open - open map[string]bool // names with an authoritative open buffer - index *symbols.Index + mu sync.RWMutex + docs map[string]*Document + // changes counts the times each name's document was installed or removed, + // so a batch can tell a name changed under it even when it is absent again. + changes map[string]uint64 + onDisk map[string][]byte // last-known on-disk bytes, used when a doc is not open + open map[string]bool // names with an authoritative open buffer + index *symbols.Index // libBase is the frozen library index this workspace's index overlays, nil // for a caller-built index. libBase *symbols.Index @@ -86,6 +89,7 @@ func NewWorkspace(opts ...Option) *Workspace { func NewWorkspaceWithIndex(idx *symbols.Index, opts ...Option) *Workspace { w := &Workspace{ docs: map[string]*Document{}, + changes: map[string]uint64{}, onDisk: map[string][]byte{}, open: map[string]bool{}, index: idx, @@ -217,6 +221,7 @@ func (w *Workspace) Remove(name string) { func (w *Workspace) reindexLocked(name string, content []byte, version int) { doc := newDocument(name, content, version) w.docs[name] = doc + w.changes[name]++ w.index.AddBuiltDocument(name, doc.AST, doc.Scope) // removes stale entries first w.index.ExpandWildcardImports() w.invalidateLocked(name) @@ -225,6 +230,7 @@ func (w *Workspace) reindexLocked(name string, content []byte, version int) { // removeLocked drops name from the document set and index. Caller holds the lock. func (w *Workspace) removeLocked(name string) { delete(w.docs, name) + w.changes[name]++ w.index.RemoveDocument(name) w.invalidateLocked(name) } From 839d74d71bacdcea65f8054a846450b79a85d974 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:23:29 +0000 Subject: [PATCH 28/53] docs: recount the test inventory Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 94064eb703..411707a892 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,425 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,426 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 940 conformance cases, 257 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (940/940 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 377 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index c04bc5485d..6a2d8507df 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 257 golden execution traces under the default schedule (state×108, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 54 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,425 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,426 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From 47a35aa5dc8ba9d7fd5af1807f49d3e7a834cd61 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:03:43 +0000 Subject: [PATCH 29/53] docs(repl): note the prompt evaluation rules kept as they are under per-file loading Co-Authored-By: jason.han --- docs/guide/04-repl.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/guide/04-repl.md b/docs/guide/04-repl.md index 2fa04774be..49d090bd49 100644 --- a/docs/guide/04-repl.md +++ b/docs/guide/04-repl.md @@ -153,6 +153,20 @@ not the order the files were loaded in — so `P::A` resolves while `P::B` does from one opening to the other does not resolve either. Entering a package at the prompt is unaffected: it still merges into the package already in the session. +### Known behaviour + +Two evaluation rules of the prompt predate per-file loading and are kept as they are; both are +open to change. A prompt expression (`%eval`, the arguments of `%calc` and `%sweep`) evaluates in +the last namespace declared, and when a loaded file declares it, the expression sees that file's +root imports even though a typed declaration does not — after `%load a.sysml` with +`private import ScalarValues::*; package A { … }`, `%eval 1.5 as Real` resolves while a typed +`attribute y : Real;` reports `Real` unresolved; the alternative is a fallback to the transcript's +own root. A qualified command argument (`%eval A::y`, `%print A::y`) is looked up in the symbol +index, which holds every document's declarations, so with two loaded `package A` it reaches the +member of the second `A` that a reference in a model or in a compound expression cannot (`%eval A` +alone reports `A` ambiguous); the alternatives are a resolver-based lookup for the evaluating +commands, or rejecting a root that resolves ambiguously. + ## Finding what a build offers `%search` looks for a substring across the declared and library symbols and reports the kind of From 584a35874519fef12ec6e35c2ee1f2901802a107 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:13:01 +0000 Subject: [PATCH 30/53] docs: regenerate the documentation counts after merging develop Co-Authored-By: jason.han --- docs/project/spec-compliance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index e13a31ecf0..766da6e2a6 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,426 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,433 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From edfe5815df1d02e457024604f71789b79fbb4d8b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:22:40 +0000 Subject: [PATCH 31/53] fix(resolve): re-own an annotation body when its metadata definition is rebuilt Linking an annotation body to the metadata definition it names set the body scope's owner only while it had none, so a body kept the symbol of a definition whose document had since been reloaded or removed: its values then resolved against attributes the definition no longer declares, and a diagnostics run over an unchanged annotating document differed from a fresh workspace's. Resolving a prefix and LinkMetadataBodies now both set the owner to the definition resolved now, nil included, writing only where it changed so a prepared batch still resolves without a write. Co-Authored-By: jason.han --- internal/core/model/batch_test.go | 39 +++++++++++ internal/core/model/workspace_test.go | 25 +++++++ internal/core/resolve/document.go | 23 +++--- internal/core/resolve/link_metadata_test.go | 77 +++++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index aa18546b33..ad6ada1079 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -353,3 +353,42 @@ func renderDiagnostics(b *strings.Builder, name string, diags []passes.Diagnosti fmt.Fprintf(b, "%s: %+v\n", filepath.Base(name), d) } } + +// A batch that reloads a metadata definition's document leaves the annotating +// documents in place; the next batch reports them over the definition now +// indexed, on any number of workers, as a fresh workspace does. +func TestOpenAllReloadedMetadataDefinitionReownsAnnotationBodies(t *testing.T) { + use := Input{Name: "use.sysml", Version: 1, Content: []byte("package Use { private import Meta::*; part def C { @M { a = b; } } }")} + first := Input{Name: "meta.sysml", Version: 1, Content: []byte("package Meta { metadata def M { attribute a; attribute b; } }")} + edited := Input{Name: "meta.sysml", Version: 2, Content: []byte("package Meta { metadata def M { attribute a; attribute c; } }")} + names := []string{"meta.sysml", "use.sysml"} + fresh := NewWorkspace() + fresh.OpenAll([]Input{edited, use}) + var want strings.Builder + for i, diags := range fresh.DiagnosticsAll(names) { + renderDiagnostics(&want, names[i], diags) + } + if !strings.Contains(want.String(), "unresolved reference: b") { + t.Fatalf("a fresh workspace should report b, which M no longer declares, got:\n%s", want.String()) + } + for _, workers := range []int{1, 8} { + ws := NewWorkspace() + if err := ws.SetWorkers(workers); err != nil { + t.Fatal(err) + } + ws.OpenAll([]Input{first, use}) + for i, diags := range ws.DiagnosticsAll(names) { + if len(diags) != 0 { + t.Fatalf("%d workers, before the reload, %s: %v", workers, names[i], diags) + } + } + ws.OpenAll([]Input{edited}) + var got strings.Builder + for i, diags := range ws.DiagnosticsAll(names) { + renderDiagnostics(&got, names[i], diags) + } + if got.String() != want.String() { + t.Errorf("%d workers, after reloading M:\n%s\nwant, as a fresh workspace reports:\n%s", workers, got.String(), want.String()) + } + } +} diff --git a/internal/core/model/workspace_test.go b/internal/core/model/workspace_test.go index 98dab8cae5..d7ea1dd9ca 100644 --- a/internal/core/model/workspace_test.go +++ b/internal/core/model/workspace_test.go @@ -193,3 +193,28 @@ func codesOf(diags []passes.Diagnostic) map[string]int { } return out } + +// An annotation body resolves its values against the metadata definition it +// names; when the definition's document changes, an unchanged annotating +// document reports over the definition now indexed, as a fresh workspace does. +func TestWorkspaceReloadedMetadataDefinitionReownsAnnotationBodies(t *testing.T) { + use := []byte("package Use { private import Meta::*; part def C { @M { a = b; } } }") + ws := NewWorkspace() + ws.Open("meta.sysml", []byte("package Meta { metadata def M { attribute a; attribute b; } }"), 1) + ws.Open("use.sysml", use, 1) + if diags := ws.Diagnostics("use.sysml"); len(diags) != 0 { + t.Fatalf("before the edit: %v", diags) + } + edited := []byte("package Meta { metadata def M { attribute a; attribute c; } }") + ws.Update("meta.sysml", edited, 2) + fresh := NewWorkspace() + fresh.Open("meta.sysml", edited, 1) + fresh.Open("use.sysml", use, 1) + want := fresh.Diagnostics("use.sysml") + if len(want) == 0 { + t.Fatal("a fresh workspace should report b, which M no longer declares") + } + if got := ws.Diagnostics("use.sysml"); !reflect.DeepEqual(got, want) { + t.Errorf("after editing M: %v\nwant, as a fresh workspace reports: %v", got, want) + } +} diff --git a/internal/core/resolve/document.go b/internal/core/resolve/document.go index 59acdd8bea..3305e5e5a1 100644 --- a/internal/core/resolve/document.go +++ b/internal/core/resolve/document.go @@ -547,18 +547,15 @@ func (r *Resolver) resolveMetadataPrefix(names, parent *symbols.Scope, prefix *a r.ResolveQualified(names, a) } owner := r.metadataBodyOwner(names, prefix) - if owner == nil { - return - } body := parent.ChildFor(prefix) if body == nil { return } // Body values resolve against the metadata definition, not the annotated element. - if body.Owner() == nil { - body.SetOwner(owner) + linkMetadataBody(body, owner) + if owner != nil { + r.resolveMetadataBody(body, prefix.Body) } - r.resolveMetadataBody(body, prefix.Body) } // metadataBodyOwner is the metadata definition the body of prefix resolves against, @@ -574,6 +571,14 @@ func (r *Resolver) metadataBodyOwner(names *symbols.Scope, prefix *ast.PrefixMet return owner } +// linkMetadataBody makes owner, the metadata definition the body resolves against +// now, the body scope's owner; a definition it kept from an earlier build goes. +func linkMetadataBody(body *symbols.Scope, owner *symbols.Symbol) { + if body.Owner() != owner { + body.SetOwner(owner) + } +} + // LinkMetadataBodies sets every annotation body scope's owner as resolving the // document would, so resolving it afterwards writes nothing to the scope tree. func (r *Resolver) LinkMetadataBodies(name string) { @@ -589,10 +594,8 @@ func (r *Resolver) LinkMetadataBodies(name string) { func (r *Resolver) linkMetadataBodies(scope *symbols.Scope) { for _, child := range scope.Children() { - if prefix, ok := child.Node().(*ast.PrefixMetadata); ok && child.Owner() == nil { - if owner := r.metadataBodyOwner(r.bodyScope(scope, child.Annotated()), prefix); owner != nil { - child.SetOwner(owner) - } + if prefix, ok := child.Node().(*ast.PrefixMetadata); ok { + linkMetadataBody(child, r.metadataBodyOwner(r.bodyScope(scope, child.Annotated()), prefix)) } r.linkMetadataBodies(child) } diff --git a/internal/core/resolve/link_metadata_test.go b/internal/core/resolve/link_metadata_test.go index b198418365..003aee7eff 100644 --- a/internal/core/resolve/link_metadata_test.go +++ b/internal/core/resolve/link_metadata_test.go @@ -153,3 +153,80 @@ func TestLinkMetadataBodiesReachesEveryBody(t *testing.T) { t.Errorf("%d bodies, %d owned; want 5 and 4", bodies, owned) } } + +// parsedRoot parses src as name, failing the test on a parse diagnostic. +func parsedRoot(t *testing.T, name, src string) *ast.RootNamespace { + t.Helper() + p := parser.New(source.New(name, []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("%s: parse diagnostics: %v", name, p.Diagnostics) + } + return root +} + +// An annotation body owned by a definition of another document follows that +// document: once it is replaced, resolving or linking the unchanged annotating +// document owns the body by the definition now indexed, and once the definition +// is gone the body is owned by nothing. +func TestMetadataBodyOwnerFollowsTheDefinitionsDocument(t *testing.T) { + const meta, use = "meta.sysml", "use.sysml" + const useSrc = "package Use { private import Meta::*; part def C { @M { a = b; } } }" + bodyOwner := func(idx *symbols.Index) *symbols.Symbol { + body := idx.DocumentRoot(use).Children()[0].Children()[0].Children()[0] + if _, ok := body.Node().(*ast.PrefixMetadata); !ok { + t.Fatalf("C's first scope is a %T, want the annotation body", body.Node()) + } + return body.Owner() + } + for _, relink := range []struct { + name string + do func(r *resolve.Resolver, root *ast.RootNamespace) + }{ + {"resolving", func(r *resolve.Resolver, root *ast.RootNamespace) { r.ResolveDocument(use, root) }}, + {"linking", func(r *resolve.Resolver, _ *ast.RootNamespace) { r.LinkMetadataBodies(use) }}, + } { + idx := symbols.NewIndex() + idx.AddDocument(meta, parsedRoot(t, meta, "package Meta { metadata def M { attribute a; attribute b; } }")) + useRoot := parsedRoot(t, use, useSrc) + idx.AddDocument(use, useRoot) + idx.ExpandWildcardImports() + r := resolve.New(idx) + r.SetModel(semantics.NewModel(r)) + r.ResolveDocument(use, useRoot) + first := bodyOwner(idx) + if first == nil || idx.GetFQN(first) != "Meta::M" { + t.Fatalf("%s: body owned by %v before the reload, want Meta::M", relink.name, first) + } + + idx.AddDocument(meta, parsedRoot(t, meta, "package Meta { metadata def M { attribute a; attribute c; } }")) + idx.ExpandWildcardImports() + r = resolve.New(idx) + r.SetModel(semantics.NewModel(r)) + relink.do(r, useRoot) + if got := bodyOwner(idx); got == nil || idx.GetFQN(got) != "Meta::M" || got == first { + t.Errorf("%s after the definition's document was replaced: body owned by %s, want the Meta::M now indexed", relink.name, fqnOrNone(idx, got)) + } + if syms := idx.LookupQualified("Meta::M::b"); len(syms) != 0 { + t.Fatalf("Meta::M::b should be gone from the index, found %d", len(syms)) + } + + idx.RemoveDocument(meta) + r = resolve.New(idx) + r.SetModel(semantics.NewModel(r)) + relink.do(r, useRoot) + if got := bodyOwner(idx); got != nil { + t.Errorf("%s after the definition's document was removed: body owned by %s, want nothing", relink.name, fqnOrNone(idx, got)) + } + } +} + +func fqnOrNone(idx *symbols.Index, sym *symbols.Symbol) string { + if sym == nil { + return "nothing" + } + if fqn := idx.GetFQN(sym); fqn != "" { + return fqn + } + return sym.Name + " of an earlier " + sym.DocName +} From 0e241a6b05276fc5e260e81fb16e8fe8edf8bcd1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:22:40 +0000 Subject: [PATCH 32/53] fix(stress-model): write a split only over the last generation's unchanged files -split-planes moved each generated file over whatever stood at its name, so a file of the user's at a plane's name, or a plane the user had edited since the last generation, was replaced without notice. Every destination is now checked before anything is staged: a file is replaced only when the manifest records it and it still reads as recorded; one that is unrecorded, changed since, or not a regular file fails the generation, all such names reported together, with nothing written, moved or removed. The two tests that expected the generator to write over a user's file at a generated name, and to fail part way through on a directory at one, now expect it to refuse before writing. Co-Authored-By: jason.han --- cmd/stress-model/main.go | 43 +++++++++ cmd/stress-model/main_test.go | 93 ++++++++++++------- docs/project/satellite-network-stress-test.md | 10 +- 3 files changed, 106 insertions(+), 40 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 5ff9927079..2321ccebe7 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -75,6 +75,9 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, if err != nil { return stats, err } + if err := replaceable(dir, files, previous); err != nil { + return stats, err + } staging, err := os.MkdirTemp(dir, ".stress-model-*") if err != nil { return stats, err @@ -116,6 +119,46 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(current.String()), 0o600) } +// replaceable reports an error naming every file the generation would write +// over that the last generation did not write, or that has changed since: the +// generator replaces only its own unedited output, and writes nothing otherwise. +func replaceable(dir string, files []stressmodel.File, previous []record) error { + recorded := make(map[string]string, len(previous)) + for _, rec := range previous { + recorded[rec.Name] = rec.Digest + } + var errs []error + for _, f := range files { + path := filepath.Join(dir, f.Name) + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + want, ok := recorded[f.Name] + switch { + case !info.Mode().IsRegular(): + errs = append(errs, fmt.Errorf("%s: not a regular file", path)) + case !ok: + errs = append(errs, fmt.Errorf("%s: not written by the last generation into %s", path, dir)) + default: + content, err := os.ReadFile(path) // #nosec G304 -- path is a generated name under the output directory. + if err != nil { + return err + } + if digest(content) != want { + errs = append(errs, fmt.Errorf("%s: changed since the last generation wrote it", path)) + } + } + } + if len(errs) == 0 { + return nil + } + return fmt.Errorf("nothing written: %w", errors.Join(errs...)) +} + // removeIfRecorded removes the regular file at path when its content still // has the recorded digest; anything else standing there is not the generator's. func removeIfRecorded(path, recorded string) error { diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index ce33bf6f9b..daa2e330cb 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "github.com/Open-MBEE/OpenSysML/internal/stressmodel" @@ -42,71 +43,91 @@ func TestWriteSplitDropsOnlyWhatALargerGenerationWrote(t *testing.T) { } } -func TestWriteSplitIntoAnUnknownDirectoryRemovesNothing(t *testing.T) { +// A first generation into a directory holding files at generated names writes +// nothing: it names every one of them, leaves each as it was and makes no manifest. +func TestWriteSplitWritesNothingOverFilesItDidNotWrite(t *testing.T) { dir := t.TempDir() for _, name := range []string{"plane000.sysml", "plane005.sysml", "library.sysml"} { if err := os.WriteFile(filepath.Join(dir, name), []byte("package Mine;\n"), 0o600); err != nil { t.Fatal(err) } } - if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir); err != nil { - t.Fatal(err) + _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir) + if err == nil { + t.Fatal("generating over the user's library.sysml and plane000.sysml should fail") } - want := []string{manifestName, "constellation.sysml", "library.sysml", "plane000.sysml", "plane001.sysml", "plane005.sysml"} - if got := listing(t, dir); !slices.Equal(got, want) { - t.Errorf("a first generation into a directory left %v, want %v", got, want) + for _, name := range []string{"library.sysml", "plane000.sysml"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("the error %q does not name %s", err, name) + } } - if got, err := os.ReadFile(filepath.Join(dir, "plane005.sysml")); err != nil || string(got) != "package Mine;\n" { - t.Errorf("plane005.sysml, which no generation wrote, reads %q, %v; want it untouched", got, err) + if strings.Contains(err.Error(), "plane005") { + t.Errorf("the error %q names plane005.sysml, which no generation of two planes writes", err) + } + if got := listing(t, dir); !slices.Equal(got, []string{"library.sysml", "plane000.sysml", "plane005.sysml"}) { + t.Errorf("the refused generation left %v; want the user's three files alone", got) + } + for _, name := range []string{"plane000.sysml", "plane005.sysml", "library.sysml"} { + if got, err := os.ReadFile(filepath.Join(dir, name)); err != nil || string(got) != "package Mine;\n" { + t.Errorf("%s reads %q, %v; want it untouched", name, got, err) + } } } -// A generation that fails part way records every file it did move in, so the -// next one still cleans up after it, and leaves no staging behind. -func TestWriteSplitThatFailsRecordsWhatItWrote(t *testing.T) { +// A later generation replaces only what the last one wrote and nothing has +// changed since: a plane the user edited, a directory at a plane's name and a +// file of the user's at one all stop it before it writes a byte, and are named +// together; with them out of the way the same generation goes through. +func TestWriteSplitReplacesOnlyItsOwnUnchangedOutput(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "notes.sysml"), []byte("package Notes;\n"), 0o600); err != nil { t.Fatal(err) } - if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir); err != nil { t.Fatal(err) } - // A directory standing where the third plane goes fails its move, after the - // library and two planes — one the earlier generation never had — moved in. - blocker := filepath.Join(dir, "plane002.sysml") - if err := os.MkdirAll(filepath.Join(blocker, "inner"), 0o750); err != nil { + firstListing, firstManifest := listing(t, dir), recordedNames(t, dir) + if err := os.WriteFile(filepath.Join(dir, "plane001.sysml"), []byte("package Edited;\n"), 0o600); err != nil { t.Fatal(err) } - if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err == nil { - t.Fatal("moving a plane onto a directory should fail the generation") + if err := os.Mkdir(filepath.Join(dir, "plane002.sysml"), 0o750); err != nil { + t.Fatal(err) } - want := []string{manifestName, "constellation.sysml", "library.sysml", "notes.sysml", "plane000.sysml", "plane001.sysml", "plane002.sysml"} - if got := listing(t, dir); !slices.Equal(got, want) { - t.Errorf("the failed generation left %v, want %v: nothing staged, nothing unrecorded", got, want) + if err := os.WriteFile(filepath.Join(dir, "plane003.sysml"), []byte("package Mine;\n"), 0o600); err != nil { + t.Fatal(err) + } + before := append(slices.Clone(firstListing), "plane002.sysml", "plane003.sysml") + slices.Sort(before) + _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir) + if err == nil { + t.Fatal("generating over an edited plane, a directory and the user's file should fail") } - recorded := recordedNames(t, dir) - for _, name := range []string{"constellation.sysml", "library.sysml", "plane000.sysml", "plane001.sysml"} { - if !slices.Contains(recorded, name) { - t.Errorf("the manifest %v should still record %s", recorded, name) + for _, name := range []string{"plane001.sysml", "plane002.sysml", "plane003.sysml"} { + if !strings.Contains(err.Error(), name) { + t.Errorf("the error %q does not name %s", err, name) } } - if slices.Contains(recorded, "notes.sysml") { - t.Errorf("the manifest %v claims the user's notes.sysml", recorded) + if got := listing(t, dir); !slices.Equal(got, before) { + t.Errorf("the refused generation left %v, want %v: nothing written, nothing staged", got, before) + } + if got, err := os.ReadFile(filepath.Join(dir, "plane001.sysml")); err != nil || string(got) != "package Edited;\n" { + t.Errorf("the edited plane001.sysml reads %q, %v; want the edit kept", got, err) + } + if got := recordedNames(t, dir); !slices.Equal(got, firstManifest) { + t.Errorf("the manifest reads %v after the refused generation, want %v as before", got, firstManifest) } - // With the directory gone, a smaller generation owns everything it finds. - if err := os.RemoveAll(blocker); err != nil { - t.Fatal(err) + for _, name := range []string{"plane001.sysml", "plane002.sysml", "plane003.sysml"} { + if err := os.RemoveAll(filepath.Join(dir, name)); err != nil { + t.Fatal(err) + } } - if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 4, Satellites: 1}, dir); err != nil { t.Fatal(err) } - want = []string{manifestName, "constellation.sysml", "library.sysml", "notes.sysml", "plane000.sysml"} + want := []string{manifestName, "constellation.sysml", "library.sysml", "notes.sysml", "plane000.sysml", "plane001.sysml", "plane002.sysml", "plane003.sysml"} if got := listing(t, dir); !slices.Equal(got, want) { - t.Errorf("regenerating with one plane left %v, want %v", got, want) - } - if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { - t.Errorf("the manifest reads %v; want only the last generation's files", recorded) + t.Errorf("regenerating with four planes left %v, want %v", got, want) } } diff --git a/docs/project/satellite-network-stress-test.md b/docs/project/satellite-network-stress-test.md index 1fb57dd6c1..e54a7f4be9 100644 --- a/docs/project/satellite-network-stress-test.md +++ b/docs/project/satellite-network-stress-test.md @@ -143,10 +143,12 @@ writes the same constellation as one `.sysml` per orbital plane plus network and analyzes to the same diagnostics as the single file (`TestSatelliteNetworkSplitValidates`). The generator lists what it wrote in `.stress-model-files` beside the model, each with a digest of its content, and -a later generation into the same directory removes only the files on that list -it did not write again and that still read as written, so a smaller -constellation leaves no plane of a larger one behind and nothing else in the -directory — a file of the user's, or an edited plane — is touched. `sysml -validate` over the files parses +a later generation into the same directory replaces only files on that list +that still read as written, removes those of them it did not write again, and +writes nothing at all when a file it would write is not on the list, has been +edited or is not a regular file — so a smaller constellation leaves no plane of +a larger one behind and nothing else in the directory, a file of the user's or +an edited plane, is touched. `sysml -validate` over the files parses them on a pool of workers, indexes them once, expands wildcard imports once and analyzes them on the pool, each document with a resolver and semantic model of its own; the facts the workspace-wide audits (OOSEM, MOSA, identity From 0d5ca25ae6b50e1cd1157221fb47870e517c0cec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:31:01 +0000 Subject: [PATCH 33/53] fix(stress-model): replace the split manifest in one rename instead of truncating it Co-Authored-By: jason.han --- cmd/stress-model/main.go | 13 ++++++++++- cmd/stress-model/main_test.go | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 2321ccebe7..85883193df 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -66,6 +66,7 @@ func digest(content []byte) string { // files are staged beside their places and each is recorded in the manifest // before it is moved in, so a generation that fails leaves nothing unrecorded; // a record whose move never happened names a file that does not read as recorded. +// The manifest of just this generation's files then replaces it in one rename. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { @@ -116,7 +117,17 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, err } } - return stats, os.WriteFile(filepath.Join(dir, manifestName), []byte(current.String()), 0o600) + return stats, replaceManifest(dir, staging, current.String()) +} + +// replaceManifest puts content in place as the manifest in one rename, so the +// appended record of the generation stands until the whole replacement does. +func replaceManifest(dir, staging, content string) error { + next := filepath.Join(staging, manifestName) + if err := os.WriteFile(next, []byte(content), 0o600); err != nil { + return err + } + return os.Rename(next, filepath.Join(dir, manifestName)) } // replaceable reports an error naming every file the generation would write diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index daa2e330cb..176ade34ff 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -1,8 +1,10 @@ package main import ( + "io" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -177,6 +179,46 @@ func TestWriteSplitRemovesOnlyFilesThatReadAsRecorded(t *testing.T) { } } +// The manifest that stood while a generation ran is never cut short: the run +// appends its records to it and then replaces it whole, so a reader holding the +// old file sees the earlier records followed by every new one, and the name +// holds the complete new manifest. +func TestWriteSplitReplacesTheManifestWholeInsteadOfTruncatingIt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("a renamed-over file cannot be held open on Windows") + } + dir := t.TempDir() + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(filepath.Join(dir, manifestName)) + if err != nil { + t.Fatal(err) + } + old, err := os.Open(filepath.Join(dir, manifestName)) + if err != nil { + t.Fatal(err) + } + defer old.Close() + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(filepath.Join(dir, manifestName)) + if err != nil { + t.Fatal(err) + } + held, err := io.ReadAll(old) + if err != nil { + t.Fatal(err) + } + if string(held) != string(first)+string(second) { + t.Errorf("the manifest held open through the second generation reads:\n%s\nwant the first manifest followed by the second's records:\n%s%s", held, first, second) + } + if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { + t.Errorf("the manifest reads %v; want only the second generation's files", recorded) + } +} + func recordedNames(t *testing.T, dir string) []string { t.Helper() records, err := readManifest(dir) From e57874b2a71c618cb7f95aaf8005d38903abbc74 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:41:52 +0000 Subject: [PATCH 34/53] docs: regenerate the documentation counts after merging develop Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2b9bd3375b..3c4cf876dd 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,447 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,454 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (945/945 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 378 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 8eda90b789..2eeb4040d1 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,447 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,454 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From feff1e8642bb27a1fe1aab1f930bb1d321641cac Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:43:10 +0000 Subject: [PATCH 35/53] docs: regenerate the documentation counts after merging develop Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3c4cf876dd..4f76b19cf4 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,454 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,476 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (945/945 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 378 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 2eeb4040d1..36565c5052 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,454 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,476 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From f96c40aecaa54589e6edb17e9104db7f740ebbd8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:11:46 +0000 Subject: [PATCH 36/53] docs: regenerate the documentation counts after merging develop Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c82911530e..222985e15f 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,463 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,470 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (945/945 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 378 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index ee0054ebba..27e50f51c9 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,463 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,470 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From dd58a8e2e27474f5841cbccd7c8b155b8181e29e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:12:10 +0000 Subject: [PATCH 37/53] docs: regenerate the documentation counts after merging develop Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 222985e15f..c224031815 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,470 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,492 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (945/945 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 378 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 27e50f51c9..d2e2149c63 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,470 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,492 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From 537558c9000cd3733ed4be643bce02f7b328bfd5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:39:16 +0000 Subject: [PATCH 38/53] fix(stress-model): publish the split manifest whole before and after moving files in A generation recorded each file in the standing manifest, by appending, just before renaming it into place, so a failure mid-way left the manifest with a second record for names it never moved. The next run then read the name as changed and refused, and cleanup could act on whichever record matched. The generation now replaces the manifest in one rename twice: before any move, with the last generation's records followed by its own, and after all moves, with its own alone. A name an interrupted generation recorded reads as either record, so a retry replaces it and a smaller run removes what was moved in. Co-Authored-By: jason.han --- README.md | 2 +- cmd/stress-model/main.go | 63 ++++++++++++++++--------- cmd/stress-model/main_test.go | 84 +++++++++++++++++++++++++++++++-- docs/project/spec-compliance.md | 2 +- 4 files changed, 122 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index c224031815..699882e3b1 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,492 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,494 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 945 conformance cases, 262 golden traces, 465 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (945/945 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 378 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 346 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 85883193df..11bb53b15b 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/Open-MBEE/OpenSysML/internal/stressmodel" @@ -63,10 +64,11 @@ func digest(content []byte) string { // writeSplit writes the network one file per plane into dir, creating it, and // removes what an earlier generation wrote there that this one did not. The -// files are staged beside their places and each is recorded in the manifest -// before it is moved in, so a generation that fails leaves nothing unrecorded; -// a record whose move never happened names a file that does not read as recorded. -// The manifest of just this generation's files then replaces it in one rename. +// files are staged beside their places and all are recorded in the manifest, +// beside the last generation's records, before any is moved in: a generation +// that fails leaves nothing unrecorded, and every name it touched reads as one +// record or the other, so the next run replaces it. The manifest of just this +// generation's files then takes the place of both. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { @@ -89,26 +91,16 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, err } } - manifest, err := os.OpenFile(filepath.Join(dir, manifestName), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) // #nosec G304 -- the output directory is named on the command line. - if err != nil { + if err := replaceManifest(dir, staging, intended(previous, files)); err != nil { return stats, err } written := make(map[string]bool, len(files)) - var current strings.Builder for _, f := range files { - line := digest([]byte(f.Source)) + " " + f.Name + "\n" - current.WriteString(line) - if _, err := manifest.WriteString(line); err != nil { - return stats, errors.Join(err, manifest.Close()) - } if err := os.Rename(filepath.Join(staging, f.Name), filepath.Join(dir, f.Name)); err != nil { - return stats, errors.Join(err, manifest.Close()) + return stats, err } written[f.Name] = true } - if err := manifest.Close(); err != nil { - return stats, err - } for _, rec := range previous { if written[rec.Name] { continue @@ -117,11 +109,37 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, err } } - return stats, replaceManifest(dir, staging, current.String()) + return stats, replaceManifest(dir, staging, manifestOf(files)) +} + +// intended is the manifest that stands while a generation moves its files in: +// the last generation's records, then a record of each file not already among them. +func intended(previous []record, files []stressmodel.File) string { + var b strings.Builder + standing := make(map[record]bool, len(previous)) + for _, rec := range previous { + standing[rec] = true + b.WriteString(rec.Digest + " " + rec.Name + "\n") + } + for _, f := range files { + if rec := (record{Name: f.Name, Digest: digest([]byte(f.Source))}); !standing[rec] { + b.WriteString(rec.Digest + " " + rec.Name + "\n") + } + } + return b.String() +} + +// manifestOf records each of files with the digest of its content. +func manifestOf(files []stressmodel.File) string { + var b strings.Builder + for _, f := range files { + b.WriteString(digest([]byte(f.Source)) + " " + f.Name + "\n") + } + return b.String() } -// replaceManifest puts content in place as the manifest in one rename, so the -// appended record of the generation stands until the whole replacement does. +// replaceManifest puts content in place as the manifest in one rename, so a +// manifest that stands is never cut short or half written. func replaceManifest(dir, staging, content string) error { next := filepath.Join(staging, manifestName) if err := os.WriteFile(next, []byte(content), 0o600); err != nil { @@ -133,10 +151,11 @@ func replaceManifest(dir, staging, content string) error { // replaceable reports an error naming every file the generation would write // over that the last generation did not write, or that has changed since: the // generator replaces only its own unedited output, and writes nothing otherwise. +// A name an interrupted generation recorded reads as either of its records. func replaceable(dir string, files []stressmodel.File, previous []record) error { - recorded := make(map[string]string, len(previous)) + recorded := make(map[string][]string, len(previous)) for _, rec := range previous { - recorded[rec.Name] = rec.Digest + recorded[rec.Name] = append(recorded[rec.Name], rec.Digest) } var errs []error for _, f := range files { @@ -159,7 +178,7 @@ func replaceable(dir string, files []stressmodel.File, previous []record) error if err != nil { return err } - if digest(content) != want { + if !slices.Contains(want, digest(content)) { errs = append(errs, fmt.Errorf("%s: changed since the last generation wrote it", path)) } } diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 176ade34ff..6bf896b794 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -179,10 +179,81 @@ func TestWriteSplitRemovesOnlyFilesThatReadAsRecorded(t *testing.T) { } } +// A generation interrupted between recording its files and moving them all in +// leaves some names with the new content and some with the old; the next run +// replaces both, and records each name once again. +func TestWriteSplitRetriesAnInterruptedGeneration(t *testing.T) { + dir := t.TempDir() + first := stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1} + second := stressmodel.SatelliteNetwork{Planes: 2, Satellites: 2} + if _, err := writeSplit(first, dir); err != nil { + t.Fatal(err) + } + files, _ := second.Split() + if files[1].Name != "plane000.sysml" || files[2].Name != "plane001.sysml" { + t.Fatalf("the split writes %s, %s second and third; the test wants the two planes", files[1].Name, files[2].Name) + } + // The second generation recorded both planes and moved only the first in. + if err := os.WriteFile(filepath.Join(dir, files[1].Name), []byte(files[1].Source), 0o600); err != nil { + t.Fatal(err) + } + if err := interruptManifest(dir, files[1:3]); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(second, dir); err != nil { + t.Fatalf("the interrupted generation cannot be retried: %v", err) + } + for _, f := range files { + if got, err := os.ReadFile(filepath.Join(dir, f.Name)); err != nil || string(got) != f.Source { + t.Errorf("%s does not read as the retried generation writes it (%v)", f.Name, err) + } + } + if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "plane001.sysml", "constellation.sysml"}) { + t.Errorf("the manifest reads %v; want each of the retried generation's files once", recorded) + } +} + +// A file an interrupted generation recorded and moved in is still the +// generator's: a smaller generation afterwards removes it like any other. +func TestWriteSplitRemovesWhatAnInterruptedGenerationMovedIn(t *testing.T) { + dir := t.TempDir() + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + files, _ := stressmodel.SatelliteNetwork{Planes: 2, Satellites: 2}.Split() + if err := os.WriteFile(filepath.Join(dir, files[2].Name), []byte(files[2].Source), 0o600); err != nil { + t.Fatal(err) + } + if err := interruptManifest(dir, files[1:3]); err != nil { + t.Fatal(err) + } + if _, err := writeSplit(stressmodel.SatelliteNetwork{Planes: 1, Satellites: 1}, dir); err != nil { + t.Fatal(err) + } + want := []string{manifestName, "constellation.sysml", "library.sysml", "plane000.sysml"} + if got := listing(t, dir); !slices.Equal(got, want) { + t.Errorf("regenerating with one plane left %v, want %v", got, want) + } +} + +// interruptManifest records files in dir's manifest the way a generation does +// before moving them in, and stops there. +func interruptManifest(dir string, files []stressmodel.File) error { + previous, err := readManifest(dir) + if err != nil { + return err + } + staging, err := os.MkdirTemp(dir, ".stress-model-*") + if err != nil { + return err + } + defer os.RemoveAll(staging) + return replaceManifest(dir, staging, intended(previous, files)) +} + // The manifest that stood while a generation ran is never cut short: the run -// appends its records to it and then replaces it whole, so a reader holding the -// old file sees the earlier records followed by every new one, and the name -// holds the complete new manifest. +// replaces it whole, before moving its files in and again after, so a reader +// holding the old file sees it unchanged and the name holds a complete manifest. func TestWriteSplitReplacesTheManifestWholeInsteadOfTruncatingIt(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("a renamed-over file cannot be held open on Windows") @@ -211,8 +282,11 @@ func TestWriteSplitReplacesTheManifestWholeInsteadOfTruncatingIt(t *testing.T) { if err != nil { t.Fatal(err) } - if string(held) != string(first)+string(second) { - t.Errorf("the manifest held open through the second generation reads:\n%s\nwant the first manifest followed by the second's records:\n%s%s", held, first, second) + if string(held) != string(first) { + t.Errorf("the manifest held open through the second generation reads:\n%s\nwant the first manifest unchanged:\n%s", held, first) + } + if string(second) == string(first) { + t.Error("the second generation did not replace the manifest") } if recorded := recordedNames(t, dir); !slices.Equal(recorded, []string{"library.sysml", "plane000.sysml", "constellation.sysml"}) { t.Errorf("the manifest reads %v; want only the second generation's files", recorded) diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index d2e2149c63..e07a6b0d44 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 262 golden execution traces under the default schedule (state×113, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 64 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,492 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,494 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From 156c739176ccb67d129a57387cd31d733bc44a87 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:40:20 +0000 Subject: [PATCH 39/53] docs(stress-model): shorten the split-manifest comments Co-Authored-By: jason.han --- cmd/stress-model/main.go | 9 ++------- cmd/stress-model/main_test.go | 10 ++++------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/cmd/stress-model/main.go b/cmd/stress-model/main.go index 11bb53b15b..78a24e0769 100644 --- a/cmd/stress-model/main.go +++ b/cmd/stress-model/main.go @@ -63,12 +63,7 @@ func digest(content []byte) string { } // writeSplit writes the network one file per plane into dir, creating it, and -// removes what an earlier generation wrote there that this one did not. The -// files are staged beside their places and all are recorded in the manifest, -// beside the last generation's records, before any is moved in: a generation -// that fails leaves nothing unrecorded, and every name it touched reads as one -// record or the other, so the next run replaces it. The manifest of just this -// generation's files then takes the place of both. +// removes what an earlier generation wrote there that this one did not. func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, error) { files, stats := n.Split() if err := os.MkdirAll(dir, 0o750); err != nil { @@ -91,6 +86,7 @@ func writeSplit(n stressmodel.SatelliteNetwork, dir string) (stressmodel.Stats, return stats, err } } + // Recorded before any move, so a failed generation leaves nothing unrecorded. if err := replaceManifest(dir, staging, intended(previous, files)); err != nil { return stats, err } @@ -151,7 +147,6 @@ func replaceManifest(dir, staging, content string) error { // replaceable reports an error naming every file the generation would write // over that the last generation did not write, or that has changed since: the // generator replaces only its own unedited output, and writes nothing otherwise. -// A name an interrupted generation recorded reads as either of its records. func replaceable(dir string, files []stressmodel.File, previous []record) error { recorded := make(map[string][]string, len(previous)) for _, rec := range previous { diff --git a/cmd/stress-model/main_test.go b/cmd/stress-model/main_test.go index 6bf896b794..4b6711a2ed 100644 --- a/cmd/stress-model/main_test.go +++ b/cmd/stress-model/main_test.go @@ -179,9 +179,8 @@ func TestWriteSplitRemovesOnlyFilesThatReadAsRecorded(t *testing.T) { } } -// A generation interrupted between recording its files and moving them all in -// leaves some names with the new content and some with the old; the next run -// replaces both, and records each name once again. +// After a generation interrupted between recording and moving its files in, +// names hold the new content or the old; the next run replaces both. func TestWriteSplitRetriesAnInterruptedGeneration(t *testing.T) { dir := t.TempDir() first := stressmodel.SatelliteNetwork{Planes: 2, Satellites: 1} @@ -251,9 +250,8 @@ func interruptManifest(dir string, files []stressmodel.File) error { return replaceManifest(dir, staging, intended(previous, files)) } -// The manifest that stood while a generation ran is never cut short: the run -// replaces it whole, before moving its files in and again after, so a reader -// holding the old file sees it unchanged and the name holds a complete manifest. +// The manifest that stood while a generation ran is replaced whole, never +// cut short: a reader holding it sees it unchanged. func TestWriteSplitReplacesTheManifestWholeInsteadOfTruncatingIt(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("a renamed-over file cannot be held open on Windows") From 087764ab99371caab1fdee432e82adcab78313f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:02:44 +0000 Subject: [PATCH 40/53] chore: rerun the pull request checks Co-Authored-By: jason.han From 513af0a7fe7e088d05491842bba18f5e6940a27d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:42:27 +0000 Subject: [PATCH 41/53] docs(performance): record the second parse a load pays and its measured cost Co-Authored-By: jason.han --- docs/internals/performance.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/internals/performance.md b/docs/internals/performance.md index 6ac37bd8cd..2b5963f23d 100644 --- a/docs/internals/performance.md +++ b/docs/internals/performance.md @@ -400,6 +400,14 @@ allocation per token, per name or per lookup where one per file, or none, would serve — the snapshot decoder's node table, allocated as one block, is the model — and each is to be measured on its own before it is changed. +One parse per load is spent twice: the REPL parses each file to accept it (the +names it declares, whether it closes its own text) and the workspace parses the +same bytes again as the document. The 34 files of the split (17 MB) parse in +1.03 s serially, so the second parse is ~1 s of the one-worker 19.5 s and +~0.13 s of the eight-worker wall. Carrying the accepted tree into the workspace +batch would recover it; it is a change to what `model.Input` owns and is left +to be measured on its own. + ## What a process pays before the model Every `sysml`, `sysml-lsp` and `sysml-grpc` start, and every test that builds a From e6df51b4aa31a247ac77aae50b32ef3ccc45af1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:05:39 +0000 Subject: [PATCH 42/53] docs: regenerate the test-function count Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6b2341b3f..0c4d4ceb4a 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,511 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,537 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (961/961 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 379 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 347 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 6b7a62e090..943d788d71 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 270 golden execution traces under the default schedule (state×121, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 80 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,511 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,537 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From db3c87a4193bf7e94c2ed235c9b287a7864a2b1e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:10:10 +0000 Subject: [PATCH 43/53] docs: regenerate the test-function count Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c4d4ceb4a..c042f82182 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,537 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,561 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (961/961 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 379 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 347 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 943d788d71..53b8e8474b 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 270 golden execution traces under the default schedule (state×121, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 80 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,537 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,561 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From 433777af5df17847bc60aaad032037a2ed347cd7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:36:23 +0000 Subject: [PATCH 44/53] fix(passes): prepare every workspace document's annotation bodies for a batch PrepareBatch linked the annotation bodies of the batch's documents only, but the workspace-wide gathers a batch's workers share read every workspace document. A batch asked for some documents therefore gathered the others' bodies unlinked: an unlinked body's attribute is filed under its bare name, where the identity gather made it collide with a declared id of a document that was asked for. PrepareBatch now links every workspace document, so a batch of any subset reports what one over every document does. Co-Authored-By: jason.han --- internal/core/model/batch_test.go | 36 ++++++++++++++++++++++++++++++ internal/core/passes/analyze.go | 9 ++++---- internal/core/passes/batch_test.go | 8 +++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index ad6ada1079..b09862b33d 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -167,6 +167,42 @@ func TestDiagnosticsAllOverSomeDocumentsMatchesAskingOneByOne(t *testing.T) { } } +// A batch of one document prepares the others' annotation bodies too: the +// identity gather files an unlinked body's attribute under its bare name, where +// it would collide with a declared id of the document asked for. +func TestDiagnosticsAllOverOneDocumentPreparesTheOthersBodies(t *testing.T) { + meta := Input{Name: "meta.sysml", Version: 1, Content: []byte(`package Meta { + metadata def M { attribute rationale : ScalarValues::String; attribute fallback : ScalarValues::String = "d"; } + part def X { @M { rationale = fallback; } } +}`)} + check := Input{Name: "check.sysml", Version: 1, Content: []byte(`package Check { + part def Y { @IdentityMetadata::ElementId { id = "rationale"; } } +}`)} + inputs := []Input{meta, check} + serial := NewWorkspace() + for _, in := range inputs { + serial.Open(in.Name, in.Content, in.Version) + } + serial.Diagnostics(meta.Name) + var want strings.Builder + renderDiagnostics(&want, check.Name, serial.Diagnostics(check.Name)) + if !strings.Contains(want.String(), "identity-unscoped-id") || strings.Contains(want.String(), "identity-duplicate-id") { + t.Fatalf("check.sysml over resolved documents should report only the unscoped id, got:\n%s", want.String()) + } + for _, workers := range []int{1, 8} { + ws := NewWorkspace() + if err := ws.SetWorkers(workers); err != nil { + t.Fatal(err) + } + ws.OpenAll(inputs) + var got strings.Builder + renderDiagnostics(&got, check.Name, ws.DiagnosticsAll([]string{check.Name})[0]) + if got.String() != want.String() { + t.Errorf("asking for check.sysml alone on %d workers reported:\n%s\nwant:\n%s", workers, got.String(), want.String()) + } + } +} + // A batch opened over documents already there replaces them as Open does, so // the index holds each name once. func TestOpenAllReplacesEarlierDocuments(t *testing.T) { diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index ea9a674fba..478597a0f5 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -117,16 +117,17 @@ func AnalyzeWithOptions(name string, kind source.Kind, root *ast.RootNamespace, return analyze(NewContextWithOptions(name, kind, idx, parseDiags, opts), root) } -// PrepareBatch links what resolving each document of batch would write into its -// scope tree, so AnalyzeInBatch contexts only read the index. Call it alone, first. -// The linker resolves as a context does, model attached, to link the same owners. +// PrepareBatch links what resolving every workspace document would write into +// its scope tree, so AnalyzeInBatch contexts only read the index. Call it alone, +// first. Every document, not only the batch's: the workspace-wide gathers read +// them all. The linker resolves as a context does, model attached. func PrepareBatch(idx *symbols.Index, batch *Batch) { if idx == nil || batch == nil { return } linker := resolve.New(idx) attachModel(linker) - for _, name := range batch.Documents { + for _, name := range idx.WorkspaceDocuments() { linker.LinkMetadataBodies(name) } } diff --git a/internal/core/passes/batch_test.go b/internal/core/passes/batch_test.go index 34d46902a1..616a655f78 100644 --- a/internal/core/passes/batch_test.go +++ b/internal/core/passes/batch_test.go @@ -88,4 +88,12 @@ func TestPrepareBatchLinksWhatAnalysisLinks(t *testing.T) { if got := ownersOf(prepared, prepared.DocumentRoot(name)); !reflect.DeepEqual(got, want) { t.Errorf("preparing links owners\n%q\nwant those analysis links\n%q", got, want) } + + // The gathers read every workspace document, so a batch of one document + // prepares the others' bodies too. + others, _ := indexedBatch(t, preparedBatch) + PrepareBatch(others, &Batch{Documents: []string{"meta.sysml"}}) + if got := ownersOf(others, others.DocumentRoot(name)); !reflect.DeepEqual(got, want) { + t.Errorf("preparing a batch without %s links its owners\n%q\nwant those analysis links\n%q", name, got, want) + } } From e5b25cd6dc4b520a4e816b0b55c29f7d554dd52c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:13:35 +0000 Subject: [PATCH 45/53] docs: regenerate the test-function count Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd558a9033..52fb4e4f8c 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,592 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,617 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 961 conformance cases, 270 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (961/961 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 379 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 347 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 00ae5421ed..6772d3a3cb 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 270 golden execution traces under the default schedule (state×121, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 80 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,592 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,617 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- From d7667c49bca0020c18ca5477df237dd301e418cc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:13:23 +0000 Subject: [PATCH 46/53] fix(passes): read comment bodies in batch contexts as the editor's model does A batch's fresh models had no source lookup, so a filter on Comment::body, Documentation::body or TextualRepresentation::body was unevaluable in DiagnosticsAll and kept every candidate an editor's model hides. The batch now carries the workspace's read-only source lookup to the preparatory linker and every worker's model. Co-Authored-By: jason.han --- README.md | 2 +- docs/project/spec-compliance.md | 2 +- internal/core/model/batch.go | 2 +- internal/core/model/batch_test.go | 45 +++++++++++++++++++++++++++++++ internal/core/passes/analyze.go | 2 +- internal/core/passes/pass.go | 6 +++++ 6 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 54e9400f2b..e356d6d95e 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ What these numbers cannot show: the OMG corpora are demonstrations rather than a **Current commit:** All tests pass (`go test -race ./...`), builds clean (`go build ./...`). -**Test coverage:** 8,684 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 968 conformance cases, 277 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. +**Test coverage:** 8,685 top-level `Test` functions (counted from the `_test.go` files, as `go test ./...` runs them) covering parsers, semantics, runtime (actions, states, instances, operators, validation). Behavioral robustness: 206 golden ASTs, 252 negatives, 968 conformance cases, 277 golden traces, 470 runtime robustness cases, 21 gRPC conformance cases and 8 gRPC robustness cases. These figures are generated by `make docs-counts` from the tree and gated. A test skips only for want of something the run did not provide, and says what: the held-image round trip declines a conformance case that creates no instance, a few gate on a PDF or Mermaid toolchain, a pinned pilot artifact, the PSSM suite, a locale, a case-insensitive filesystem or a live Flexo stack, and the OMG corpus gates skip until the corpora are downloaded unless asked to fail. **Parser coverage:** 101/101 bundled library files parse cleanly — the 94 official SysML v2 standard library files and the non-normative `OpenSysML Libraries/OpenSysMLMathFunctions.kerml`, `OpenSysML Libraries/DocumentQueries.sysml`, `OpenSysML Libraries/IdentityMetadata.sysml`, `OpenSysML Libraries/DiagramLayout.sysml`, `OpenSysML Libraries/OOSEM.sysml`, `OpenSysML Libraries/MOSA.sysml` and `OpenSysML Libraries/StateSpaceIntegration.sysml` extensions. Conformance verified by [stdlib_conformance_test.go](internal/core/libs/stdlib_conformance_test.go). Grammar reference: [OMG Xtext grammar](https://github.com/Systems-Modeling/SysML-v2-Pilot-Implementation/tree/master/org.omg.kerml.xtext/src/org/omg/kerml/xtext). **Behavioral execution:** Calc/constraint/requirement/satisfy functional. Action/state executors handle nested invocation, control flow keywords, loop and conditional statements and the send statement (968/968 conformance cases passing). Coverage is self-assessed against the specification text and the normative library: the pinned OMG pilot implementation evaluates expressions but does not execute actions or state machines headlessly, so no external implementation currently adjudicates these rows. See [spec compliance](docs/project/spec-compliance.md). **Reference differential:** 379 files compared diagnostic-by-diagnostic against the pinned OMG pilot implementation (`2026-08`), 347 in full agreement; every divergence is enumerated and adjudicated in [the differential](docs/project/pilot-differential.md), reproducible with `go run ./cmd/pilot-diff`. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index d7ff5c7a6e..535d62e5fb 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -133,7 +133,7 @@ what cannot be checked by anything is in - Golden traces: 277 golden execution traces under the default schedule (state×128, action×74, calc×32, clock×6, extent×6, constraint×4, string×4, three each of accept and analysis, two each of exhibited, f63 and verification, and one each of assign, f62, function, meta, object, occurrence, performed, send, two, w6e and w7d), and 80 more `.trace.golden` files pinning a case under a named policy, `.declared` or `.seed-` — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) - Negative parser tests: 252 negative parser subtests (first-level subtests of `TestNegative`; 396 across the `TestNegative*` functions, 60 of them KerML, and 454 across every `*Negative*` parser test) - gRPC: 21 gRPC conformance cases and 8 gRPC robustness cases (`internal/grpc/testdata/conformance/`, `internal/grpc/robustness_test.go`) -- Test functions: 8,684 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. +- Test functions: 8,685 top-level `Test` functions across the module (`go test -count=1 ./...` runs them all, with the OMG corpora downloaded, `OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_SMT=1` and z3 installed). The figures on this list are generated by `make docs-counts` from the tree and gated; the test and subtest total of a run is not, since it moves with every fixture and only a run can state it. A test skips only where it says why: TestHeldImageRoundTrip declines a conformance case that creates no instance, so there is no held image to round-trip. Three skip themselves: TestSubsettingTargetIsTheInheritedFeature and TestRequirementEvaluation_SubjectNotFound against a limitation they record, and TestHelperSolverProcess, which is a solver child process the parent invokes. The others skip for want of something the run did not provide, and each names it: the `weasyprint`, `pandoc` and `prince` subtests of TestRenderWithInstalledEngines and TestRenderInlineRunsWithInstalledEngines and TestRenderDiagramsWithInstalledMermaid want the PDF and Mermaid toolchain, TestExtractionMatchesBaseline and TestUpdateIsIdempotentAcrossDays the pinned pilot validator jar, TestEmitSuite, TestRefereeRowsAreWellFormed, TestSuiteRead and TestSuiteClassification the downloaded PSSM test suite, TestCRealNotationIsLocaleIndependent a non-C locale, TestRenderDocumentsRejectsCaseAliasedTargets a case-insensitive filesystem, and TestFlexoInterop and TestFlexoInteropApply a live Flexo stack. --- diff --git a/internal/core/model/batch.go b/internal/core/model/batch.go index e7765c6318..292198c8fb 100644 --- a/internal/core/model/batch.go +++ b/internal/core/model/batch.go @@ -155,7 +155,7 @@ func (w *Workspace) DiagnosticsAll(names []string) [][]passes.Diagnostic { pending = append(pending, name) } } - batch := &passes.Batch{Documents: pending, Gathers: passes.NewGathers()} + batch := &passes.Batch{Documents: pending, Gathers: passes.NewGathers(), Source: w.sourceText()} passes.PrepareBatch(w.index, batch) analyzed := make([][]passes.Diagnostic, len(pending)) ParallelFor(w.workers, len(pending), func(i int) { diff --git a/internal/core/model/batch_test.go b/internal/core/model/batch_test.go index b09862b33d..a87b5a2be6 100644 --- a/internal/core/model/batch_test.go +++ b/internal/core/model/batch_test.go @@ -203,6 +203,51 @@ func TestDiagnosticsAllOverOneDocumentPreparesTheOthersBodies(t *testing.T) { } } +// A batch's contexts read comment bodies from the documents as the editor's +// model does, so an import filtered on Comment::body hides the same names on +// both paths instead of keeping every candidate as an unevaluable filter would. +func TestDiagnosticsAllReadsCommentBodiesAsAnEditorDoes(t *testing.T) { + inputs := []Input{ + {Name: "p.sysml", Version: 1, Content: []byte(`package P { + comment Shown /* public */ + comment Hidden /* private */ +}`)}, + {Name: "a.sysml", Version: 1, Content: []byte(`package A { + private import KerML::*; + private import P::*[Comment::body == "public"]; + comment about Shown /* seen */ + comment about Hidden /* filtered out */ + private import Q::Hidden; +} +package Q { + private import KerML::*; + public import P::*; + filter Comment::body == "public"; +}`)}, + } + serial := NewWorkspace() + for _, in := range inputs { + serial.Open(in.Name, in.Content, in.Version) + } + var want strings.Builder + renderDiagnostics(&want, "a.sysml", serial.Diagnostics("a.sysml")) + if n := strings.Count(want.String(), "unresolved reference"); n != 2 { + t.Fatalf("an editor should report Hidden and Q::Hidden unresolved, got:\n%s", want.String()) + } + for _, workers := range []int{1, 8} { + ws := NewWorkspace() + if err := ws.SetWorkers(workers); err != nil { + t.Fatal(err) + } + ws.OpenAll(inputs) + var got strings.Builder + renderDiagnostics(&got, "a.sysml", ws.DiagnosticsAll([]string{"a.sysml"})[0]) + if got.String() != want.String() { + t.Errorf("a batch on %d workers reported:\n%s\nwant, as the editor does:\n%s", workers, got.String(), want.String()) + } + } +} + // A batch opened over documents already there replaces them as Open does, so // the index holds each name once. func TestOpenAllReplacesEarlierDocuments(t *testing.T) { diff --git a/internal/core/passes/analyze.go b/internal/core/passes/analyze.go index 478597a0f5..52ede0d378 100644 --- a/internal/core/passes/analyze.go +++ b/internal/core/passes/analyze.go @@ -126,7 +126,7 @@ func PrepareBatch(idx *symbols.Index, batch *Batch) { return } linker := resolve.New(idx) - attachModel(linker) + attachModel(linker).SetSourceText(batch.Source) for _, name := range idx.WorkspaceDocuments() { linker.LinkMetadataBodies(name) } diff --git a/internal/core/passes/pass.go b/internal/core/passes/pass.go index 94d20f6021..c387d60d1e 100644 --- a/internal/core/passes/pass.go +++ b/internal/core/passes/pass.go @@ -77,6 +77,9 @@ type Batch struct { // Gathers is what the workspace-wide audits gather, once for the batch, on // first use by any of its contexts; nil leaves each context to gather alone. Gathers *Gathers + // Source reads the documents' notation, which comment and documentation + // bodies come from; nil leaves every body unreadable, as an editor never is. + Source source.Lookup } // Options is the analysis configuration of one run. The zero value is what @@ -159,6 +162,9 @@ func (c *Context) Resolver() *resolve.Resolver { func (c *Context) Model() *semantics.Model { if c.model == nil { c.model = attachModel(c.Resolver()) + if c.Batch != nil { + c.model.SetSourceText(c.Batch.Source) + } } return c.model } From a8386adb8ffb7ee48c851f5ea07acd4bdf11e923 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:01:42 +0000 Subject: [PATCH 47/53] fix(model): settle pending regathers before a batch consults the diagnostics cache An edit queues the regathers it takes for the next read (settleGathersLocked); the editor path settles them before it serves a cached verdict, the batch path did not, so DiagnosticsAll after an edit could answer with an entry the settle would have dropped. The batch now settles first, pinned by TestBatchSettlesPendingGathersBeforeServingTheCache. The performance internals also describe what the batch's gather is: one of its own, gathered once per batch, not the workspace's. Co-Authored-By: jason.han --- docs/internals/performance.md | 21 +++++++++------- internal/workspace/model/batch.go | 2 ++ .../workspace/model/lazy_regather_test.go | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/docs/internals/performance.md b/docs/internals/performance.md index 13f00b4656..09dd7bdb59 100644 --- a/docs/internals/performance.md +++ b/docs/internals/performance.md @@ -323,15 +323,18 @@ with a private resolver and semantic model, over an index nothing writes while the pool runs. What resolving a document would otherwise link into the scope tree on first use — the owner of a metadata body — is linked for every document of the batch before the pool starts (`passes.PrepareBatch`), so the workers -only read it. The batch carries the workspace's `passes.Gathers` -(`passes.Batch.Gathers`), settled before the pool starts: the first context that -runs a workspace-wide audit gathers every document's facts into it, under its -lock, and every context reads the same union afterwards, so the audits gather -each document once per batch rather than once per analysis. A private resolver -records no dependencies, so the diagnostics a batch computes are cached with -none — dropped on any change to the workspace (`Workspace.batched`) rather than -per dependency — while the gathers themselves stay the workspace's, invalidated -per document as the editor path does. Diagnostics come back in the order the +only read it. The batch carries a `passes.Gathers` of its own +(`passes.Batch.Gathers`): the first context that runs a workspace-wide audit +gathers every document's facts into it, under its lock, and every context reads +the same union afterwards, so the audits gather each document once per batch +rather than once per analysis. A private resolver records no dependencies, so +the diagnostics a batch computes are cached with none — dropped on any change +to the workspace (`Workspace.batched`) rather than per dependency — and what +its contexts gather never enters the workspace's own `passes.Gathers`, whose +entries are invalidated per document as the editor path's resolver reports. +The batch does settle the regathers an edit left pending before it consults +the diagnostics cache, so a verdict the editor path cached is not served once a +change to another document has undone it. Diagnostics come back in the order the files were given and are the same at any job count; `-jobs` and `OPENSYSML_JOBS`, the setting that bounds how many runs of one check go concurrently, set the pool, default one worker per CPU. The earlier cost of diff --git a/internal/workspace/model/batch.go b/internal/workspace/model/batch.go index 75ef0746a1..089010ea35 100644 --- a/internal/workspace/model/batch.go +++ b/internal/workspace/model/batch.go @@ -98,9 +98,11 @@ func (w *Workspace) commitBatch(was map[string]uint64, docs []*Document) { // DiagnosticsAll returns the named documents' diagnostics in the order named (nil // for an unknown name), analyzing the uncached ones on the workers, then caching. // The workers share one gather of the workspace-wide audits, made on first use. +// Pending regathers settle first, so no cached entry they would drop is served. func (w *Workspace) DiagnosticsAll(names []string) [][]diag.Diagnostic { w.mu.Lock() defer w.mu.Unlock() + w.settleGathersLocked() out := make([][]diag.Diagnostic, len(names)) var pending []string queued := map[string]bool{} diff --git a/internal/workspace/model/lazy_regather_test.go b/internal/workspace/model/lazy_regather_test.go index 9b727947a9..faf2bb6aa1 100644 --- a/internal/workspace/model/lazy_regather_test.go +++ b/internal/workspace/model/lazy_regather_test.go @@ -38,3 +38,27 @@ func TestWorkspacePendingGathersSettleOnRead(t *testing.T) { t.Errorf("sat.sysml: incremental %v, fresh %v", gotSat, want) } } + +// TestBatchSettlesPendingGathersBeforeServingTheCache: a batch consulted after +// an edit settles the regathers the edit queued before it serves a cached +// verdict, so the entry the settle would drop is not the answer. +func TestBatchSettlesPendingGathersBeforeServingTheCache(t *testing.T) { + ws := NewWorkspace() + ws.Open("hub.sysml", []byte("package M { private import OOSEM::*; #stakeholderNeed requirement need; }"), 1) + ws.Open("sat.sysml", []byte("package S { private import OOSEM::*; #systemRequirement requirement sys; }"), 1) + if got := codesOf(ws.Diagnostics("sat.sysml")); len(got) != 0 { + t.Fatalf("sat.sysml under a stakeholder need: %v, want clean", got) + } + + // The hub's need becomes a mission requirement with no read in between; the + // satellite's cached verdict stands until the pending regather runs. + ws.Update("hub.sysml", []byte("package M { private import OOSEM::*; #missionRequirement requirement mission; }"), 2) + got := codesOf(ws.DiagnosticsAll([]string{"sat.sysml"})[0]) + + fresh := NewWorkspace() + fresh.Open("hub.sysml", ws.Document("hub.sysml").Content, 1) + fresh.Open("sat.sysml", ws.Document("sat.sysml").Content, 1) + if want := codesOf(fresh.Diagnostics("sat.sysml")); !reflect.DeepEqual(got, want) { + t.Errorf("sat.sysml: batch after the edit %v, fresh %v", got, want) + } +} From 04bf5c9966339c02664705e62d5a3462f2337687 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:12:15 +0000 Subject: [PATCH 48/53] docs(cli): say that -jobs also bounds how many files of one load are parsed and validated at once Co-Authored-By: jason.han --- docs/reference/cli.md | 8 +++++++- docs/reference/environment.md | 4 ++-- docs/reference/repl-commands.md | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f09121da4d..9c6c46d049 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -300,7 +300,7 @@ written in, so the verdicts are about that object: | `-engines` | Lists the analysis engines this build knows — name, kind, protocol, authority, the question kinds each answers and its status — and exits, without a model and without starting a process: the external engines of `OPENSYSML_ENGINES` and the tools of `OPENSYSML_TOOLS` are listed from their manifests alone, each followed by a line naming its file and command. See [Analysis engines](#analysis-engines) | | `-probe` | With `-engines`, also start each external engine once, check its `describe` against its manifest entry field by field and report the outcome as its status (`ready (…; describe agrees)`, or the first field that disagrees). See [External engines](external-engines.md) | | `-engine \|auto\|all` | The analysis engine every check of the invocation is put to. `auto` (the default) picks the engine of highest authority covering the question and advances past one that refuses or answers *not covered*, reaching an external engine only after every built-in one has; a name (`run`, `explore`, `check`, `smt`, `sweep`, `solve`, or an external engine's) puts the question to that engine alone, and its refusal is the answer; `all` puts it to every engine covering it, one after another in name order, and composes their answers. A name no engine is registered under is refused before anything runs. `-engine explore` explores as `-schedule explore` does; `-engine check` searches every schedule of each `-action` for a violation, a deadlock, a failure or a divergence ([Checking every schedule of an action](#checking-every-schedule-of-an-action-or-a-state-machine)); `-engine smt` decides a `-check-property` over every schedule and every value of the free inputs with an SMT solver ([Deciding a property over the inputs](#deciding-a-property-over-the-inputs)). See [Analysis engines](#analysis-engines) | -| `-jobs ` | Runs of one check that may go concurrently — the linearizations of an exploration, the rows of a `-sweep`/`-samples`, the engines `-engine all` consults — each on a worker of its own over the shared model. `n` is a positive integer; the default is `OPENSYSML_JOBS`, else one per CPU, fewer where the memory available leaves less than 512 MiB per worker (Linux: `MemAvailable` and the cgroup's `memory.max`; one at least). The result of a check is the same at any count: the outcome table, the witness, the run count and the cut a violation makes are those of the runs taken one at a time in plan order. See [Running in parallel](#running-in-parallel) | +| `-jobs ` | Runs of one check that may go concurrently — the linearizations of an exploration, the rows of a `-sweep`/`-samples`, the engines `-engine all` consults — each on a worker of its own over the shared model, and how many files of one load are parsed and validated at once (`-validate`, `-satisfy`, `-check` and every mode that loads files). `n` is a positive integer; the default is `OPENSYSML_JOBS`, else one per CPU, fewer where the memory available leaves less than 512 MiB per worker (Linux: `MemAvailable` and the cgroup's `memory.max`; one at least). The result of a check is the same at any count: the outcome table, the witness, the run count and the cut a violation makes are those of the runs taken one at a time in plan order. See [Running in parallel](#running-in-parallel) | | `-json` | Reports the checks as one JSON document rather than as lines. Each check carries its `plan` and `results[]` beside the fields it always carried ([Analysis engines](#analysis-engines)) | Other modes, each described in full by `sysml -help` and the manual page: @@ -1517,6 +1517,12 @@ the rows, their outputs, verdicts, evaluations and errors are those of `-jobs 1` With `-json` the check's `plan` carries `workers`, how many workers the plan built, and `warming`, the milliseconds spent building them; the human-readable report does not print them. +The same count sets how many files of one load are parsed and validated at once. The files named +on the command line are parsed on `n` workers, indexed together once, and analysed on `n` workers, +each file as a document of its own; the diagnostics are those of `-jobs 1`, in command-line order, +whatever `n` is. A model split over several files therefore validates faster on more CPUs where a +single file does not; `docs/project/satellite-network-stress-test.md` records the measurements. + ## Analysis engines Every check is a question put to an analysis engine, and every verdict line is followed by its diff --git a/docs/reference/environment.md b/docs/reference/environment.md index 2147929818..47e0067940 100644 --- a/docs/reference/environment.md +++ b/docs/reference/environment.md @@ -13,7 +13,7 @@ run that would never finish into a reported error instead of a hang. | `OPENSYSML_MAX_ELEMENTS` | `1000000` | Collection elements one evaluation may hold — the bound on the memory a run holds rather than on the work it does | | `OPENSYSML_MAX_CALC_DEPTH` | `10000` (ceiling `25000`) | Nested `calc` invocations one run may hold on the stack, which is what a recursion spends | | `OPENSYSML_MAX_SWEEP_RUNS` | `1000` | Runs one parameter sweep or sample may make (`-sweep`/`-samples`, `%sweep`/`%samples`, `RunSweep`), each a whole analysis or calc run with the budgets above of its own | -| `OPENSYSML_JOBS` | one per CPU, fewer where the memory available leaves less than 512 MiB per worker | Runs of one check that may go concurrently (`-jobs`, `%jobs`; the gRPC service reads it at startup), each on a worker of its own over the shared model. Bounds how many runs go at once, not the work or memory of any one of them: a fleet of `n` workers may hold `n` times `OPENSYSML_MAX_ELEMENTS`. The result of a check does not depend on it | +| `OPENSYSML_JOBS` | one per CPU, fewer where the memory available leaves less than 512 MiB per worker | Runs of one check that may go concurrently (`-jobs`, `%jobs`; the gRPC service reads it at startup), each on a worker of its own over the shared model, and how many files of one load are parsed and validated at once. Bounds how many runs go at once, not the work or memory of any one of them: a fleet of `n` workers may hold `n` times `OPENSYSML_MAX_ELEMENTS`. The result of a check does not depend on it | | `OPENSYSML_CALC_COMPILE` | unset (on) | Set to `0`, `false`, `off` or `no` to run every `calc` on the reference evaluator, instead of compiling a pure scalar body to a closure fast path on its first invocation; results, errors and step counts are the same either way, so this is a bisecting aid | | `OPENSYSML_SMT` | unset (look for `z3`, then `cvc5`, on `PATH`) | Executable the `smt` and `solve` engines (`-engine smt`, `%engine smt`) and `%check`, `%explain`, `%solve`, `%configure` and `%optimize` drive as their SMT solver, speaking SMT-LIB2 on standard input (experimental); `%optimize` needs `z3` in particular, as `(minimize …)`/`(maximize …)` is a z3 extension cvc5 does not implement | | `OPENSYSML_SMT_TIMEOUT` | `10s` | How long one solver query may take, as a Go duration (`5s`, `500ms`), after which the verdict is `unknown`; a check's `-check-timeout` (`%check-bounds timeout=`) takes its place for the `smt` engine's queries | @@ -187,7 +187,7 @@ ranges would make more runs than it allows is refused before the first one is made, naming the count the plan asks for and the bound it exceeds. `OPENSYSML_JOBS` is no budget at all but the width of the fleet: how many of one check's runs — an exploration's linearizations, a sweep's rows, the engines `-engine all` consults — -may go at once. A value that is not a positive integer is refused at startup; `-jobs` and `%jobs` +may go at once, and how many files of one load are parsed and validated at once. A value that is not a positive integer is refused at startup; `-jobs` and `%jobs` override it for one invocation or session. See [Running in parallel](cli.md#running-in-parallel). diff --git a/docs/reference/repl-commands.md b/docs/reference/repl-commands.md index 35fcf2cd50..fe566436d6 100644 --- a/docs/reference/repl-commands.md +++ b/docs/reference/repl-commands.md @@ -43,7 +43,7 @@ into the parts it holds (`car.fl.hub`, `#3.fl`, `car.wheels[2]`). | `%schedule []` | Show or set the scheduling policy the executors resolve their [choice points](../guide/06-behavior.md) under: `reverse` (the default: reverse token order, first holding guard, first enabled transition), `declared` (spawn and declaration order), `seed:` (a pseudo-random order the non-negative integer `n` fixes, so the same seed replays the same run) or `replay:` (the choice lines of a witness, followed move for move and then `reverse`'s picks one token a step — a header of `no choice points` follows the one run there is; a move the run cannot make is a `replay refused` error naming it — [Running one witness again](../guide/06-behavior.md#running-one-witness-again)). Applies to runs started from then on — `%action`, `%state`, `%analysis`; a calc's body performs nothing, so `%calc` has no choice to make — while a debugging session already under way keeps the policy it started with; every choice point a run reaches is reported and the `took …` of each `choice` line is what the policy took. A spelling naming no policy (an unknown name, `seed` or `seed:` without a number, `seed:-1`, `seed:abc`, a malformed `explore:` option, `replay:` without a readable file of choice lines, one that is empty or has a line spelling no choice) is refused and the policy is left as it was. `explore[:runs=N,depth=D]` is refused at the prompt too, as a typed error saying why: it replays a behavior from the start once per linearization, which `%action` and `%state`, stepping one run, cannot do — run `sysml -schedule explore -action ` (or `-state`, `-analysis`, `-calc`) for the outcome table ([Exploring every linearization](cli.md#exploring-every-linearization)), or send a request with that `schedule` over the wire | | `%strict [on\|off]` | Show or set strict conformance: report notation no SysML v2 production admits as an error, and reprint the session's diagnostics under the new mode ([Strict conformance](../guide/03-command-line.md#strict-conformance)) | | `%budget` | Show the five bounds one run may spend, each with the variable that raises it | -| `%jobs []` | Show or set how many runs of one check asked from then on may go concurrently — the linearizations a check explores under `%schedule explore`, the engines `%engine all` consults — the rows of a `%sweep` or `%samples` — each on a worker of its own over the session's model; `OPENSYSML_JOBS`, else one per CPU the memory available allows, until set. The result of a check is the same at any count. The count bounds a plan's runs, not the session: the held context, its objects and a debugging session under way are untouched by setting it. A value that is not a positive integer is refused and the count left as it was ([Running in parallel](cli.md#running-in-parallel)) | +| `%jobs []` | Show or set how many runs of one check asked from then on may go concurrently — the linearizations a check explores under `%schedule explore`, the engines `%engine all` consults — the rows of a `%sweep` or `%samples` — each on a worker of its own over the session's model — and how many files of one `%load` are parsed and validated at once; `OPENSYSML_JOBS`, else one per CPU the memory available allows, until set. The result of a check is the same at any count. The count bounds a plan's runs, not the session: the held context, its objects and a debugging session under way are untouched by setting it. A value that is not a positive integer is refused and the count left as it was ([Running in parallel](cli.md#running-in-parallel)) | | `%engines [probe]` | List the analysis engines of the build in name order — the kind of each, the protocol it is spoken by, the authority it carries, the question kinds it answers and its status (`ready`, `ready (z3 at …)` for one whose process was found, `unavailable: `), then one line per manifest entry naming its file and command — as the CLI's [`-engines`](cli.md#analysis-engines) does, starting nothing. `%engines probe` also starts each [external engine](external-engines.md) once, checks its `describe` against its manifest entry field by field and reports the outcome as its status, as `-engines -probe` does; any other argument is refused | | `%engine [\|auto\|all]` | Show or set the analysis engine every question asked from then on — `%constraint`, `%requirement`, `%satisfy`, `%validate`, `%calc`, `%analysis`, `%sweep`, `%samples`, `%check` and the other solver commands — is put to. `auto` (the default) picks the engine of highest authority covering the question and advances past one that refuses or answers *not covered*; a name puts it to that engine alone, whose refusal is then the verdict; `all` puts it to every covering engine, one after another in name order, and composes their answers, naming a disagreement in the interpreter's favor. Every verdict is followed by a `standing:` line — the claim, the strength of the evidence (*not covered*, *observed*, *witnessed*, *bounded*, *proved*) and what earned it — and under `all` each engine's part. A name no engine is registered under is refused and the selection left as it was. `explore` is refused at the prompt as `%schedule explore` is, since the debuggers step one run; the `%action` and `%state` debuggers keep the schedule `%schedule` set whatever the engine ([Analysis engines](cli.md#analysis-engines)). `%engine check` is the one selection that changes what `%action` does by itself: it puts the action to the `check` engine, which searches every schedule for a violation, a deadlock, a failure or a divergence and prints the verdict, instead of starting a debugging session; `%engine all` does the same, the exploration beside the checker, once a `%check-*` setting is made ([Checking every schedule](#checking-every-schedule-of-an-action-or-a-state-machine)) | | `%check-property [...\|off]` | Show or set the constraints and requirements the `check` engine evaluates at every stable state of a checked action, on its performing object where there is one; `off` (the default) names none | From 5d807222ae4e7a1018cdac9dd098a92cc6fa140a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:23:28 +0000 Subject: [PATCH 49/53] docs(skills): repeated root packages resolve by document name, not load order Co-Authored-By: jason.han --- .agents/skills/testing-sysml-repl/SKILL.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 51fd1e85ec..3d71525166 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -2537,9 +2537,12 @@ its output rather than in an exit code — so assert on the exact rendered text: bare `Real`. A qualified expression such as `%eval A::x + 1.0` should still work, proving isolation did not remove the loaded package from the index. - Two loaded files declaring the same root package are two root namespaces, not - a duplicate, and a reference to the name resolves to the first declaration in - load order. Use separate `A::X` and `A::Y` files and reverse their load order - to prove that behavior. + a duplicate. References select the declaration in the document whose name + sorts first, independent of CLI argument order (see the CLI reference's + Multiple Files section). Put `A::X` in `first.sysml` and `A::Y` in + `second.sysml`, then reverse arguments: `A::X` must resolve and `A::Y` must + remain unresolved in both orders. Do not confuse reference precedence with + document rendering order. - For rendering order, `%view` takes a **view**, not an ordinary package. `%render #table` renders the loaded documents without a declared view; reverse two nonalphabetical package names and assert their member groups reverse. @@ -2562,8 +2565,8 @@ None for local multi-file CLI/REPL tests. `sysml ...` and `%load ...` expand to model files via `internal/workspace/project.Expand`, and every file is accepted before one analysis pass (`Session.SubmitAll`), each file a workspace document of its own indexed with the -others, so load order does not affect name resolution except between root namespaces of -one name (the first wins). Shapes to expect: +others. Repeated root names resolve by document-name order, not load order. +Shapes to expect: - More than one file prints a `loaded N files:` header listing each path (a single file prints no header — a good tell that the multi-file path was taken). From 045f46dfbe0f1200478ba5d137968362cf15f166 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:24:01 +0000 Subject: [PATCH 50/53] docs(skills): probes for -jobs, the split-planes generator and batch diagnostic invalidation Co-Authored-By: jason.han --- .agents/skills/testing-sysml-repl/SKILL.md | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agents/skills/testing-sysml-repl/SKILL.md b/.agents/skills/testing-sysml-repl/SKILL.md index 3d71525166..93390a4dfb 100644 --- a/.agents/skills/testing-sysml-repl/SKILL.md +++ b/.agents/skills/testing-sysml-repl/SKILL.md @@ -2562,6 +2562,29 @@ its output rather than in an exit code — so assert on the exact rendered text: None for local multi-file CLI/REPL tests. +### Parallel file-load verification + +- The shared concurrency knob is `-jobs N` / `OPENSYSML_JOBS`, also observable + with `%jobs`. It bounds both plan execution and files parsed/validated in one + load. Compare stdout, stderr and exit status at 1, 2 and 8 jobs plus an + environment override; test invalid values against a nonexistent path to + distinguish startup rejection from a load failure. `-workers` is not a + supported replacement flag. +- Generate a small multi-file model from the nested tools module: + `go run -C tools ./cmd/stress-model -planes 4 -satellites 10 -ground-stations 8 -split-planes `. + Verify SHA256/name manifest records, then shrink the model: unchanged surplus + output should disappear, while an edited surplus plane and user file survive. + Editing a still-current output should refuse the entire regeneration with + `nothing written`; compare all directory bytes before and after. +- A load containing `part component : Needed::T;` gives a non-vacuous batch + diagnostic. In `%verbosity debug`, declare `package Needed { part def T; }`, + then `package Needed {}`, then restore `T`. Whole-buffer diagnostics must + change 1→0→1→0. An empty package removes its members; a nonempty declaration + merges with existing members and is not a suitable deletion probe. +- For save/reload, retain comments and loaded-file declarations and assert + only the latest prompt redeclaration survives. Re-evaluate a compound + expression after `%clear` and reloading the saved file. + `sysml ...` and `%load ...` expand to model files via `internal/workspace/project.Expand`, and every file is accepted before one analysis pass (`Session.SubmitAll`), each file a workspace document of its own indexed with the From 943304282134acae21bade3676a98c807d72473d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:48:18 +0000 Subject: [PATCH 51/53] feat(repl): refuse to load a file named as the transcript The typed transcript is the workspace document , so a file of that name would share its key and each would overwrite the other. Every path loader (LoadPaths, LoadFile, LoadFilesSummary, %load) now reads through one helper that refuses such a file with a *ReservedNameError before anything is submitted; the CLI exits as it does for any read failure. Co-Authored-By: jason.han --- .../unreleased/per-file-documents.changed.md | 1 + cmd/sysml/load_test.go | 38 +++++++++++++++ docs/guide/04-repl.md | 6 ++- internal/frontend/repl/filedocs_test.go | 32 +++++++++++++ internal/frontend/repl/load.go | 48 +++++++++++++------ internal/frontend/repl/run.go | 31 +++++------- internal/frontend/repl/session.go | 4 +- 7 files changed, 124 insertions(+), 36 deletions(-) diff --git a/changes/unreleased/per-file-documents.changed.md b/changes/unreleased/per-file-documents.changed.md index bd0eca889a..8ef5196eba 100644 --- a/changes/unreleased/per-file-documents.changed.md +++ b/changes/unreleased/per-file-documents.changed.md @@ -1 +1,2 @@ - **Every file the command line or `%load` reads is a document of its own.** `sysml -validate`, `-satisfy`, `-e` and the REPL's `%load` used to join the files they were given into one buffer with the typed transcript, so a model split over files was analysed as if it were one file; each file is now a workspace document, indexed with the others and analysed on its own, exactly as the editor and the OMG corpus gates analyse it. Two things a reader will observe: a root-level import in one file (`private import ScalarValues::*;`) no longer serves the other files on the command line or the prompt after `%load` — a KerML root import surfaces its names in its own document's root namespace only, as `docs/project/spec-compliance.md` records — and two files that both declare `package A` are no longer reported as `Duplicate of other owned member name`: they are two root namespaces of one name, and a reference to `A` resolves to the declaration in the file whose name sorts first (the order the editor gives documents, whatever order the files were given in), as the pilot implementation resolves a repeated root name to the first. Root packages stay reachable from every file and from the prompt through the global namespace. A differential test runs every multi-file directory of the fixtures and of the four OMG corpora through the command line and through a workspace and asserts the same diagnostics. +- **A file named `` is refused by every load.** `` is the name the REPL keeps the typed transcript under, so `%load ` and `sysml -validate ` report `cannot load : the name is reserved for the text typed at the prompt` before anything loads and exit as a read failure does; `./` loads it. diff --git a/cmd/sysml/load_test.go b/cmd/sysml/load_test.go index ece381b297..8c07937019 100644 --- a/cmd/sysml/load_test.go +++ b/cmd/sysml/load_test.go @@ -113,11 +113,49 @@ func TestCheckGlobExitStatus(t *testing.T) { wantReport(t, checkPaths(t, binary, "-validate", filepath.Join(dir, "*.kerml")), 2, "no model files match") } +// TestLoadRefusesTheTranscriptsName checks that a file named as the session's +// transcript is a read failure like any other: refused before anything loads, +// named in the error, and exiting as a run that decided nothing. +func TestLoadRefusesTheTranscriptsName(t *testing.T) { + const reserved = "" + dir := t.TempDir() + write(t, filepath.Join(dir, reserved), "package FromFile { part def X; }\n") + write(t, filepath.Join(dir, "ok.sysml"), "package OK { part def Y; }\n") + t.Chdir(dir) + + sess := repl.NewSession() + status, err := loadFiles(sess, []string{"ok.sysml", reserved}) + var named *repl.ReservedNameError + if !errors.As(err, &named) || named.Name != reserved { + t.Fatalf("loadFiles error = %v, want a *repl.ReservedNameError naming %s", err, reserved) + } + if status != exitUnevaluable { + t.Errorf("status = %d, want %d", status, exitUnevaluable) + } + if got := sess.List(); len(got) != 0 { + t.Errorf("a refused load must load nothing, got %v", got) + } + + binary := buildCLI(t) + wantReport(t, checkPathsIn(t, dir, binary, "-validate", reserved), exitUnevaluable, + "sysml: cannot load : the name is reserved") + wantReport(t, checkPathsIn(t, dir, binary, "-constraint", "OK::Held", "ok.sysml", reserved), exitUnevaluable, + "cannot load : the name is reserved") + wantReport(t, checkPathsIn(t, dir, binary, "-validate", "./"+reserved), exitHolds) +} + // checkPaths runs the binary on paths the caller names, rather than on a model // written to a file for it as check does. func checkPaths(t *testing.T, binary string, args ...string) runOutcome { + t.Helper() + return checkPathsIn(t, "", binary, args...) +} + +// checkPathsIn is checkPaths run from dir, so a relative path is read there. +func checkPathsIn(t *testing.T, dir, binary string, args ...string) runOutcome { t.Helper() cmd := exec.Command(binary, args...) + cmd.Dir = dir var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() diff --git a/docs/guide/04-repl.md b/docs/guide/04-repl.md index 2ff1c960d9..7050f43542 100644 --- a/docs/guide/04-repl.md +++ b/docs/guide/04-repl.md @@ -131,7 +131,11 @@ non-interactive use, a load's diagnostics are errors, so a script that loads a m file fails rather than continuing against an empty session. Each loaded file is a document of its own, analysed as the editor and the checker analyse it, -while everything typed at the prompt forms one transcript document. Two consequences follow. +while everything typed at the prompt forms one transcript document. The transcript is kept +under the name ``, which is therefore reserved: a file whose path is literally `` +is refused by `%load` and by the command line (`cannot load : the name is reserved for the +text typed at the prompt`) before anything is loaded, like a file that could not be read; name it +`./` or from another directory to load it. Two consequences follow. A root-level import serves the file it is written in and no other: after `%load a.sysml`, a `private import ScalarValues::*;` at the top of `a.sysml` does not make `Real` resolvable in diff --git a/internal/frontend/repl/filedocs_test.go b/internal/frontend/repl/filedocs_test.go index a2d508d3a5..2da469b9ee 100644 --- a/internal/frontend/repl/filedocs_test.go +++ b/internal/frontend/repl/filedocs_test.go @@ -1,6 +1,7 @@ package repl import ( + "errors" "fmt" "os" "path/filepath" @@ -285,3 +286,34 @@ func workspaceDiagnostics(t *testing.T, paths []string) []string { sort.Strings(out) return out } + +// The transcript is kept under one workspace name, so a file of that name is +// refused at the load rather than sharing the document with the typed text. +func TestLoadRefusesAFileNamedAsTheTranscript(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, docName), "package FromFile { part def X; }\n") + t.Chdir(dir) + + s := NewSession() + s.Submit("package Typed { part def T; }") + before := s.Text() + + _, err := s.LoadFilesSummary([]string{docName}) + var reserved *ReservedNameError + if !errors.As(err, &reserved) || reserved.Name != docName { + t.Fatalf("LoadFilesSummary(%q) error = %v, want a *ReservedNameError naming it", docName, err) + } + if _, _, err := s.runMeta("%load " + docName); err == nil || !strings.Contains(err.Error(), "reserved") { + t.Fatalf("%%load %s error = %v, want the name refused as reserved", docName, err) + } + if _, err := s.LoadFile(docName); !errors.As(err, &reserved) { + t.Errorf("LoadFile(%q) error = %v, want a *ReservedNameError", docName, err) + } + + if got := s.Text(); got != before { + t.Errorf("the refused load changed the transcript:\n%s\nwas:\n%s", got, before) + } + if got := strings.Join(s.List(), "\n"); strings.Contains(got, "FromFile") || !strings.Contains(got, "Typed") { + t.Errorf("the refused file's declarations must not enter the session; got %v", s.List()) + } +} diff --git a/internal/frontend/repl/load.go b/internal/frontend/repl/load.go index 180770e6aa..57e126a92a 100644 --- a/internal/frontend/repl/load.go +++ b/internal/frontend/repl/load.go @@ -53,22 +53,15 @@ func (s *Session) loadPathsReport(paths []string) (LoadReport, error) { if err != nil { return LoadReport{}, err } - files = s.withDependencies(files) - srcs := make([]SourceFile, 0, len(files)) - names := make([]string, 0, len(files)) - for _, file := range files { - name, data, err := project.ReadFile(file) - if err != nil { - return LoadReport{}, readError(name, err) - } - names = append(names, name) - srcs = append(srcs, SourceFile{Name: name, Text: string(data)}) + srcs, err := s.readSources(s.withDependencies(files)) + if err != nil { + return LoadReport{}, err } var loaded []string - if len(files) > 1 { - loaded = append(loaded, fmt.Sprintf("loaded %d files:", len(files))) - for _, name := range names { - loaded = append(loaded, " "+name) + if len(srcs) > 1 { + loaded = append(loaded, fmt.Sprintf("loaded %d files:", len(srcs))) + for _, src := range srcs { + loaded = append(loaded, " "+src.Name) } } found, declared := renderSplit(s.submitFiles(srcs), s.verbosity) @@ -104,6 +97,33 @@ func expandHomes(paths []string) []string { return out } +// readSources reads every path into the file a load submits, under the name it +// is reported by; the error is a *ReadError or a *ReservedNameError. +func (s *Session) readSources(paths []string) ([]SourceFile, error) { + files := make([]SourceFile, 0, len(paths)) + for _, path := range paths { + name, data, err := project.ReadFile(path) + if err != nil { + return nil, readError(name, err) + } + if name == docName { + return nil, &ReservedNameError{Name: name} + } + files = append(files, SourceFile{Name: name, Text: string(data)}) + } + return files, nil +} + +// ReservedNameError is a file a load refused because its name is the one the +// session keeps its typed text under, which a loaded file cannot share. +type ReservedNameError struct { + Name string +} + +func (e *ReservedNameError) Error() string { + return fmt.Sprintf("cannot load %s: the name is reserved for the text typed at the prompt", e.Name) +} + // ReadError is a file a load could not read, under the name it is reported by. type ReadError struct { Path string diff --git a/internal/frontend/repl/run.go b/internal/frontend/repl/run.go index cde7e3f712..0d8ad3a341 100644 --- a/internal/frontend/repl/run.go +++ b/internal/frontend/repl/run.go @@ -11,7 +11,6 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" - "github.com/Open-MBEE/OpenSysML/internal/workspace/project" ) // errRuntimeInit marks a runtime the session could not create at all, which the @@ -54,17 +53,13 @@ func errorLines(lines []string, _ []NamedValue, err error) ([]string, bool, erro // LoadFile submits path and the files beside and below it that declare a root // namespace it imports as one submission, returning the lines `%load` prints. -// A lone "-" reads standard input; the error is a file it could not read. +// A lone "-" reads standard input; the error is a file it could not read or +// one named as the transcript is. func (s *Session) LoadFile(path string) ([]string, error) { defer s.enter()() - paths := s.withDependencies([]string{expandHome(path)}) - files := make([]SourceFile, 0, len(paths)) - for _, p := range paths { - name, data, err := project.ReadFile(p) - if err != nil { - return nil, readError(name, err) - } - files = append(files, SourceFile{Name: name, Text: string(data)}) + files, err := s.readSources(s.withDependencies([]string{expandHome(path)})) + if err != nil { + return nil, err } return renderResult(s.submitFiles(files), s.verbosity), nil } @@ -79,18 +74,14 @@ func (s *Session) LoadFileSummary(path string) ([]string, error) { // LoadFilesSummary is LoadFileSummary over every path as one submission, each // file a document of its own, indexed together and each summarized on its own; -// a read failure is a *ReadError. Files beside and below the paths that declare -// an imported root namespace load too. +// a read failure is a *ReadError and a file named as the transcript is a +// *ReservedNameError. Files beside and below the paths that declare an imported +// root namespace load too. func (s *Session) LoadFilesSummary(paths []string) ([]string, error) { defer s.enter()() - paths = s.withDependencies(expandHomes(paths)) - files := make([]SourceFile, 0, len(paths)) - for _, path := range paths { - name, data, err := project.ReadFile(path) - if err != nil { - return nil, readError(name, err) - } - files = append(files, SourceFile{Name: name, Text: string(data)}) + files, err := s.readSources(s.withDependencies(expandHomes(paths))) + if err != nil { + return nil, err } res, byFile, whole := s.submitEach(files) var lines []string diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index 53b04822a6..42d8dc9a9a 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -848,7 +848,9 @@ func (s *Session) submitAll(srcs []string) Result { // SubmitFiles accumulates every file as one submission: all of them are accepted // before the buffer is reindexed and analyzed, so a declaration in one resolves // against the others no matter which order they arrive in. This is what makes -// loading a multi-file project order-independent. +// loading a multi-file project order-independent. A file's Name is its +// workspace document, so it must not be the transcript's; the path loaders +// refuse such a file before it gets here. func (s *Session) SubmitFiles(files []SourceFile) Result { defer s.enter()() return s.submitFiles(files) From 5ea2a9534c28424655a90661eceb0d7efd809fcc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:59:35 +0000 Subject: [PATCH 52/53] fix(repl): locate a loaded document's spans by its own name after the merge develop's sessionSourceFile still distinguished the joined KerML buffer; with each loaded file a document of its own only the transcript's spans need the snippet lookup. Co-Authored-By: jason.han --- internal/frontend/repl/view.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/frontend/repl/view.go b/internal/frontend/repl/view.go index 6a4144a4e6..c0a71b35b9 100644 --- a/internal/frontend/repl/view.go +++ b/internal/frontend/repl/view.go @@ -251,10 +251,10 @@ func (s *Session) viewRenderer() (*view.Renderer, error) { return view.NewRenderer(model, resolver, s.sessionSourceText()), nil } -// sessionSourceFile locates the file a span of the session buffer was loaded from, so a -// location a declaration states relative to its file resolves against that file, not the buffer. +// sessionSourceFile locates the file a span of a session document was loaded from: +// a loaded file is a document named for its path; the transcript's spans are typed. func (s *Session) sessionSourceFile(doc string, span source.Span) string { - if doc != docName && doc != kermlDocName { + if doc != docName { return source.FileNamed(doc, span) } sn, _ := s.snippetAt(span.Offset) From 0ca1956b058222e8e424638dcb56d2dcb4bc1791 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:11:21 +0000 Subject: [PATCH 53/53] fix(repl): refuse a direct submission of a file named as the transcript SubmitFiles takes SourceFile values without the path loaders, so a file named reached openDocuments under the transcript's workspace key and its findings and declarations were read twice. The submission is now refused whole before anything is accepted; Result.Refused carries the *ReservedNameError and the rendering is that one error line. Co-Authored-By: jason.han --- internal/frontend/repl/filedocs_test.go | 17 ++++++++++++++++- internal/frontend/repl/load.go | 13 +++++++++++-- internal/frontend/repl/render.go | 10 ++++++++++ internal/frontend/repl/session.go | 24 ++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/internal/frontend/repl/filedocs_test.go b/internal/frontend/repl/filedocs_test.go index 2da469b9ee..6f4f8fdbdc 100644 --- a/internal/frontend/repl/filedocs_test.go +++ b/internal/frontend/repl/filedocs_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" "testing" @@ -310,10 +311,24 @@ func TestLoadRefusesAFileNamedAsTheTranscript(t *testing.T) { t.Errorf("LoadFile(%q) error = %v, want a *ReservedNameError", docName, err) } + // A direct submission has no error to return, so the whole of it is refused + // in the result: nothing accepted, the refusal all it renders. + res := s.SubmitFiles([]SourceFile{ + {Name: "ok.sysml", Text: "package Direct { part def D; }\n"}, + {Name: docName, Text: "package FromFile { part x : Missing; }\n"}, + }) + if !errors.As(res.Refused, &reserved) || len(res.Declared) != 0 || len(res.Origins) != 0 { + t.Errorf("a refused SubmitFiles = {Refused: %v, Declared: %v, Origins: %v}, want a *ReservedNameError and nothing else", + res.Refused, res.Declared, res.Origins) + } + want := []string{"error: cannot load : the name is reserved for the text typed at the prompt"} + if got := renderResult(res, VerbosityNormal); !slices.Equal(got, want) { + t.Errorf("a refused SubmitFiles rendered %q, want %q", got, want) + } if got := s.Text(); got != before { t.Errorf("the refused load changed the transcript:\n%s\nwas:\n%s", got, before) } - if got := strings.Join(s.List(), "\n"); strings.Contains(got, "FromFile") || !strings.Contains(got, "Typed") { + if got := strings.Join(s.List(), "\n"); strings.Contains(got, "FromFile") || strings.Contains(got, "Direct") || !strings.Contains(got, "Typed") { t.Errorf("the refused file's declarations must not enter the session; got %v", s.List()) } } diff --git a/internal/frontend/repl/load.go b/internal/frontend/repl/load.go index 57e126a92a..888ccd116b 100644 --- a/internal/frontend/repl/load.go +++ b/internal/frontend/repl/load.go @@ -106,8 +106,8 @@ func (s *Session) readSources(paths []string) ([]SourceFile, error) { if err != nil { return nil, readError(name, err) } - if name == docName { - return nil, &ReservedNameError{Name: name} + if err := reservedName(name); err != nil { + return nil, err } files = append(files, SourceFile{Name: name, Text: string(data)}) } @@ -124,6 +124,15 @@ func (e *ReservedNameError) Error() string { return fmt.Sprintf("cannot load %s: the name is reserved for the text typed at the prompt", e.Name) } +// reservedName is the *ReservedNameError refusing a file named as the +// transcript, nil for any other name. +func reservedName(name string) error { + if name != docName { + return nil + } + return &ReservedNameError{Name: name} +} + // ReadError is a file a load could not read, under the name it is reported by. type ReadError struct { Path string diff --git a/internal/frontend/repl/render.go b/internal/frontend/repl/render.go index 251646bdde..d9bcfad1b8 100644 --- a/internal/frontend/repl/render.go +++ b/internal/frontend/repl/render.go @@ -26,6 +26,10 @@ type Result struct { Origins []Origin // the files of THIS submission, in buffer order Notices []string // side effects of the submission, e.g. a debugging session it ended + // Refused is the *ReservedNameError a submission was refused for, nil when + // it was accepted; a refused submission changed nothing. + Refused error + // Blocked names the unresolved error that stopped the deeper checks from // running over this submission, nil when they ran or when the session already // reported that error. @@ -343,6 +347,9 @@ func renderResult(r Result, v Verbosity) []string { // analysis found apart from what the submission declared, so a caller outside // the prompt can send the two to different streams. func renderSplit(r Result, v Verbosity) (found, declared []string) { + if r.Refused != nil { + return []string{"error: " + r.Refused.Error()}, nil + } if v >= VerbosityDebug { // Everything the analysis produced over the whole buffer, at // buffer-absolute positions, plus where this submission landed in it. @@ -369,6 +376,9 @@ func renderSplit(r Result, v Verbosity) (found, declared []string) { // text just read rather than about the analysis of the model as a whole: a load // that defers the analysis still says why a file could not be read. func renderSyntax(r Result, v Verbosity) []string { + if r.Refused != nil { + return []string{"error: " + r.Refused.Error()} + } // A finding about the notation is no reason a file could not be read, and the // analysis this load defers reports it, so reporting it here would report it twice. var diags []diag.Diagnostic diff --git a/internal/frontend/repl/session.go b/internal/frontend/repl/session.go index dfaacae848..4aed6a279e 100644 --- a/internal/frontend/repl/session.go +++ b/internal/frontend/repl/session.go @@ -849,13 +849,33 @@ func (s *Session) submitAll(srcs []string) Result { // before the buffer is reindexed and analyzed, so a declaration in one resolves // against the others no matter which order they arrive in. This is what makes // loading a multi-file project order-independent. A file's Name is its -// workspace document, so it must not be the transcript's; the path loaders -// refuse such a file before it gets here. +// workspace document, so a file named as the transcript is refused: nothing is +// accepted and the result carries the *ReservedNameError as Refused. func (s *Session) SubmitFiles(files []SourceFile) Result { defer s.enter()() + for _, f := range files { + if err := reservedName(f.Name); err != nil { + return s.refuse(err) + } + } return s.submitFiles(files) } +// refuse is the result of a submission no part of which was accepted: the +// session as it stands, with nothing of its own but the refusal. +func (s *Session) refuse(err error) Result { + text := s.text() + return Result{ + Members: s.sessionMembers(), + Diagnostics: s.diagnostics(), + Source: text, + Offset: len(text), + Refused: err, + masked: s.maskedSpans(), + foreign: s.foreignSpans(), + } +} + func (s *Session) submitFiles(files []SourceFile) Result { res, _, _ := s.submitEach(files) return res