Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a436e4b
feat(repl): analyze each loaded file as a workspace document of its own
devin-ai-integration[bot] Sep 15, 2026
7154184
refactor(repl): parse a compound prompt expression after the transcri…
devin-ai-integration[bot] Sep 15, 2026
fb004ca
docs(skills): probes for per-file document isolation in the REPL test…
devin-ai-integration[bot] Sep 15, 2026
5ce0223
Merge branch 'develop' into feature/per-file-documents
devin-ai-integration[bot] Sep 15, 2026
72868cd
fix(repl): name a blocking error only from the submission's own document
devin-ai-integration[bot] Sep 15, 2026
7b9b73d
fix(repl): keep the transcript's blocker note through a file load
devin-ai-integration[bot] Sep 15, 2026
33d05db
fix(repl): let a load that resolves the standing error end its note's…
devin-ai-integration[bot] Sep 15, 2026
3c8d922
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
284a74f
fix(repl): take a masked file's declarations out of the index when no…
devin-ai-integration[bot] Sep 15, 2026
954e393
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
eb33938
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
ca319a3
Merge origin/develop into feature/per-file-documents
devin-ai-integration[bot] Sep 15, 2026
fa7a0d0
docs(repl): a repeated root name resolves by document name order, not…
devin-ai-integration[bot] Sep 15, 2026
839d74d
docs: recount the test inventory
devin-ai-integration[bot] Sep 15, 2026
47a35aa
docs(repl): note the prompt evaluation rules kept as they are under p…
devin-ai-integration[bot] Sep 15, 2026
822cd95
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
584a358
docs: regenerate the documentation counts after merging develop
devin-ai-integration[bot] Sep 15, 2026
c95c2a5
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
e57874b
docs: regenerate the documentation counts after merging develop
devin-ai-integration[bot] Sep 15, 2026
81f00b8
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 15, 2026
f96c40a
docs: regenerate the documentation counts after merging develop
devin-ai-integration[bot] Sep 15, 2026
2d96bfe
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
c5a7bef
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
431e0bd
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
e6df51b
docs: regenerate the test-function count
devin-ai-integration[bot] Sep 16, 2026
7943f2a
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
21eaa20
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
3ff0ed9
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
3df6b15
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
ad28197
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
6bc8b74
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
ab00383
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 16, 2026
a0abc6f
merge: develop into feature/per-file-documents
devin-ai-integration[bot] Sep 25, 2026
5d80722
docs(skills): repeated root packages resolve by document name, not lo…
devin-ai-integration[bot] Sep 25, 2026
9433042
feat(repl): refuse to load a file named as the transcript
devin-ai-integration[bot] Sep 25, 2026
72d7302
Merge remote-tracking branch 'origin/develop' into feature/per-file-d…
devin-ai-integration[bot] Sep 25, 2026
5ea2a95
fix(repl): locate a loaded document's spans by its own name after the…
devin-ai-integration[bot] Sep 25, 2026
0ca1956
fix(repl): refuse a direct submission of a file named as the transcript
devin-ai-integration[bot] Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .agents/skills/testing-sysml-repl/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2530,9 +2530,43 @@ its output rather than in an exit code — so assert on the exact rendered text:

## Multi-file projects: `%load <path>...` 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. 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.
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/frontend/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 <dir|glob|file>...` and `%load <path>...` expand to model files via
`internal/workspace/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. 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).
Expand Down
2 changes: 2 additions & 0 deletions changes/unreleased/per-file-documents.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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 `<repl>` is refused by every load.** `<repl>` is the name the REPL keeps the typed transcript under, so `%load <repl>` and `sysml -validate <repl>` report `cannot load <repl>: the name is reserved for the text typed at the prompt` before anything loads and exit as a read failure does; `./<repl>` loads it.
4 changes: 2 additions & 2 deletions cmd/sysml/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,8 +617,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())
Expand Down
38 changes: 38 additions & 0 deletions cmd/sysml/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<repl>"
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 <repl>: the name is reserved")
wantReport(t, checkPathsIn(t, dir, binary, "-constraint", "OK::Held", "ok.sysml", reserved), exitUnevaluable,
"cannot load <repl>: 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()
Expand Down
46 changes: 34 additions & 12 deletions docs/guide/04-repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,31 +130,53 @@ 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. The transcript is kept
under the name `<repl>`, which is therefore reserved: a file whose path is literally `<repl>`
is refused by `%load` and by the command line (`cannot load <repl>: 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
`./<repl>` 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
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)
```

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 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.

### 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

Expand Down
12 changes: 12 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ 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 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

### 1. Quick Calculation
Expand Down
5 changes: 2 additions & 3 deletions internal/frontend/repl/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,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,
Expand Down Expand Up @@ -295,7 +294,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.
Expand Down
Loading
Loading