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/18] 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/18] 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/18] 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 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 04/18] 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 05/18] 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 06/18] 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 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 07/18] 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 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 08/18] 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 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 09/18] 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 10/18] 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 11/18] 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 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 12/18] 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 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 13/18] 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 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 14/18] 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 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 15/18] 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 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 16/18] 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 17/18] 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 18/18] 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